chrome-ai-bridge 2.3.9 → 2.4.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.
@@ -0,0 +1,491 @@
1
+ /**
2
+ * chrome-ai-bridge Connect UI
3
+ * Extension2-style simple flow:
4
+ * 1. MCP server opens connect.html?mcpRelayUrl=ws://...
5
+ * 2. Tab list is displayed
6
+ * 3. User selects tab -> Click "Connect"
7
+ * 4. Done
8
+ */
9
+
10
+ class ConnectUI {
11
+ constructor() {
12
+ this.mcpRelayUrl = null;
13
+ this.sessionId = null;
14
+ this.allowTabTakeover = false;
15
+ this.autoMode = false;
16
+ this.autoTabId = null;
17
+ this.autoTabUrl = null;
18
+ this.autoNewTab = false;
19
+ this.debugPanelVisible = false;
20
+ this.autoRefreshInterval = null;
21
+
22
+ // DOM elements
23
+ this.statusEl = document.getElementById('status');
24
+ this.statusTextEl = document.getElementById('status-text');
25
+ this.statusIconEl = this.statusEl.querySelector('.status-icon');
26
+ this.tabSelectionEl = document.getElementById('tab-selection');
27
+ this.tabsListEl = document.getElementById('tabs-list');
28
+ this.connectedViewEl = document.getElementById('connected-view');
29
+ this.connectedTabTitleEl = document.getElementById('connected-tab-title');
30
+ this.connectedTabIdEl = document.getElementById('connected-tab-id');
31
+ this.disconnectBtnEl = document.getElementById('disconnect-btn');
32
+ this.errorViewEl = document.getElementById('error-view');
33
+ this.errorMessageEl = document.getElementById('error-message');
34
+
35
+ // Debug panel elements
36
+ this.debugToggleEl = document.getElementById('debug-toggle');
37
+ this.debugPanelEl = document.getElementById('debug-panel');
38
+ this.logFilterEl = document.getElementById('log-filter');
39
+ this.refreshLogsEl = document.getElementById('refresh-logs');
40
+ this.clearLogsEl = document.getElementById('clear-logs');
41
+ this.exportLogsEl = document.getElementById('export-logs');
42
+ this.debugStatsEl = document.getElementById('debug-stats');
43
+ this.logOutputEl = document.getElementById('log-output');
44
+
45
+ this.init();
46
+ this.initDebugPanel();
47
+ }
48
+
49
+ async init() {
50
+ try {
51
+ // Parse URL parameters (Extension2 style: parameters are always provided)
52
+ const params = new URLSearchParams(window.location.search);
53
+ this.mcpRelayUrl = params.get('mcpRelayUrl');
54
+ this.sessionId = params.get('sessionId');
55
+ this.allowTabTakeover = params.get('allowTabTakeover') === 'true';
56
+ this.autoMode = params.get('auto') === 'true';
57
+ this.autoTabUrl = params.get('tabUrl');
58
+ this.autoNewTab = params.get('newTab') === 'true';
59
+ const rawTabId = params.get('tabId');
60
+ this.autoTabId =
61
+ rawTabId && /^\d+$/.test(rawTabId) ? Number(rawTabId) : null;
62
+
63
+ // Validate relay URL
64
+ if (!this.mcpRelayUrl) {
65
+ this.showError('Missing mcpRelayUrl parameter. Make sure the MCP server is running.');
66
+ return;
67
+ }
68
+
69
+ if (!this.validateRelayUrl()) {
70
+ return;
71
+ }
72
+
73
+ if (this.autoMode) {
74
+ const connected = await this.tryAutoConnect();
75
+ if (connected) {
76
+ return;
77
+ }
78
+ }
79
+
80
+ // Show tab selection UI
81
+ await this.loadTabs();
82
+
83
+ } catch (error) {
84
+ this.showError(`Initialization failed: ${error.message}`);
85
+ }
86
+ }
87
+
88
+ validateRelayUrl() {
89
+ try {
90
+ const url = new URL(this.mcpRelayUrl);
91
+ if (!['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
92
+ this.showError('Invalid relay URL: must be loopback address (127.0.0.1)');
93
+ return false;
94
+ }
95
+ return true;
96
+ } catch {
97
+ this.showError('Invalid relay URL format');
98
+ return false;
99
+ }
100
+ }
101
+
102
+ async tryAutoConnect() {
103
+ if (!this.autoTabUrl && !this.autoTabId) {
104
+ return false;
105
+ }
106
+
107
+ try {
108
+ this.showStatus('Auto-connecting...', 'info', '⚡');
109
+
110
+ const relayResponse = await chrome.runtime.sendMessage({
111
+ type: 'connectToRelay',
112
+ mcpRelayUrl: this.mcpRelayUrl,
113
+ sessionId: this.sessionId,
114
+ });
115
+ if (!relayResponse || !relayResponse.success) {
116
+ throw new Error(relayResponse?.error || 'Relay connection failed');
117
+ }
118
+
119
+ const connectResponse = await chrome.runtime.sendMessage({
120
+ type: 'connectToTab',
121
+ mcpRelayUrl: this.mcpRelayUrl,
122
+ tabId: this.autoTabId,
123
+ tabUrl: this.autoTabUrl,
124
+ newTab: this.autoNewTab,
125
+ sessionId: this.sessionId,
126
+ allowTabTakeover: this.allowTabTakeover,
127
+ });
128
+ if (!connectResponse || !connectResponse.success) {
129
+ throw new Error(connectResponse?.error || 'Tab connection failed');
130
+ }
131
+
132
+ this.showStatus('Connected. Closing...', 'success', '✅');
133
+ setTimeout(() => {
134
+ window.close();
135
+ }, 300);
136
+ return true;
137
+ } catch (error) {
138
+ const message =
139
+ error && typeof error === 'object' && 'message' in error
140
+ ? error.message
141
+ : String(error);
142
+ this.showStatus(
143
+ `Auto-connect failed (${message}). Select tab manually.`,
144
+ 'error',
145
+ '⚠️',
146
+ );
147
+ return false;
148
+ }
149
+ }
150
+
151
+ async loadTabs() {
152
+ try {
153
+ const tabs = await chrome.tabs.query({});
154
+
155
+ // Filter out extension pages and chrome:// URLs
156
+ const filteredTabs = tabs.filter(tab => {
157
+ if (!tab.url) return false;
158
+ if (tab.url.startsWith('chrome://')) return false;
159
+ if (tab.url.startsWith('chrome-extension://')) return false;
160
+ if (tab.url.startsWith('devtools://')) return false;
161
+ return true;
162
+ });
163
+
164
+ if (filteredTabs.length === 0) {
165
+ this.showError('No tabs available. Open a web page first.');
166
+ return;
167
+ }
168
+
169
+ this.renderTabs(filteredTabs);
170
+ this.showStatus('Select a tab to connect', 'info', '📋');
171
+ this.tabSelectionEl.classList.remove('hidden');
172
+
173
+ } catch (error) {
174
+ this.showError(`Failed to load tabs: ${error.message}`);
175
+ }
176
+ }
177
+
178
+ renderTabs(tabs) {
179
+ this.tabsListEl.innerHTML = '';
180
+
181
+ // Sort tabs: active tab first, then by window/tab order
182
+ const sortedTabs = [...tabs].sort((a, b) => {
183
+ if (a.active && !b.active) return -1;
184
+ if (!a.active && b.active) return 1;
185
+ if (a.windowId !== b.windowId) return a.windowId - b.windowId;
186
+ return a.index - b.index;
187
+ });
188
+
189
+ sortedTabs.forEach(tab => {
190
+ const tabItem = document.createElement('div');
191
+ tabItem.className = `tab-item${tab.active ? ' active' : ''}`;
192
+ tabItem.dataset.tabId = tab.id;
193
+
194
+ // Favicon
195
+ const faviconEl = document.createElement('div');
196
+ faviconEl.className = 'tab-favicon';
197
+ if (tab.favIconUrl && !tab.favIconUrl.startsWith('chrome://')) {
198
+ const img = document.createElement('img');
199
+ img.src = tab.favIconUrl;
200
+ img.onerror = () => { faviconEl.textContent = '🌐'; };
201
+ faviconEl.appendChild(img);
202
+ } else {
203
+ faviconEl.textContent = '🌐';
204
+ }
205
+
206
+ // Tab info
207
+ const infoEl = document.createElement('div');
208
+ infoEl.className = 'tab-info';
209
+
210
+ const titleRow = document.createElement('div');
211
+ titleRow.className = 'tab-title-row';
212
+
213
+ const titleEl = document.createElement('span');
214
+ titleEl.className = 'tab-title';
215
+ titleEl.textContent = tab.title || 'Untitled';
216
+ titleRow.appendChild(titleEl);
217
+
218
+ if (tab.active) {
219
+ const badge = document.createElement('span');
220
+ badge.className = 'tab-active-badge';
221
+ badge.textContent = 'Active';
222
+ titleRow.appendChild(badge);
223
+ }
224
+
225
+ const urlEl = document.createElement('div');
226
+ urlEl.className = 'tab-url';
227
+ urlEl.textContent = tab.url || '';
228
+
229
+ const idEl = document.createElement('div');
230
+ idEl.className = 'tab-id';
231
+ idEl.textContent = `Tab ID: ${tab.id}`;
232
+
233
+ infoEl.appendChild(titleRow);
234
+ infoEl.appendChild(urlEl);
235
+ infoEl.appendChild(idEl);
236
+
237
+ // Connect button
238
+ const connectBtn = document.createElement('button');
239
+ connectBtn.className = 'tab-connect-btn';
240
+ connectBtn.textContent = 'Connect';
241
+ connectBtn.addEventListener('click', (e) => {
242
+ e.stopPropagation();
243
+ this.connectToTab(tab);
244
+ });
245
+
246
+ tabItem.appendChild(faviconEl);
247
+ tabItem.appendChild(infoEl);
248
+ tabItem.appendChild(connectBtn);
249
+
250
+ // Also connect on row click
251
+ tabItem.addEventListener('click', () => {
252
+ this.connectToTab(tab);
253
+ });
254
+
255
+ this.tabsListEl.appendChild(tabItem);
256
+ });
257
+ }
258
+
259
+ async connectToTab(tab) {
260
+ try {
261
+ this.showStatus(`Connecting to "${tab.title}"...`, 'info', '⏳');
262
+
263
+ // Step 1: Connect to relay
264
+ const relayResponse = await chrome.runtime.sendMessage({
265
+ type: 'connectToRelay',
266
+ mcpRelayUrl: this.mcpRelayUrl,
267
+ sessionId: this.sessionId,
268
+ });
269
+
270
+ if (!relayResponse || !relayResponse.success) {
271
+ throw new Error(relayResponse?.error || 'Relay connection failed');
272
+ }
273
+
274
+ // Step 2: Connect to tab
275
+ const connectResponse = await chrome.runtime.sendMessage({
276
+ type: 'connectToTab',
277
+ mcpRelayUrl: this.mcpRelayUrl,
278
+ tabId: tab.id,
279
+ windowId: tab.windowId,
280
+ sessionId: this.sessionId,
281
+ allowTabTakeover: this.allowTabTakeover,
282
+ });
283
+
284
+ if (!connectResponse || !connectResponse.success) {
285
+ throw new Error(connectResponse?.error || 'Tab connection failed');
286
+ }
287
+
288
+ // Show connected view
289
+ this.tabSelectionEl.classList.add('hidden');
290
+ this.connectedViewEl.classList.remove('hidden');
291
+ this.connectedTabTitleEl.textContent = tab.title || 'Untitled';
292
+ this.connectedTabIdEl.textContent = tab.id;
293
+ this.showStatus('Connected', 'success', '✅');
294
+
295
+ // Set up disconnect button
296
+ this.disconnectBtnEl.onclick = () => this.disconnect(tab.id);
297
+
298
+ } catch (error) {
299
+ this.showError(`Connection failed: ${error.message}`);
300
+ }
301
+ }
302
+
303
+ async disconnect(tabId) {
304
+ try {
305
+ await chrome.runtime.sendMessage({
306
+ type: 'disconnect',
307
+ tabId: tabId
308
+ });
309
+ } catch {
310
+ // Ignore disconnect errors
311
+ }
312
+ this.reset();
313
+ }
314
+
315
+ reset() {
316
+ this.connectedViewEl.classList.add('hidden');
317
+ this.errorViewEl.classList.add('hidden');
318
+ this.loadTabs();
319
+ }
320
+
321
+ showStatus(message, type = 'info', icon = '⏳') {
322
+ this.statusTextEl.textContent = message;
323
+ this.statusIconEl.textContent = icon;
324
+ this.statusEl.className = `status ${type}`;
325
+ this.statusEl.classList.remove('hidden');
326
+ this.errorViewEl.classList.add('hidden');
327
+ }
328
+
329
+ showError(message) {
330
+ this.showStatus(message, 'error', '❌');
331
+ this.tabSelectionEl.classList.add('hidden');
332
+ this.connectedViewEl.classList.add('hidden');
333
+ this.errorViewEl.classList.remove('hidden');
334
+ this.errorMessageEl.textContent = message;
335
+ }
336
+
337
+ escapeHtml(text) {
338
+ const div = document.createElement('div');
339
+ div.textContent = text;
340
+ return div.innerHTML;
341
+ }
342
+
343
+ // ========== Debug Panel Methods ==========
344
+
345
+ initDebugPanel() {
346
+ this.debugToggleEl.addEventListener('click', () => {
347
+ this.toggleDebugPanel();
348
+ });
349
+
350
+ this.logFilterEl.addEventListener('change', () => {
351
+ this.refreshLogs();
352
+ });
353
+
354
+ this.refreshLogsEl.addEventListener('click', () => {
355
+ this.refreshLogs();
356
+ });
357
+
358
+ this.clearLogsEl.addEventListener('click', () => {
359
+ this.clearLogs();
360
+ });
361
+
362
+ this.exportLogsEl.addEventListener('click', () => {
363
+ this.exportLogs();
364
+ });
365
+ }
366
+
367
+ toggleDebugPanel() {
368
+ this.debugPanelVisible = !this.debugPanelVisible;
369
+ if (this.debugPanelVisible) {
370
+ this.debugPanelEl.classList.remove('hidden');
371
+ this.debugToggleEl.textContent = 'Hide Debug Logs';
372
+ this.refreshLogs();
373
+ this.autoRefreshInterval = setInterval(() => this.refreshLogs(), 2000);
374
+ } else {
375
+ this.debugPanelEl.classList.add('hidden');
376
+ this.debugToggleEl.textContent = 'Show Debug Logs';
377
+ if (this.autoRefreshInterval) {
378
+ clearInterval(this.autoRefreshInterval);
379
+ this.autoRefreshInterval = null;
380
+ }
381
+ }
382
+ }
383
+
384
+ async refreshLogs() {
385
+ try {
386
+ const filter = this.logFilterEl.value || null;
387
+ const response = await chrome.runtime.sendMessage({
388
+ type: 'getDebugLogs',
389
+ filter: filter,
390
+ limit: 100
391
+ });
392
+
393
+ if (!response || !response.success) {
394
+ this.logOutputEl.textContent = 'Failed to fetch logs';
395
+ return;
396
+ }
397
+
398
+ // Update stats
399
+ const stats = response.stats;
400
+ const state = response.state;
401
+ this.debugStatsEl.innerHTML = `
402
+ <strong>Total Logs:</strong> ${stats.total} |
403
+ <strong>Active Connections:</strong> ${state.activeConnections?.length || 0} |
404
+ <strong>Pending:</strong> ${state.pendingTabSelection?.length || 0}
405
+ <br>
406
+ <strong>By Category:</strong>
407
+ ${Object.entries(stats.byCategory || {}).map(([cat, count]) => `${cat}: ${count}`).join(', ') || 'none'}
408
+ `;
409
+
410
+ // Render logs
411
+ const logs = response.logs || [];
412
+ if (logs.length === 0) {
413
+ this.logOutputEl.innerHTML = '<span style="color: #888;">No logs yet</span>';
414
+ return;
415
+ }
416
+
417
+ const html = logs.map(log => this.formatLogEntry(log)).join('');
418
+ this.logOutputEl.innerHTML = html;
419
+
420
+ // Scroll to bottom
421
+ this.logOutputEl.scrollTop = this.logOutputEl.scrollHeight;
422
+ } catch (error) {
423
+ this.logOutputEl.textContent = `Error: ${error.message}`;
424
+ }
425
+ }
426
+
427
+ formatLogEntry(log) {
428
+ const ts = log.ts ? log.ts.split('T')[1].split('.')[0] : '';
429
+ const cat = log.category || 'unknown';
430
+ const catClass = `cat-${cat}`;
431
+ const msg = this.escapeHtml(log.message || '');
432
+ const data = log.data ? this.escapeHtml(JSON.stringify(log.data)) : '';
433
+
434
+ return `<div class="log-entry">` +
435
+ `<span class="ts">${ts}</span> ` +
436
+ `<span class="${catClass}">[${cat.toUpperCase()}]</span> ` +
437
+ `<span class="msg">${msg}</span>` +
438
+ (data ? `<span class="data">${data}</span>` : '') +
439
+ `</div>`;
440
+ }
441
+
442
+ async clearLogs() {
443
+ try {
444
+ await chrome.runtime.sendMessage({ type: 'clearDebugLogs' });
445
+ this.refreshLogs();
446
+ } catch (error) {
447
+ this.showError(`Failed to clear logs: ${error.message}`);
448
+ }
449
+ }
450
+
451
+ async exportLogs() {
452
+ try {
453
+ const response = await chrome.runtime.sendMessage({
454
+ type: 'getDebugLogs',
455
+ filter: null,
456
+ limit: 500
457
+ });
458
+
459
+ if (!response || !response.success) {
460
+ this.showError('Failed to export logs');
461
+ return;
462
+ }
463
+
464
+ const exportData = {
465
+ timestamp: new Date().toISOString(),
466
+ stats: response.stats,
467
+ state: response.state,
468
+ logs: response.logs
469
+ };
470
+
471
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
472
+ const url = URL.createObjectURL(blob);
473
+ const a = document.createElement('a');
474
+ a.href = url;
475
+ a.download = `chrome-ai-bridge-debug-${Date.now()}.json`;
476
+ a.click();
477
+ URL.revokeObjectURL(url);
478
+ } catch (error) {
479
+ this.showError(`Failed to export logs: ${error.message}`);
480
+ }
481
+ }
482
+ }
483
+
484
+ // Initialize when DOM is ready
485
+ if (document.readyState === 'loading') {
486
+ document.addEventListener('DOMContentLoaded', () => {
487
+ new ConnectUI();
488
+ });
489
+ } else {
490
+ new ConnectUI();
491
+ }
@@ -29,6 +29,7 @@ export class RelayServer extends EventEmitter {
29
29
  discoveryServer = null;
30
30
  discoveryPort = null;
31
31
  keepAliveTimer = null;
32
+ _lastDiscoveryOptions = {};
32
33
  constructor(options = {}) {
33
34
  super();
34
35
  this.host = options.host || '127.0.0.1';
@@ -172,6 +173,9 @@ export class RelayServer extends EventEmitter {
172
173
  this.ready = true;
173
174
  debugLog(`[RelayServer] Connection ready for tab ${this.tabId}`);
174
175
  this.emit('ready', this.tabId);
176
+ // Release discovery port after WebSocket is established.
177
+ // 1-second grace period lets Extension finish processing the response.
178
+ setTimeout(() => this.stopDiscoveryServer(), 1000);
175
179
  break;
176
180
  case 'pong':
177
181
  debugLog('[RelayServer] Received keep-alive pong');
@@ -269,6 +273,7 @@ export class RelayServer extends EventEmitter {
269
273
  * Extension polls this endpoint when user clicks the extension icon.
270
274
  */
271
275
  async startDiscoveryServer(options = {}) {
276
+ this._lastDiscoveryOptions = options;
272
277
  const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
273
278
  const wsUrl = this.getConnectionURL();
274
279
  for (const port of ports) {
@@ -332,6 +337,27 @@ export class RelayServer extends EventEmitter {
332
337
  debugLog('[RelayServer] Could not start discovery server on any port');
333
338
  return null;
334
339
  }
340
+ /**
341
+ * Release the discovery HTTP server (port).
342
+ * Called automatically after the Extension WebSocket connects (ready event).
343
+ * The port becomes available for other sessions.
344
+ */
345
+ stopDiscoveryServer() {
346
+ if (this.discoveryServer) {
347
+ this.discoveryServer.close();
348
+ debugLog(`[RelayServer] Discovery server released (port ${this.discoveryPort})`);
349
+ this.discoveryServer = null;
350
+ this.discoveryPort = null;
351
+ }
352
+ }
353
+ /**
354
+ * Re-acquire a discovery port (e.g. after WebSocket disconnect for reconnection).
355
+ * Uses the same options as the last startDiscoveryServer() call.
356
+ */
357
+ async restartDiscoveryServer(options) {
358
+ this.stopDiscoveryServer();
359
+ return this.startDiscoveryServer(options || this._lastDiscoveryOptions);
360
+ }
335
361
  /**
336
362
  * Stop server
337
363
  */
@@ -349,11 +375,7 @@ export class RelayServer extends EventEmitter {
349
375
  this.ready = false;
350
376
  this.tabId = null;
351
377
  this.rejectPendingRequests(new Error('RELAY_STOPPED: Relay stopped before request completion'));
352
- if (this.discoveryServer) {
353
- this.discoveryServer.close();
354
- this.discoveryServer = null;
355
- this.discoveryPort = null;
356
- }
378
+ this.stopDiscoveryServer();
357
379
  if (this.wss) {
358
380
  return new Promise((resolve) => {
359
381
  this.wss.close(() => {
@@ -5,7 +5,10 @@ import { RelayServer } from '../extension/relay-server.js';
5
5
  import { logRelay, logExtension, logInfo, logError } from './mcp-logger.js';
6
6
  // Stable extension ID (from manifest.json key)
7
7
  const EXTENSION_ID = 'ibjplbopgmcacpmfpnaeoloepdhenlbm';
8
- const ENABLE_WAKE_CONNECT_PAGE = process.env.CAI_ENABLE_WAKE_CONNECT_PAGE !== '0';
8
+ // Wake connect page disabled by default — it opens a chrome-extension:// URL
9
+ // that gets ERR_BLOCKED_BY_CLIENT and annoys users. Discovery polling is the
10
+ // primary mechanism; wake is only useful in rare edge cases.
11
+ const ENABLE_WAKE_CONNECT_PAGE = process.env.CAI_ENABLE_WAKE_CONNECT_PAGE === '1';
9
12
  /**
10
13
  * Get Chrome executable path for current platform
11
14
  */
@@ -2032,16 +2032,20 @@ async function askChatGPTFastInternal(question, debug) {
2032
2032
  summary: interceptor.getSummary(),
2033
2033
  });
2034
2034
  // Hybrid: prefer network text (primary), DOM as fallback
2035
+ // Use network if it captured anything and is at least 50% of DOM length
2036
+ // (avoids using truncated network text when DOM has the full answer)
2035
2037
  let hybridAnswer = finalAnswer;
2036
2038
  let answerSource = 'dom';
2037
- if (networkResult.text.length > 50) {
2039
+ const netLen = networkResult.text.length;
2040
+ const domLen = finalAnswer.length;
2041
+ if (netLen > 0 && (domLen === 0 || netLen >= domLen * 0.5)) {
2038
2042
  hybridAnswer = networkResult.text;
2039
2043
  answerSource = 'network';
2040
2044
  }
2041
2045
  logInfo('chatgpt', 'Answer source selected', {
2042
2046
  source: answerSource,
2043
- networkLen: networkResult.text.length,
2044
- domLen: finalAnswer.length,
2047
+ networkLen: netLen,
2048
+ domLen,
2045
2049
  });
2046
2050
  return { answer: hybridAnswer, timings: fullTimings, debug: debugInfo };
2047
2051
  }
@@ -3030,17 +3034,20 @@ async function askGeminiFastInternal(question, debug) {
3030
3034
  });
3031
3035
  // Hybrid: prefer network text (primary), DOM as fallback
3032
3036
  // Normalize network text with same Gemini-specific cleanup as DOM text
3037
+ // Use network if it captured anything and is at least 50% of DOM length
3033
3038
  const networkNormalized = normalizeGeminiResponse(networkResult.text, question);
3034
3039
  let hybridAnswer = normalized;
3035
3040
  let answerSource = 'dom';
3036
- if (networkNormalized.length > 50) {
3041
+ const netLen = networkNormalized.length;
3042
+ const domLen = normalized.length;
3043
+ if (netLen > 0 && (domLen === 0 || netLen >= domLen * 0.5)) {
3037
3044
  hybridAnswer = networkNormalized;
3038
3045
  answerSource = 'network';
3039
3046
  }
3040
3047
  logInfo('gemini', 'Answer source selected', {
3041
3048
  source: answerSource,
3042
- networkLen: networkNormalized.length,
3043
- domLen: normalized.length,
3049
+ networkLen: netLen,
3050
+ domLen,
3044
3051
  });
3045
3052
  return { answer: hybridAnswer, timings: fullTimings, debug: debugInfo };
3046
3053
  }