@hileeon/mcc 0.1.8 → 0.1.9

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.
Files changed (86) hide show
  1. package/README.md +226 -127
  2. package/dist/accounts/store.d.ts +1 -0
  3. package/dist/accounts/store.d.ts.map +1 -1
  4. package/dist/accounts/store.js.map +1 -1
  5. package/dist/commands/launch.d.ts +9 -0
  6. package/dist/commands/launch.d.ts.map +1 -0
  7. package/dist/commands/launch.js +158 -0
  8. package/dist/commands/launch.js.map +1 -0
  9. package/dist/commands/mcp.d.ts +9 -0
  10. package/dist/commands/mcp.d.ts.map +1 -0
  11. package/dist/commands/mcp.js +112 -0
  12. package/dist/commands/mcp.js.map +1 -0
  13. package/dist/commands/profile.d.ts +8 -0
  14. package/dist/commands/profile.d.ts.map +1 -0
  15. package/dist/commands/profile.js +125 -0
  16. package/dist/commands/profile.js.map +1 -0
  17. package/dist/core/model-router.d.ts.map +1 -1
  18. package/dist/core/model-router.js +5 -2
  19. package/dist/core/model-router.js.map +1 -1
  20. package/dist/{dashboard-server.d.ts → dashboard/server.d.ts} +1 -1
  21. package/dist/dashboard/server.d.ts.map +1 -0
  22. package/dist/{dashboard-server.js → dashboard/server.js} +169 -51
  23. package/dist/dashboard/server.js.map +1 -0
  24. package/dist/mcc.d.ts +4 -2
  25. package/dist/mcc.d.ts.map +1 -1
  26. package/dist/mcc.js +121 -408
  27. package/dist/mcc.js.map +1 -1
  28. package/dist/mcp/mcp-config.d.ts +17 -1
  29. package/dist/mcp/mcp-config.d.ts.map +1 -1
  30. package/dist/mcp/mcp-config.js +50 -17
  31. package/dist/mcp/mcp-config.js.map +1 -1
  32. package/dist/proxy/proxy-daemon.d.ts.map +1 -1
  33. package/dist/proxy/proxy-daemon.js +17 -2
  34. package/dist/proxy/proxy-daemon.js.map +1 -1
  35. package/dist/proxy/proxy-entry.js +5 -3
  36. package/dist/proxy/proxy-entry.js.map +1 -1
  37. package/dist/proxy/proxy-server.d.ts.map +1 -1
  38. package/dist/proxy/proxy-server.js +32 -6
  39. package/dist/proxy/proxy-server.js.map +1 -1
  40. package/dist/shared/config.d.ts +15 -0
  41. package/dist/shared/config.d.ts.map +1 -0
  42. package/dist/shared/config.js +79 -0
  43. package/dist/shared/config.js.map +1 -0
  44. package/dist/shared/logger.d.ts +23 -18
  45. package/dist/shared/logger.d.ts.map +1 -1
  46. package/dist/shared/logger.js +17 -178
  47. package/dist/shared/logger.js.map +1 -1
  48. package/dist/shared/provider-preset-catalog.d.ts +6 -2
  49. package/dist/shared/provider-preset-catalog.d.ts.map +1 -1
  50. package/dist/shared/provider-preset-catalog.js +47 -26
  51. package/dist/shared/provider-preset-catalog.js.map +1 -1
  52. package/dist/ui/assets/index-ClqmrjNk.js +40 -0
  53. package/dist/ui/assets/index-CwMwQ-Z4.css +1 -0
  54. package/dist/ui/index.html +21 -13
  55. package/dist/update.d.ts +31 -0
  56. package/dist/update.d.ts.map +1 -0
  57. package/dist/update.js +196 -0
  58. package/dist/update.js.map +1 -0
  59. package/lib/mcp/mcc-image-analysis-server.cjs +454 -454
  60. package/lib/mcp/mcc-websearch-server.cjs +339 -339
  61. package/lib/mcp-hooks/image-analysis-runtime.cjs +510 -510
  62. package/lib/mcp-hooks/image-analyzer-transformer.cjs +526 -526
  63. package/lib/mcp-hooks/websearch-transformer.cjs +1597 -1421
  64. package/lib/proxy/config/config-loader-facade.js +24 -24
  65. package/lib/proxy/glmt/delta-accumulator.js +362 -362
  66. package/lib/proxy/glmt/glmt-transformer.js +203 -203
  67. package/lib/proxy/glmt/index.js +40 -40
  68. package/lib/proxy/glmt/locale-enforcer.js +68 -68
  69. package/lib/proxy/glmt/pipeline/content-transformer.js +161 -161
  70. package/lib/proxy/glmt/pipeline/index.js +19 -19
  71. package/lib/proxy/glmt/pipeline/request-transformer.js +115 -115
  72. package/lib/proxy/glmt/pipeline/response-builder.js +204 -204
  73. package/lib/proxy/glmt/pipeline/stream-parser.js +233 -233
  74. package/lib/proxy/glmt/pipeline/tool-call-handler.js +77 -77
  75. package/lib/proxy/glmt/pipeline/types.js +5 -5
  76. package/lib/proxy/glmt/reasoning-enforcer.js +150 -150
  77. package/lib/proxy/glmt/sse-parser.js +101 -101
  78. package/lib/proxy/services/logging.js +13 -13
  79. package/lib/proxy/transformers/request-transformer.js +471 -471
  80. package/lib/proxy/transformers/sse-stream-transformer.js +198 -198
  81. package/lib/shared/logger.cjs +156 -138
  82. package/package.json +58 -41
  83. package/dist/dashboard-server.d.ts.map +0 -1
  84. package/dist/dashboard-server.js.map +0 -1
  85. package/dist/ui/assets/index-B16lhKZ6.js +0 -40
  86. package/dist/ui/assets/index-jEfiB6-h.css +0 -1
@@ -1,1421 +1,1597 @@
1
- #!/usr/bin/env node
2
- /**
3
- * CCS WebSearch Hook - deterministic search backends with legacy CLI fallback
4
- *
5
- * Primary providers:
6
- * - Exa Search API
7
- * - Tavily Search API
8
- * - Brave Search API
9
- * - SearXNG JSON API
10
- * - DuckDuckGo HTML search
11
- *
12
- * Legacy compatibility fallback:
13
- * - Gemini CLI
14
- * - OpenCode
15
- * - Grok CLI
16
- */
17
-
18
- const { spawnSync } = require('child_process');
19
- const { createHash } = require('crypto');
20
- const fs = require('fs');
21
- const os = require('os');
22
- const path = require('path');
23
-
24
- const isWindows = process.platform === 'win32';
25
- const DEFAULT_TIMEOUT_SEC = 55;
26
- const DEFAULT_RESULT_COUNT = 5;
27
- const MIN_VALID_RESPONSE_LENGTH = 20;
28
- const EXA_URL = 'https://api.exa.ai/search';
29
- const TAVILY_URL = 'https://api.tavily.com/search';
30
- const DDG_URL = 'https://html.duckduckgo.com/html/';
31
- const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search';
32
- const USER_AGENT =
33
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
34
- const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
35
- const SHORT_RETRY_AFTER_MAX_SEC = 3;
36
- const TRANSIENT_RETRY_DELAY_MS = 750;
37
- const TRANSIENT_RETRY_ATTEMPTS = 1;
38
- const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120;
39
- const DEFAULT_QUOTA_COOLDOWN_SEC = 900;
40
- const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60;
41
-
42
- const SHARED_INSTRUCTIONS = `Instructions:
43
- 1. Search the web for current, up-to-date information
44
- 2. Provide a comprehensive summary of the search results
45
- 3. Include relevant URLs/sources when available
46
- 4. Be concise but thorough - prioritize key facts
47
- 5. Focus on factual information from reliable sources
48
- 6. If results conflict, note the discrepancy
49
- 7. Format output clearly with sections if the topic is complex`;
50
-
51
- const PROVIDER_CONFIG = {
52
- gemini: {
53
- model: 'gemini-2.5-flash',
54
- toolInstruction: 'Use the google_web_search tool to find current information.',
55
- quirks: null,
56
- },
57
- opencode: {
58
- model: 'opencode/grok-code',
59
- toolInstruction: 'Search the web using your built-in capabilities.',
60
- quirks: null,
61
- },
62
- grok: {
63
- model: 'grok-3',
64
- toolInstruction: 'Use your web search capabilities to find information.',
65
- quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.',
66
- },
67
- };
68
-
69
- const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
70
- const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g;
71
- const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i;
72
- const ddgNoResultsHeadingRe = /No results found for/i;
73
- const htmlTagRe = /<[^>]+>/g;
74
-
75
- // Shared logger — set MCC_LOG_LEVEL=debug to enable
76
- const logger = require('../shared/logger.cjs');
77
- const log = require('../shared/logger.cjs');
78
-
79
- function debug(message) {
80
- log.debug('WebSearch', message);
81
- }
82
-
83
- function getMccDirPath() {
84
- if ((process.env.MCC_DIR || '').trim()) {
85
- return path.resolve(process.env.MCC_DIR.trim());
86
- }
87
-
88
- if ((process.env.MCC_HOME || '').trim()) {
89
- return path.join(path.resolve(process.env.MCC_HOME.trim()), '.mcc');
90
- }
91
-
92
- const home = (process.env.HOME || process.env.USERPROFILE || '').trim();
93
- if (home) {
94
- return path.join(home, '.mcc');
95
- }
96
-
97
- return path.join(process.cwd(), '.mcc');
98
- }
99
-
100
- function isTraceEnabled() {
101
- return process.env.MCC_WEBSEARCH_TRACE === '1';
102
- }
103
-
104
- function normalizeSafePrefix(inputPath) {
105
- return `${path.resolve(inputPath)}${path.sep}`;
106
- }
107
-
108
- function getSafeTracePrefixes() {
109
- return [
110
- normalizeSafePrefix(path.join(getMccDirPath(), 'logs')),
111
- normalizeSafePrefix(os.tmpdir()),
112
- normalizeSafePrefix('/var/log'),
113
- ];
114
- }
115
-
116
- function getProviderStatePath() {
117
- return path.join(getMccDirPath(), 'cache', PROVIDER_STATE_FILE);
118
- }
119
-
120
- function readProviderState() {
121
- try {
122
- const statePath = getProviderStatePath();
123
- if (!fs.existsSync(statePath)) {
124
- return { cooldowns: {} };
125
- }
126
-
127
- const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
128
- const cooldowns =
129
- parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object'
130
- ? parsed.cooldowns
131
- : {};
132
- return { cooldowns };
133
- } catch {
134
- return { cooldowns: {} };
135
- }
136
- }
137
-
138
- function writeProviderState(state) {
139
- try {
140
- const statePath = getProviderStatePath();
141
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
142
- const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
143
- fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
144
- fs.renameSync(tempPath, statePath);
145
- } catch {
146
- // Best-effort only.
147
- }
148
- }
149
-
150
- function sanitizeProviderState(state) {
151
- const now = Date.now();
152
- const nextCooldowns = {};
153
- let changed = false;
154
-
155
- for (const [providerId, entry] of Object.entries(state.cooldowns || {})) {
156
- if (!entry || typeof entry !== 'object') {
157
- changed = true;
158
- continue;
159
- }
160
-
161
- const until = Number.parseInt(String(entry.until || ''), 10);
162
- if (!Number.isFinite(until) || until <= now) {
163
- changed = true;
164
- continue;
165
- }
166
-
167
- nextCooldowns[providerId] = {
168
- until,
169
- reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited',
170
- updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now,
171
- sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '',
172
- };
173
- }
174
-
175
- return {
176
- state: { cooldowns: nextCooldowns },
177
- changed,
178
- };
179
- }
180
-
181
- function getProviderCooldown(providerId) {
182
- const { state, changed } = sanitizeProviderState(readProviderState());
183
- if (changed) {
184
- writeProviderState(state);
185
- }
186
-
187
- return state.cooldowns[providerId] || null;
188
- }
189
-
190
- function clearProviderCooldown(providerId) {
191
- const { state } = sanitizeProviderState(readProviderState());
192
- if (!(providerId in state.cooldowns)) {
193
- return;
194
- }
195
-
196
- delete state.cooldowns[providerId];
197
- writeProviderState(state);
198
- }
199
-
200
- function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) {
201
- const clampedCooldownSec = Math.max(
202
- 1,
203
- Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec))
204
- );
205
- const { state } = sanitizeProviderState(readProviderState());
206
- const until = Date.now() + clampedCooldownSec * 1000;
207
- state.cooldowns[providerId] = {
208
- until,
209
- reason,
210
- updatedAt: Date.now(),
211
- sourceError: sourceError || '',
212
- };
213
- writeProviderState(state);
214
- return until;
215
- }
216
-
217
- function sleep(ms) {
218
- return new Promise((resolve) => setTimeout(resolve, ms));
219
- }
220
-
221
- function getAllowedTraceFileOverride() {
222
- const configured = (process.env.MCC_WEBSEARCH_TRACE_FILE || '').trim();
223
- if (!configured) {
224
- return null;
225
- }
226
-
227
- const resolved = path.resolve(configured);
228
- if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) {
229
- return resolved;
230
- }
231
-
232
- return null;
233
- }
234
-
235
- function getTraceFilePath() {
236
- const fallback = path.join(getMccDirPath(), 'logs', 'websearch-trace.jsonl');
237
- return getAllowedTraceFileOverride() || fallback;
238
- }
239
-
240
- function traceWebSearchEvent(event, payload = {}) {
241
- if (!isTraceEnabled()) {
242
- return;
243
- }
244
-
245
- try {
246
- const traceFilePath = getTraceFilePath();
247
- fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
248
- fs.appendFileSync(
249
- traceFilePath,
250
- JSON.stringify({
251
- at: new Date().toISOString(),
252
- event,
253
- launchId: process.env.MCC_WEBSEARCH_TRACE_LAUNCH_ID || null,
254
- launcher: process.env.MCC_WEBSEARCH_TRACE_LAUNCHER || null,
255
- profileType: process.env.MCC_PROFILE_TYPE || null,
256
- pid: process.pid,
257
- ...payload,
258
- }) + '\n',
259
- 'utf8'
260
- );
261
- } catch {
262
- // Best-effort only.
263
- }
264
- }
265
-
266
- function readHeaderValue(headers, headerName) {
267
- if (!headers) {
268
- return '';
269
- }
270
-
271
- if (typeof headers.get === 'function') {
272
- return headers.get(headerName) || '';
273
- }
274
-
275
- const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()];
276
- if (Array.isArray(direct)) {
277
- return direct[0] || '';
278
- }
279
- return typeof direct === 'string' ? direct : '';
280
- }
281
-
282
- function parseRetryAfterSeconds(rawValue) {
283
- const value = String(rawValue || '').trim();
284
- if (!value) {
285
- return null;
286
- }
287
-
288
- const asSeconds = Number.parseInt(value, 10);
289
- if (Number.isFinite(asSeconds) && asSeconds > 0) {
290
- return asSeconds;
291
- }
292
-
293
- const asDate = Date.parse(value);
294
- if (Number.isFinite(asDate)) {
295
- const deltaSec = Math.ceil((asDate - Date.now()) / 1000);
296
- return deltaSec > 0 ? deltaSec : null;
297
- }
298
-
299
- return null;
300
- }
301
-
302
- function getQueryFingerprint(query) {
303
- const normalizedQuery = typeof query === 'string' ? query.trim() : '';
304
- return {
305
- queryHash: normalizedQuery
306
- ? createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16)
307
- : null,
308
- queryLength: normalizedQuery.length,
309
- };
310
- }
311
-
312
- function getSkipReason() {
313
- if (process.env.MCC_WEBSEARCH_SKIP === '1') return 'skip_flag';
314
- const profileType = process.env.MCC_PROFILE_TYPE;
315
- if (profileType === 'account') return 'native_account_profile';
316
- if (profileType === 'default') return 'native_default_profile';
317
- if (process.env.MCC_WEBSEARCH_ENABLED === '0') return 'disabled';
318
- return null;
319
- }
320
-
321
- function shouldSkipHook() {
322
- return getSkipReason() !== null;
323
- }
324
-
325
- function isCliAvailable(cmd) {
326
- try {
327
- const whichCmd = isWindows ? 'where.exe' : 'which';
328
- const result = spawnSync(whichCmd, [cmd], {
329
- encoding: 'utf8',
330
- timeout: 2000,
331
- stdio: ['pipe', 'pipe', 'pipe'],
332
- });
333
- return result.status === 0 && result.stdout.trim().length > 0;
334
- } catch {
335
- return false;
336
- }
337
- }
338
-
339
- function isProviderEnabled(provider) {
340
- return process.env[`MCC_WEBSEARCH_${provider.toUpperCase()}`] === '1';
341
- }
342
-
343
- function hasEnvValue(name) {
344
- return (process.env[name] || '').trim().length > 0;
345
- }
346
-
347
- function getFirstEnvValue(names) {
348
- for (const name of names) {
349
- if (hasEnvValue(name)) {
350
- return process.env[name].trim();
351
- }
352
- }
353
- return '';
354
- }
355
-
356
- function getProviderApiKey(providerId) {
357
- switch (providerId) {
358
- case 'brave':
359
- return getFirstEnvValue(['BRAVE_API_KEY', 'MCC_WEBSEARCH_BRAVE_API_KEY']);
360
- case 'exa':
361
- return getFirstEnvValue(['EXA_API_KEY', 'MCC_WEBSEARCH_EXA_API_KEY']);
362
- case 'tavily':
363
- return getFirstEnvValue(['TAVILY_API_KEY', 'MCC_WEBSEARCH_TAVILY_API_KEY']);
364
- default:
365
- return '';
366
- }
367
- }
368
-
369
- function getResultCount(provider) {
370
- const raw = process.env[`MCC_WEBSEARCH_${provider.toUpperCase()}_MAX_RESULTS`];
371
- const parsed = Number.parseInt(raw || '', 10);
372
- return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT;
373
- }
374
-
375
- function getSearxngBaseUrl() {
376
- const raw = (process.env.MCC_WEBSEARCH_SEARXNG_URL || '').trim();
377
- if (!raw) {
378
- return '';
379
- }
380
-
381
- try {
382
- const parsed = new URL(raw);
383
- if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) {
384
- return '';
385
- }
386
-
387
- if (parsed.username || parsed.password) {
388
- return '';
389
- }
390
-
391
- let pathname = parsed.pathname.replace(/\/+$/, '');
392
- if (pathname.toLowerCase().endsWith('/search')) {
393
- pathname = pathname.slice(0, -'/search'.length);
394
- }
395
-
396
- parsed.pathname = pathname || '/';
397
- parsed.search = '';
398
- parsed.hash = '';
399
- return parsed.toString().replace(/\/+$/, '');
400
- } catch {
401
- return '';
402
- }
403
- }
404
-
405
- function buildPrompt(providerId, query) {
406
- const config = PROVIDER_CONFIG[providerId];
407
- const parts = [
408
- `Search the web for: ${query}`,
409
- '',
410
- config.toolInstruction,
411
- '',
412
- SHARED_INSTRUCTIONS,
413
- ];
414
- if (config.quirks) {
415
- parts.push('', `Note: ${config.quirks}`);
416
- }
417
- return parts.join('\n');
418
- }
419
-
420
- function decodeHtml(value) {
421
- return value
422
- .replace(/&amp;/g, '&')
423
- .replace(/&quot;/g, '"')
424
- .replace(/&#39;/g, "'")
425
- .replace(/&lt;/g, '<')
426
- .replace(/&gt;/g, '>');
427
- }
428
-
429
- function compactText(value, maxLength = 280) {
430
- const text = String(value || '')
431
- .replace(/\s+/g, ' ')
432
- .trim();
433
- if (text.length <= maxLength) {
434
- return text;
435
- }
436
- return `${text.slice(0, maxLength - 3).trimEnd()}...`;
437
- }
438
-
439
- function extractDuckDuckGoResults(html, count) {
440
- const links = [...html.matchAll(ddgLinkRe)].slice(0, count + 5);
441
- const snippets = [...html.matchAll(ddgSnippetRe)].slice(0, count + 5);
442
-
443
- return links.slice(0, count).map((match, index) => {
444
- let url = match[1];
445
- if (url.includes('uddg=')) {
446
- try {
447
- const decoded = decodeURIComponent(url);
448
- const uddgIndex = decoded.indexOf('uddg=');
449
- if (uddgIndex !== -1) {
450
- url = decoded.slice(uddgIndex + 5).split('&')[0];
451
- }
452
- } catch {
453
- // keep original url
454
- }
455
- }
456
-
457
- return {
458
- title: decodeHtml(match[2].replace(htmlTagRe, '').trim()),
459
- url,
460
- description: decodeHtml((snippets[index]?.[1] || '').replace(htmlTagRe, '').trim()),
461
- };
462
- });
463
- }
464
-
465
- function classifyDuckDuckGoHtml(html, count) {
466
- const responseHtml = String(html || '');
467
- const results = extractDuckDuckGoResults(responseHtml, count);
468
- if (results.length > 0) {
469
- return {
470
- kind: 'results',
471
- results,
472
- };
473
- }
474
-
475
- if (ddgNoResultsRe.test(responseHtml) || ddgNoResultsHeadingRe.test(responseHtml)) {
476
- return {
477
- kind: 'no_results',
478
- results: [],
479
- };
480
- }
481
-
482
- return {
483
- kind: 'non_result_html',
484
- results: [],
485
- error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)',
486
- };
487
- }
488
-
489
- function formatStructuredSearchResults(query, providerName, results) {
490
- const lines = [
491
- 'CCS local WebSearch evidence',
492
- `Provider: ${providerName}`,
493
- `Query: "${query}"`,
494
- `Result count: ${results.length}`,
495
- '',
496
- ];
497
-
498
- if (!results.length) {
499
- lines.push('No results found.');
500
- return lines.join('\n');
501
- }
502
-
503
- for (const [index, result] of results.entries()) {
504
- lines.push(`${index + 1}. ${result.title}`);
505
- lines.push(` URL: ${result.url}`);
506
- if (result.description) {
507
- lines.push(` Snippet: ${result.description}`);
508
- }
509
- lines.push('');
510
- }
511
- return lines.join('\n');
512
- }
513
-
514
- function buildSuccessHookOutput(query, providerName, content) {
515
- return {
516
- hookSpecificOutput: {
517
- hookEventName: 'PreToolUse',
518
- permissionDecision: 'deny',
519
- permissionDecisionReason: `CCS already retrieved WebSearch results locally via ${providerName}. Use the provided context instead of calling native WebSearch for "${query}".`,
520
- additionalContext: content,
521
- },
522
- };
523
- }
524
-
525
- function buildFailureHookOutput(query, errors) {
526
- const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
527
- return {
528
- hookSpecificOutput: {
529
- hookEventName: 'PreToolUse',
530
- permissionDecision: 'deny',
531
- permissionDecisionReason: `CCS could not complete local WebSearch for "${query}". Native WebSearch is unavailable for this profile.`,
532
- additionalContext: `CCS local WebSearch failed for "${query}". Attempted providers: ${detail}`,
533
- },
534
- };
535
- }
536
-
537
- function emitHookOutput(output) {
538
- console.log(JSON.stringify(output));
539
- process.exit(0);
540
- }
541
-
542
- async function fetchWithTimeout(url, options, timeoutMs) {
543
- const controller = new AbortController();
544
- const timer = setTimeout(() => controller.abort(), timeoutMs);
545
- try {
546
- return await fetch(url, { ...options, signal: controller.signal });
547
- } finally {
548
- clearTimeout(timer);
549
- }
550
- }
551
-
552
- async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
553
- const apiKey = getProviderApiKey('brave');
554
- if (!apiKey) {
555
- return { success: false, error: 'BRAVE_API_KEY is not set' };
556
- }
557
-
558
- const params = new URLSearchParams({
559
- q: query,
560
- count: String(getResultCount('brave')),
561
- });
562
-
563
- try {
564
- const response = await fetchWithTimeout(
565
- `${BRAVE_URL}?${params.toString()}`,
566
- {
567
- headers: {
568
- Accept: 'application/json',
569
- 'User-Agent': USER_AGENT,
570
- 'X-Subscription-Token': apiKey,
571
- },
572
- },
573
- timeoutSec * 1000
574
- );
575
-
576
- if (!response.ok) {
577
- const body = await response.text();
578
- return {
579
- success: false,
580
- error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`,
581
- statusCode: response.status,
582
- retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
583
- };
584
- }
585
-
586
- const body = await response.json();
587
- const results = (body.web?.results || []).map((result) => ({
588
- title: result.title || 'Untitled',
589
- url: result.url || '',
590
- description: result.description || '',
591
- }));
592
-
593
- return {
594
- success: true,
595
- content: formatStructuredSearchResults(query, 'Brave Search', results),
596
- };
597
- } catch (error) {
598
- return {
599
- success: false,
600
- error: error.name === 'AbortError' ? 'Brave Search timed out' : error.message,
601
- };
602
- }
603
- }
604
-
605
- async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
606
- const baseUrl = getSearxngBaseUrl();
607
- if (!baseUrl) {
608
- return { success: false, error: 'SearXNG URL is invalid or not configured' };
609
- }
610
-
611
- const params = new URLSearchParams({
612
- q: query,
613
- format: 'json',
614
- });
615
-
616
- try {
617
- const response = await fetchWithTimeout(
618
- `${baseUrl}/search?${params.toString()}`,
619
- {
620
- headers: {
621
- Accept: 'application/json',
622
- 'User-Agent': USER_AGENT,
623
- },
624
- },
625
- timeoutSec * 1000
626
- );
627
-
628
- if (!response.ok) {
629
- const body = await response.text();
630
- if (
631
- response.status === 403 &&
632
- /format(?:=|\s*)json|json[^\n]{0,40}disabled|disabled[^\n]{0,40}json/i.test(body)
633
- ) {
634
- return {
635
- success: false,
636
- error: 'SearXNG returned 403: format=json is disabled on this instance',
637
- statusCode: response.status,
638
- retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
639
- };
640
- }
641
-
642
- return {
643
- success: false,
644
- error: `SearXNG returned ${response.status}: ${body.slice(0, 160)}`,
645
- statusCode: response.status,
646
- retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
647
- };
648
- }
649
-
650
- let body;
651
- try {
652
- body = await response.json();
653
- } catch {
654
- return {
655
- success: false,
656
- error: 'SearXNG returned malformed JSON payload',
657
- };
658
- }
659
-
660
- if (!body || typeof body !== 'object' || !Array.isArray(body.results)) {
661
- return {
662
- success: false,
663
- error: 'SearXNG JSON response is missing results[]',
664
- };
665
- }
666
-
667
- const count = getResultCount('searxng');
668
- const results = body.results.slice(0, count).map((entry) => {
669
- const result = entry && typeof entry === 'object' ? entry : {};
670
- const url = typeof result.url === 'string' ? result.url : '';
671
- const titleSource =
672
- typeof result.title === 'string' && result.title.trim().length > 0
673
- ? result.title
674
- : url || 'Untitled';
675
- const descriptionSource =
676
- typeof result.content === 'string'
677
- ? result.content
678
- : typeof result.description === 'string'
679
- ? result.description
680
- : typeof result.snippet === 'string'
681
- ? result.snippet
682
- : '';
683
-
684
- return {
685
- title: compactText(titleSource, 120),
686
- url,
687
- description: compactText(descriptionSource, 240),
688
- };
689
- });
690
-
691
- return {
692
- success: true,
693
- content: formatStructuredSearchResults(query, 'SearXNG', results),
694
- };
695
- } catch (error) {
696
- return {
697
- success: false,
698
- error: error.name === 'AbortError' ? 'SearXNG timed out' : error.message,
699
- };
700
- }
701
- }
702
-
703
- async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
704
- const apiKey = getProviderApiKey('exa');
705
- if (!apiKey) {
706
- return { success: false, error: 'EXA_API_KEY is not set' };
707
- }
708
-
709
- try {
710
- const response = await fetchWithTimeout(
711
- EXA_URL,
712
- {
713
- method: 'POST',
714
- headers: {
715
- Accept: 'application/json',
716
- 'Content-Type': 'application/json',
717
- 'User-Agent': USER_AGENT,
718
- 'x-api-key': apiKey,
719
- },
720
- body: JSON.stringify({
721
- query,
722
- type: 'auto',
723
- numResults: getResultCount('exa'),
724
- text: true,
725
- }),
726
- },
727
- timeoutSec * 1000
728
- );
729
-
730
- if (!response.ok) {
731
- const body = await response.text();
732
- return {
733
- success: false,
734
- error: `Exa returned ${response.status}: ${body.slice(0, 160)}`,
735
- statusCode: response.status,
736
- retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
737
- };
738
- }
739
-
740
- const body = await response.json();
741
- const results = (body.results || []).map((result) => ({
742
- title: compactText(result.title || result.url || 'Untitled', 120),
743
- url: result.url || '',
744
- description: compactText(result.text || result.summary || '', 240),
745
- }));
746
-
747
- return {
748
- success: true,
749
- content: formatStructuredSearchResults(query, 'Exa', results),
750
- };
751
- } catch (error) {
752
- return {
753
- success: false,
754
- error: error.name === 'AbortError' ? 'Exa timed out' : error.message,
755
- };
756
- }
757
- }
758
-
759
- async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
760
- const apiKey = getProviderApiKey('tavily');
761
- if (!apiKey) {
762
- return { success: false, error: 'TAVILY_API_KEY is not set' };
763
- }
764
-
765
- try {
766
- const response = await fetchWithTimeout(
767
- TAVILY_URL,
768
- {
769
- method: 'POST',
770
- headers: {
771
- Accept: 'application/json',
772
- Authorization: `Bearer ${apiKey}`,
773
- 'Content-Type': 'application/json',
774
- 'User-Agent': USER_AGENT,
775
- },
776
- body: JSON.stringify({
777
- query,
778
- search_depth: 'basic',
779
- max_results: getResultCount('tavily'),
780
- include_answer: false,
781
- include_raw_content: false,
782
- }),
783
- },
784
- timeoutSec * 1000
785
- );
786
-
787
- if (!response.ok) {
788
- const body = await response.text();
789
- return {
790
- success: false,
791
- error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`,
792
- statusCode: response.status,
793
- retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
794
- };
795
- }
796
-
797
- const body = await response.json();
798
- const results = (body.results || []).map((result) => ({
799
- title: compactText(result.title || result.url || 'Untitled', 120),
800
- url: result.url || '',
801
- description: compactText(result.content || '', 240),
802
- }));
803
-
804
- return {
805
- success: true,
806
- content: formatStructuredSearchResults(query, 'Tavily', results),
807
- };
808
- } catch (error) {
809
- return {
810
- success: false,
811
- error: error.name === 'AbortError' ? 'Tavily timed out' : error.message,
812
- };
813
- }
814
- }
815
-
816
- async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
817
- try {
818
- const params = new URLSearchParams({ q: query });
819
- const response = await fetchWithTimeout(
820
- `${DDG_URL}?${params.toString()}`,
821
- {
822
- headers: {
823
- Accept: 'text/html',
824
- 'User-Agent': USER_AGENT,
825
- },
826
- },
827
- timeoutSec * 1000
828
- );
829
-
830
- if (!response.ok) {
831
- return {
832
- success: false,
833
- error: `DuckDuckGo returned ${response.status}`,
834
- statusCode: response.status,
835
- retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
836
- };
837
- }
838
-
839
- const html = await response.text();
840
- const parsed = classifyDuckDuckGoHtml(html, getResultCount('duckduckgo'));
841
- if (parsed.kind === 'non_result_html') {
842
- return {
843
- success: false,
844
- error: `${parsed.error} (status ${response.status})`,
845
- statusCode: response.status,
846
- };
847
- }
848
-
849
- return {
850
- success: true,
851
- content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results),
852
- };
853
- } catch (error) {
854
- return {
855
- success: false,
856
- error: error.name === 'AbortError' ? 'DuckDuckGo timed out' : error.message,
857
- };
858
- }
859
- }
860
-
861
- function shouldRetryGeminiWithLegacyPrompt(errorMessage) {
862
- const lower = (errorMessage || '').toLowerCase();
863
- return (
864
- lower.includes('unknown option') ||
865
- lower.includes('unknown argument') ||
866
- lower.includes('unrecognized option') ||
867
- lower.includes('usage: gemini') ||
868
- lower.includes('use --prompt') ||
869
- lower.includes('using the --prompt option')
870
- );
871
- }
872
-
873
- function runGeminiCommand(args, timeoutMs) {
874
- const result = spawnSync('gemini', args, {
875
- encoding: 'utf8',
876
- timeout: timeoutMs,
877
- maxBuffer: 1024 * 1024 * 2,
878
- stdio: ['pipe', 'pipe', 'pipe'],
879
- shell: isWindows,
880
- });
881
-
882
- if (result.error) {
883
- if (result.error.code === 'ENOENT')
884
- return { success: false, error: 'Gemini CLI not installed' };
885
- throw result.error;
886
- }
887
- if (result.status !== 0) {
888
- return {
889
- success: false,
890
- error: (result.stderr || '').trim() || `Gemini CLI exited with code ${result.status}`,
891
- };
892
- }
893
-
894
- const output = (result.stdout || '').trim();
895
- if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) {
896
- return { success: false, error: 'Empty or too short response from Gemini' };
897
- }
898
- return { success: true, content: output };
899
- }
900
-
901
- function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
902
- try {
903
- const timeoutMs = timeoutSec * 1000;
904
- const model = process.env.MCC_WEBSEARCH_GEMINI_MODEL || PROVIDER_CONFIG.gemini.model;
905
- const prompt = buildPrompt('gemini', query);
906
- const baseArgs = ['--model', model, '--yolo'];
907
-
908
- debug(`Executing Gemini legacy fallback with model ${model}`);
909
- const positionalResult = runGeminiCommand([...baseArgs, prompt], timeoutMs);
910
- if (positionalResult.success || !shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) {
911
- return positionalResult;
912
- }
913
-
914
- return runGeminiCommand([...baseArgs, '-p', prompt], timeoutMs);
915
- } catch (error) {
916
- return {
917
- success: false,
918
- error: error.killed ? 'Gemini CLI timed out' : error.message || 'Unknown Gemini error',
919
- };
920
- }
921
- }
922
-
923
- function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
924
- try {
925
- const model = process.env.MCC_WEBSEARCH_OPENCODE_MODEL || PROVIDER_CONFIG.opencode.model;
926
- const result = spawnSync(
927
- 'opencode',
928
- ['run', buildPrompt('opencode', query), '--model', model],
929
- {
930
- encoding: 'utf8',
931
- timeout: timeoutSec * 1000,
932
- maxBuffer: 1024 * 1024 * 2,
933
- stdio: ['pipe', 'pipe', 'pipe'],
934
- shell: isWindows,
935
- }
936
- );
937
-
938
- if (result.error) {
939
- if (result.error.code === 'ENOENT')
940
- return { success: false, error: 'OpenCode not installed' };
941
- throw result.error;
942
- }
943
- if (result.status !== 0) {
944
- return {
945
- success: false,
946
- error: (result.stderr || '').trim() || `OpenCode exited with code ${result.status}`,
947
- };
948
- }
949
-
950
- const output = (result.stdout || '').trim();
951
- if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) {
952
- return { success: false, error: 'Empty or too short response from OpenCode' };
953
- }
954
- return { success: true, content: output };
955
- } catch (error) {
956
- return {
957
- success: false,
958
- error: error.killed ? 'OpenCode timed out' : error.message || 'Unknown OpenCode error',
959
- };
960
- }
961
- }
962
-
963
- function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
964
- try {
965
- const result = spawnSync('grok', [buildPrompt('grok', query)], {
966
- encoding: 'utf8',
967
- timeout: timeoutSec * 1000,
968
- maxBuffer: 1024 * 1024 * 2,
969
- stdio: ['pipe', 'pipe', 'pipe'],
970
- shell: isWindows,
971
- });
972
-
973
- if (result.error) {
974
- if (result.error.code === 'ENOENT')
975
- return { success: false, error: 'Grok CLI not installed' };
976
- throw result.error;
977
- }
978
- if (result.status !== 0) {
979
- return {
980
- success: false,
981
- error: (result.stderr || '').trim() || `Grok CLI exited with code ${result.status}`,
982
- };
983
- }
984
-
985
- const output = (result.stdout || '').trim();
986
- if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) {
987
- return { success: false, error: 'Empty or too short response from Grok' };
988
- }
989
- return { success: true, content: output };
990
- } catch (error) {
991
- return {
992
- success: false,
993
- error: error.killed ? 'Grok CLI timed out' : error.message || 'Unknown Grok error',
994
- };
995
- }
996
- }
997
-
998
- function outputSuccess(query, content, providerName) {
999
- emitHookOutput(buildSuccessHookOutput(query, providerName, content));
1000
- }
1001
-
1002
- function outputAllFailedMessage(query, errors) {
1003
- emitHookOutput(buildFailureHookOutput(query, errors));
1004
- }
1005
-
1006
- function getConfiguredProviders() {
1007
- return [
1008
- {
1009
- name: 'Exa',
1010
- id: 'exa',
1011
- available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')),
1012
- fn: tryExaSearch,
1013
- },
1014
- {
1015
- name: 'Tavily',
1016
- id: 'tavily',
1017
- available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')),
1018
- fn: tryTavilySearch,
1019
- },
1020
- {
1021
- name: 'Brave Search',
1022
- id: 'brave',
1023
- available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
1024
- fn: tryBraveSearch,
1025
- },
1026
- {
1027
- name: 'SearXNG',
1028
- id: 'searxng',
1029
- available: () => isProviderEnabled('searxng') && Boolean(getSearxngBaseUrl()),
1030
- fn: trySearxngSearch,
1031
- },
1032
- {
1033
- name: 'DuckDuckGo',
1034
- id: 'duckduckgo',
1035
- available: () => isProviderEnabled('duckduckgo'),
1036
- fn: tryDuckDuckGoSearch,
1037
- },
1038
- {
1039
- name: 'Gemini CLI',
1040
- id: 'gemini',
1041
- available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'),
1042
- fn: tryGeminiSearch,
1043
- },
1044
- {
1045
- name: 'OpenCode',
1046
- id: 'opencode',
1047
- available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'),
1048
- fn: tryOpenCodeSearch,
1049
- },
1050
- {
1051
- name: 'Grok CLI',
1052
- id: 'grok',
1053
- available: () => isProviderEnabled('grok') && isCliAvailable('grok'),
1054
- fn: tryGrokSearch,
1055
- },
1056
- ];
1057
- }
1058
-
1059
- function looksLikeQuotaExhaustion(errorMessage) {
1060
- const lower = String(errorMessage || '').toLowerCase();
1061
- return (
1062
- (lower.includes('quota') &&
1063
- (lower.includes('exceed') ||
1064
- lower.includes('exhaust') ||
1065
- lower.includes('deplet') ||
1066
- lower.includes('limit') ||
1067
- lower.includes('used up'))) ||
1068
- lower.includes('insufficient credits') ||
1069
- lower.includes('credit balance') ||
1070
- lower.includes('out of credits') ||
1071
- lower.includes('billing hard limit') ||
1072
- lower.includes('monthly usage cap')
1073
- );
1074
- }
1075
-
1076
- function looksLikeTransientFailure(errorMessage) {
1077
- const lower = String(errorMessage || '').toLowerCase();
1078
- return (
1079
- lower.includes('timed out') ||
1080
- lower.includes('timeout') ||
1081
- lower.includes('temporarily unavailable') ||
1082
- lower.includes('service unavailable') ||
1083
- lower.includes('bad gateway') ||
1084
- lower.includes('gateway timeout') ||
1085
- lower.includes('socket hang up') ||
1086
- lower.includes('econnreset') ||
1087
- lower.includes('fetch failed') ||
1088
- lower.includes('network')
1089
- );
1090
- }
1091
-
1092
- function classifyProviderFailure(result) {
1093
- const errorMessage = String(result.error || '');
1094
- const statusCode =
1095
- Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null;
1096
- const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null;
1097
-
1098
- if (looksLikeQuotaExhaustion(errorMessage)) {
1099
- return {
1100
- kind: 'cooldown',
1101
- reason: 'quota_exhausted',
1102
- cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC,
1103
- retryAfterSec,
1104
- };
1105
- }
1106
-
1107
- if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) {
1108
- if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) {
1109
- return {
1110
- kind: 'retry',
1111
- delayMs: retryAfterSec * 1000,
1112
- reason: 'rate_limited_short_backoff',
1113
- retryAfterSec,
1114
- };
1115
- }
1116
-
1117
- return {
1118
- kind: 'cooldown',
1119
- reason: 'rate_limited',
1120
- cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC,
1121
- retryAfterSec,
1122
- };
1123
- }
1124
-
1125
- if (
1126
- (statusCode && [502, 503, 504].includes(statusCode)) ||
1127
- looksLikeTransientFailure(errorMessage)
1128
- ) {
1129
- return {
1130
- kind: 'retry',
1131
- delayMs: TRANSIENT_RETRY_DELAY_MS,
1132
- reason: 'transient_failure',
1133
- retryAfterSec,
1134
- };
1135
- }
1136
-
1137
- return {
1138
- kind: 'fail',
1139
- reason: 'non_retryable',
1140
- retryAfterSec,
1141
- };
1142
- }
1143
-
1144
- async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) {
1145
- for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) {
1146
- traceWebSearchEvent('websearch_provider_attempt', {
1147
- source: 'provider',
1148
- providerId: provider.id,
1149
- providerName: provider.name,
1150
- attempt: attempt + 1,
1151
- ...fingerprint,
1152
- });
1153
-
1154
- const result = await provider.fn(query, timeoutSec);
1155
- if (result.success) {
1156
- clearProviderCooldown(provider.id);
1157
- return result;
1158
- }
1159
-
1160
- const policy = classifyProviderFailure(result);
1161
- if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) {
1162
- traceWebSearchEvent('websearch_provider_retry_scheduled', {
1163
- source: 'provider',
1164
- providerId: provider.id,
1165
- providerName: provider.name,
1166
- attempt: attempt + 1,
1167
- delayMs: policy.delayMs,
1168
- reason: policy.reason,
1169
- retryAfterSec: policy.retryAfterSec,
1170
- ...fingerprint,
1171
- });
1172
- await sleep(policy.delayMs);
1173
- continue;
1174
- }
1175
-
1176
- if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') {
1177
- const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC;
1178
- const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error);
1179
- traceWebSearchEvent('websearch_provider_cooldown_applied', {
1180
- source: 'provider',
1181
- providerId: provider.id,
1182
- providerName: provider.name,
1183
- cooldownUntil: until,
1184
- cooldownSec,
1185
- reason: 'rate_limited',
1186
- retryAfterSec: policy.retryAfterSec,
1187
- afterRetryExhausted: true,
1188
- ...fingerprint,
1189
- });
1190
- return {
1191
- ...result,
1192
- error: `${result.error} (cooldown ${cooldownSec}s)`,
1193
- };
1194
- }
1195
-
1196
- if (policy.kind === 'cooldown') {
1197
- const until = applyProviderCooldown(
1198
- provider.id,
1199
- policy.cooldownSec,
1200
- policy.reason,
1201
- result.error
1202
- );
1203
- traceWebSearchEvent('websearch_provider_cooldown_applied', {
1204
- source: 'provider',
1205
- providerId: provider.id,
1206
- providerName: provider.name,
1207
- cooldownUntil: until,
1208
- cooldownSec: policy.cooldownSec,
1209
- reason: policy.reason,
1210
- retryAfterSec: policy.retryAfterSec,
1211
- ...fingerprint,
1212
- });
1213
- return {
1214
- ...result,
1215
- error: `${result.error} (cooldown ${policy.cooldownSec}s)`,
1216
- };
1217
- }
1218
-
1219
- return result;
1220
- }
1221
-
1222
- return { success: false, error: 'Provider retry policy exhausted' };
1223
- }
1224
-
1225
- function getActiveProviders() {
1226
- return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available());
1227
- }
1228
-
1229
- function getActiveProviderIds() {
1230
- return getActiveProviders().map((provider) => provider.id);
1231
- }
1232
-
1233
- function hasAnyActiveProviders() {
1234
- return getActiveProviders().length > 0;
1235
- }
1236
-
1237
- async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
1238
- const fingerprint = getQueryFingerprint(query);
1239
- const configuredProviders = getConfiguredProviders();
1240
- const activeProviders = [];
1241
-
1242
- for (const provider of configuredProviders) {
1243
- const cooldown = getProviderCooldown(provider.id);
1244
- if (cooldown) {
1245
- traceWebSearchEvent('websearch_provider_cooldown_skip', {
1246
- source: 'provider',
1247
- providerId: provider.id,
1248
- providerName: provider.name,
1249
- cooldownUntil: cooldown.until,
1250
- cooldownReason: cooldown.reason,
1251
- remainingMs: Math.max(0, cooldown.until - Date.now()),
1252
- ...fingerprint,
1253
- });
1254
- continue;
1255
- }
1256
-
1257
- if (provider.available()) {
1258
- activeProviders.push(provider);
1259
- }
1260
- }
1261
-
1262
- debug(
1263
- `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
1264
- );
1265
- traceWebSearchEvent('websearch_provider_run_started', {
1266
- source: 'provider',
1267
- activeProviderIds: activeProviders.map((provider) => provider.id),
1268
- ...fingerprint,
1269
- });
1270
-
1271
- if (activeProviders.length === 0) {
1272
- traceWebSearchEvent('websearch_provider_run_unavailable', {
1273
- source: 'provider',
1274
- activeProviderIds: [],
1275
- ...fingerprint,
1276
- });
1277
- return { success: false, noActiveProviders: true, errors: [] };
1278
- }
1279
-
1280
- const errors = [];
1281
- for (const provider of activeProviders) {
1282
- debug(`Trying ${provider.name}`);
1283
- const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint);
1284
- if (result.success) {
1285
- traceWebSearchEvent('websearch_provider_success', {
1286
- source: 'provider',
1287
- providerId: provider.id,
1288
- providerName: provider.name,
1289
- ...fingerprint,
1290
- });
1291
- return {
1292
- success: true,
1293
- providerId: provider.id,
1294
- providerName: provider.name,
1295
- content: result.content,
1296
- };
1297
- }
1298
- traceWebSearchEvent('websearch_provider_failure', {
1299
- source: 'provider',
1300
- providerId: provider.id,
1301
- providerName: provider.name,
1302
- error: result.error,
1303
- ...fingerprint,
1304
- });
1305
- errors.push({ provider: provider.name, error: result.error });
1306
- }
1307
-
1308
- traceWebSearchEvent('websearch_provider_run_failed', {
1309
- source: 'provider',
1310
- errorCount: errors.length,
1311
- activeProviderIds: activeProviders.map((provider) => provider.id),
1312
- ...fingerprint,
1313
- });
1314
- return { success: false, noActiveProviders: false, errors };
1315
- }
1316
-
1317
- async function processHook(input) {
1318
- try {
1319
- if (shouldSkipHook()) {
1320
- traceWebSearchEvent('websearch_hook_skipped', {
1321
- source: 'hook',
1322
- reason: getSkipReason(),
1323
- });
1324
- process.exit(0);
1325
- }
1326
-
1327
- const data = JSON.parse(input);
1328
- if (data.tool_name !== 'WebSearch') {
1329
- process.exit(0);
1330
- }
1331
-
1332
- const query = data.tool_input?.query || '';
1333
- if (!query) {
1334
- process.exit(0);
1335
- }
1336
-
1337
- traceWebSearchEvent('websearch_hook_invoked', {
1338
- source: 'hook',
1339
- ...getQueryFingerprint(query),
1340
- });
1341
-
1342
- const timeout = Number.parseInt(
1343
- process.env.MCC_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
1344
- 10
1345
- );
1346
- const result = await runLocalWebSearch(query, timeout);
1347
- if (result.noActiveProviders) {
1348
- traceWebSearchEvent('websearch_hook_no_active_providers', {
1349
- source: 'hook',
1350
- ...getQueryFingerprint(query),
1351
- });
1352
- process.exit(0);
1353
- }
1354
-
1355
- if (result.success) {
1356
- traceWebSearchEvent('websearch_hook_success', {
1357
- source: 'hook',
1358
- providerId: result.providerId,
1359
- providerName: result.providerName,
1360
- ...getQueryFingerprint(query),
1361
- });
1362
- outputSuccess(query, result.content, result.providerName);
1363
- return;
1364
- }
1365
-
1366
- traceWebSearchEvent('websearch_hook_failure', {
1367
- source: 'hook',
1368
- errorCount: result.errors.length,
1369
- ...getQueryFingerprint(query),
1370
- });
1371
- outputAllFailedMessage(query, result.errors);
1372
- } catch (error) {
1373
- debug(`Hook error: ${error.message}`);
1374
- traceWebSearchEvent('websearch_hook_error', {
1375
- source: 'hook',
1376
- error: error.message,
1377
- });
1378
- process.exit(0);
1379
- }
1380
- }
1381
-
1382
- function startFromStdin() {
1383
- let input = '';
1384
- process.stdin.setEncoding('utf8');
1385
- process.stdin.on('data', (chunk) => {
1386
- input += chunk;
1387
- });
1388
- process.stdin.on('end', () => {
1389
- processHook(input);
1390
- });
1391
- process.stdin.on('error', () => {
1392
- process.exit(0);
1393
- });
1394
- }
1395
-
1396
- if (require.main === module) {
1397
- startFromStdin();
1398
- }
1399
-
1400
- module.exports = {
1401
- buildFailureHookOutput,
1402
- buildSuccessHookOutput,
1403
- classifyDuckDuckGoHtml,
1404
- extractDuckDuckGoResults,
1405
- formatStructuredSearchResults,
1406
- getActiveProviders,
1407
- hasAnyActiveProviders,
1408
- runLocalWebSearch,
1409
- shouldSkipHook,
1410
- getActiveProviderIds,
1411
- classifyProviderFailure,
1412
- getQueryFingerprint,
1413
- getSkipReason,
1414
- parseRetryAfterSeconds,
1415
- traceWebSearchEvent,
1416
- tryExaSearch,
1417
- tryTavilySearch,
1418
- tryDuckDuckGoSearch,
1419
- tryBraveSearch,
1420
- trySearxngSearch,
1421
- };
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CCS WebSearch Hook - deterministic search backends with legacy CLI fallback
4
+ *
5
+ * Primary providers:
6
+ * - Exa Search API
7
+ * - Tavily Search API
8
+ * - Brave Search API
9
+ * - SearXNG JSON API
10
+ * - DuckDuckGo HTML search
11
+ *
12
+ * Legacy compatibility fallback:
13
+ * - Gemini CLI
14
+ * - OpenCode
15
+ * - Grok CLI
16
+ */
17
+
18
+ const { spawnSync } = require('child_process');
19
+ const { createHash } = require('crypto');
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ const isWindows = process.platform === 'win32';
25
+ const DEFAULT_TIMEOUT_SEC = 55;
26
+ const DEFAULT_RESULT_COUNT = 5;
27
+ const MIN_VALID_RESPONSE_LENGTH = 20;
28
+ const EXA_URL = 'https://api.exa.ai/search';
29
+ const TAVILY_URL = 'https://api.tavily.com/search';
30
+ const DDG_URL = 'https://html.duckduckgo.com/html/';
31
+ const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search';
32
+ const BOCHA_URL = 'https://api.bochaai.com/v1/web-search?utm_source=mcc';
33
+ const MINIMAX_SEARCH_DEFAULT_URL = 'https://api.minimaxi.com/v1/coding_plan/search';
34
+ const USER_AGENT =
35
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
36
+ const PROVIDER_STATE_FILE = 'websearch-provider-state.json';
37
+ const SHORT_RETRY_AFTER_MAX_SEC = 3;
38
+ const TRANSIENT_RETRY_DELAY_MS = 750;
39
+ const TRANSIENT_RETRY_ATTEMPTS = 1;
40
+ const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120;
41
+ const DEFAULT_QUOTA_COOLDOWN_SEC = 900;
42
+ const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60;
43
+
44
+ const SHARED_INSTRUCTIONS = `Instructions:
45
+ 1. Search the web for current, up-to-date information
46
+ 2. Provide a comprehensive summary of the search results
47
+ 3. Include relevant URLs/sources when available
48
+ 4. Be concise but thorough - prioritize key facts
49
+ 5. Focus on factual information from reliable sources
50
+ 6. If results conflict, note the discrepancy
51
+ 7. Format output clearly with sections if the topic is complex`;
52
+
53
+ const PROVIDER_CONFIG = {
54
+ gemini: {
55
+ model: 'gemini-2.5-flash',
56
+ toolInstruction: 'Use the google_web_search tool to find current information.',
57
+ quirks: null,
58
+ },
59
+ opencode: {
60
+ model: 'opencode/grok-code',
61
+ toolInstruction: 'Search the web using your built-in capabilities.',
62
+ quirks: null,
63
+ },
64
+ grok: {
65
+ model: 'grok-3',
66
+ toolInstruction: 'Use your web search capabilities to find information.',
67
+ quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.',
68
+ },
69
+ };
70
+
71
+ const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
72
+ const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g;
73
+ const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i;
74
+ const ddgNoResultsHeadingRe = /No results found for/i;
75
+ const htmlTagRe = /<[^>]+>/g;
76
+
77
+ // Shared logger — set MCC_LOG_LEVEL=debug to enable
78
+ const logger = require('../shared/logger.cjs');
79
+ const log = require('../shared/logger.cjs');
80
+
81
+ function debug(message) {
82
+ log.debug('WebSearch', message);
83
+ }
84
+
85
+ function getMccDirPath() {
86
+ if ((process.env.MCC_DIR || '').trim()) {
87
+ return path.resolve(process.env.MCC_DIR.trim());
88
+ }
89
+
90
+ if ((process.env.MCC_HOME || '').trim()) {
91
+ return path.join(path.resolve(process.env.MCC_HOME.trim()), '.mcc');
92
+ }
93
+
94
+ const home = (process.env.HOME || process.env.USERPROFILE || '').trim();
95
+ if (home) {
96
+ return path.join(home, '.mcc');
97
+ }
98
+
99
+ return path.join(process.cwd(), '.mcc');
100
+ }
101
+
102
+ function isTraceEnabled() {
103
+ return process.env.MCC_WEBSEARCH_TRACE === '1';
104
+ }
105
+
106
+ function normalizeSafePrefix(inputPath) {
107
+ return `${path.resolve(inputPath)}${path.sep}`;
108
+ }
109
+
110
+ function getSafeTracePrefixes() {
111
+ return [
112
+ normalizeSafePrefix(path.join(getMccDirPath(), 'logs')),
113
+ normalizeSafePrefix(os.tmpdir()),
114
+ normalizeSafePrefix('/var/log'),
115
+ ];
116
+ }
117
+
118
+ function getProviderStatePath() {
119
+ return path.join(getMccDirPath(), 'cache', PROVIDER_STATE_FILE);
120
+ }
121
+
122
+ function readProviderState() {
123
+ try {
124
+ const statePath = getProviderStatePath();
125
+ if (!fs.existsSync(statePath)) {
126
+ return { cooldowns: {} };
127
+ }
128
+
129
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
130
+ const cooldowns =
131
+ parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object'
132
+ ? parsed.cooldowns
133
+ : {};
134
+ return { cooldowns };
135
+ } catch {
136
+ return { cooldowns: {} };
137
+ }
138
+ }
139
+
140
+ function writeProviderState(state) {
141
+ try {
142
+ const statePath = getProviderStatePath();
143
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
144
+ const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
145
+ fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
146
+ fs.renameSync(tempPath, statePath);
147
+ } catch {
148
+ // Best-effort only.
149
+ }
150
+ }
151
+
152
+ function sanitizeProviderState(state) {
153
+ const now = Date.now();
154
+ const nextCooldowns = {};
155
+ let changed = false;
156
+
157
+ for (const [providerId, entry] of Object.entries(state.cooldowns || {})) {
158
+ if (!entry || typeof entry !== 'object') {
159
+ changed = true;
160
+ continue;
161
+ }
162
+
163
+ const until = Number.parseInt(String(entry.until || ''), 10);
164
+ if (!Number.isFinite(until) || until <= now) {
165
+ changed = true;
166
+ continue;
167
+ }
168
+
169
+ nextCooldowns[providerId] = {
170
+ until,
171
+ reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited',
172
+ updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now,
173
+ sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '',
174
+ };
175
+ }
176
+
177
+ return {
178
+ state: { cooldowns: nextCooldowns },
179
+ changed,
180
+ };
181
+ }
182
+
183
+ function getProviderCooldown(providerId) {
184
+ const { state, changed } = sanitizeProviderState(readProviderState());
185
+ if (changed) {
186
+ writeProviderState(state);
187
+ }
188
+
189
+ return state.cooldowns[providerId] || null;
190
+ }
191
+
192
+ function clearProviderCooldown(providerId) {
193
+ const { state } = sanitizeProviderState(readProviderState());
194
+ if (!(providerId in state.cooldowns)) {
195
+ return;
196
+ }
197
+
198
+ delete state.cooldowns[providerId];
199
+ writeProviderState(state);
200
+ }
201
+
202
+ function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) {
203
+ const clampedCooldownSec = Math.max(
204
+ 1,
205
+ Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec))
206
+ );
207
+ const { state } = sanitizeProviderState(readProviderState());
208
+ const until = Date.now() + clampedCooldownSec * 1000;
209
+ state.cooldowns[providerId] = {
210
+ until,
211
+ reason,
212
+ updatedAt: Date.now(),
213
+ sourceError: sourceError || '',
214
+ };
215
+ writeProviderState(state);
216
+ return until;
217
+ }
218
+
219
+ function sleep(ms) {
220
+ return new Promise((resolve) => setTimeout(resolve, ms));
221
+ }
222
+
223
+ function getAllowedTraceFileOverride() {
224
+ const configured = (process.env.MCC_WEBSEARCH_TRACE_FILE || '').trim();
225
+ if (!configured) {
226
+ return null;
227
+ }
228
+
229
+ const resolved = path.resolve(configured);
230
+ if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) {
231
+ return resolved;
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ function getTraceFilePath() {
238
+ const fallback = path.join(getMccDirPath(), 'logs', 'websearch-trace.jsonl');
239
+ return getAllowedTraceFileOverride() || fallback;
240
+ }
241
+
242
+ function traceWebSearchEvent(event, payload = {}) {
243
+ if (!isTraceEnabled()) {
244
+ return;
245
+ }
246
+
247
+ try {
248
+ const traceFilePath = getTraceFilePath();
249
+ fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
250
+ fs.appendFileSync(
251
+ traceFilePath,
252
+ JSON.stringify({
253
+ at: new Date().toISOString(),
254
+ event,
255
+ launchId: process.env.MCC_WEBSEARCH_TRACE_LAUNCH_ID || null,
256
+ launcher: process.env.MCC_WEBSEARCH_TRACE_LAUNCHER || null,
257
+ profileType: process.env.MCC_PROFILE_TYPE || null,
258
+ pid: process.pid,
259
+ ...payload,
260
+ }) + '\n',
261
+ 'utf8'
262
+ );
263
+ } catch {
264
+ // Best-effort only.
265
+ }
266
+ }
267
+
268
+ function readHeaderValue(headers, headerName) {
269
+ if (!headers) {
270
+ return '';
271
+ }
272
+
273
+ if (typeof headers.get === 'function') {
274
+ return headers.get(headerName) || '';
275
+ }
276
+
277
+ const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()];
278
+ if (Array.isArray(direct)) {
279
+ return direct[0] || '';
280
+ }
281
+ return typeof direct === 'string' ? direct : '';
282
+ }
283
+
284
+ function parseRetryAfterSeconds(rawValue) {
285
+ const value = String(rawValue || '').trim();
286
+ if (!value) {
287
+ return null;
288
+ }
289
+
290
+ const asSeconds = Number.parseInt(value, 10);
291
+ if (Number.isFinite(asSeconds) && asSeconds > 0) {
292
+ return asSeconds;
293
+ }
294
+
295
+ const asDate = Date.parse(value);
296
+ if (Number.isFinite(asDate)) {
297
+ const deltaSec = Math.ceil((asDate - Date.now()) / 1000);
298
+ return deltaSec > 0 ? deltaSec : null;
299
+ }
300
+
301
+ return null;
302
+ }
303
+
304
+ function getQueryFingerprint(query) {
305
+ const normalizedQuery = typeof query === 'string' ? query.trim() : '';
306
+ return {
307
+ queryHash: normalizedQuery
308
+ ? createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16)
309
+ : null,
310
+ queryLength: normalizedQuery.length,
311
+ };
312
+ }
313
+
314
+ function getSkipReason() {
315
+ if (process.env.MCC_WEBSEARCH_SKIP === '1') return 'skip_flag';
316
+ const profileType = process.env.MCC_PROFILE_TYPE;
317
+ if (profileType === 'account') return 'native_account_profile';
318
+ if (profileType === 'default') return 'native_default_profile';
319
+ if (process.env.MCC_WEBSEARCH_ENABLED === '0') return 'disabled';
320
+ return null;
321
+ }
322
+
323
+ function shouldSkipHook() {
324
+ return getSkipReason() !== null;
325
+ }
326
+
327
+ function isCliAvailable(cmd) {
328
+ try {
329
+ const whichCmd = isWindows ? 'where.exe' : 'which';
330
+ const result = spawnSync(whichCmd, [cmd], {
331
+ encoding: 'utf8',
332
+ timeout: 2000,
333
+ stdio: ['pipe', 'pipe', 'pipe'],
334
+ });
335
+ return result.status === 0 && result.stdout.trim().length > 0;
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+
341
+ function isProviderEnabled(provider) {
342
+ return process.env[`MCC_WEBSEARCH_${provider.toUpperCase()}`] === '1';
343
+ }
344
+
345
+ function hasEnvValue(name) {
346
+ return (process.env[name] || '').trim().length > 0;
347
+ }
348
+
349
+ function getFirstEnvValue(names) {
350
+ for (const name of names) {
351
+ if (hasEnvValue(name)) {
352
+ return process.env[name].trim();
353
+ }
354
+ }
355
+ return '';
356
+ }
357
+
358
+ function getProviderApiKey(providerId) {
359
+ switch (providerId) {
360
+ case 'brave':
361
+ return getFirstEnvValue(['BRAVE_API_KEY', 'MCC_WEBSEARCH_BRAVE_API_KEY']);
362
+ case 'exa':
363
+ return getFirstEnvValue(['EXA_API_KEY', 'MCC_WEBSEARCH_EXA_API_KEY']);
364
+ case 'tavily':
365
+ return getFirstEnvValue(['TAVILY_API_KEY', 'MCC_WEBSEARCH_TAVILY_API_KEY']);
366
+ case 'bocha':
367
+ return getFirstEnvValue(['BOCHA_API_KEY', 'MCC_WEBSEARCH_BOCHA_API_KEY']);
368
+ case 'minimax':
369
+ return getFirstEnvValue([
370
+ 'MINIMAX_SEARCH_API_KEY',
371
+ 'MCC_WEBSEARCH_MINIMAX_API_KEY',
372
+ 'MINIMAX_API_KEY',
373
+ ]);
374
+ default:
375
+ return '';
376
+ }
377
+ }
378
+
379
+ function getMinimaxSearchUrl() {
380
+ const raw = (process.env.MCC_WEBSEARCH_MINIMAX_URL || '').trim();
381
+ return raw || MINIMAX_SEARCH_DEFAULT_URL;
382
+ }
383
+
384
+ function getResultCount(provider) {
385
+ const raw = process.env[`MCC_WEBSEARCH_${provider.toUpperCase()}_MAX_RESULTS`];
386
+ const parsed = Number.parseInt(raw || '', 10);
387
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT;
388
+ }
389
+
390
+ function getSearxngBaseUrl() {
391
+ const raw = (process.env.MCC_WEBSEARCH_SEARXNG_URL || '').trim();
392
+ if (!raw) {
393
+ return '';
394
+ }
395
+
396
+ try {
397
+ const parsed = new URL(raw);
398
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) {
399
+ return '';
400
+ }
401
+
402
+ if (parsed.username || parsed.password) {
403
+ return '';
404
+ }
405
+
406
+ let pathname = parsed.pathname.replace(/\/+$/, '');
407
+ if (pathname.toLowerCase().endsWith('/search')) {
408
+ pathname = pathname.slice(0, -'/search'.length);
409
+ }
410
+
411
+ parsed.pathname = pathname || '/';
412
+ parsed.search = '';
413
+ parsed.hash = '';
414
+ return parsed.toString().replace(/\/+$/, '');
415
+ } catch {
416
+ return '';
417
+ }
418
+ }
419
+
420
+ function buildPrompt(providerId, query) {
421
+ const config = PROVIDER_CONFIG[providerId];
422
+ const parts = [
423
+ `Search the web for: ${query}`,
424
+ '',
425
+ config.toolInstruction,
426
+ '',
427
+ SHARED_INSTRUCTIONS,
428
+ ];
429
+ if (config.quirks) {
430
+ parts.push('', `Note: ${config.quirks}`);
431
+ }
432
+ return parts.join('\n');
433
+ }
434
+
435
+ function decodeHtml(value) {
436
+ return value
437
+ .replace(/&amp;/g, '&')
438
+ .replace(/&quot;/g, '"')
439
+ .replace(/&#39;/g, "'")
440
+ .replace(/&lt;/g, '<')
441
+ .replace(/&gt;/g, '>');
442
+ }
443
+
444
+ function compactText(value, maxLength = 280) {
445
+ const text = String(value || '')
446
+ .replace(/\s+/g, ' ')
447
+ .trim();
448
+ if (text.length <= maxLength) {
449
+ return text;
450
+ }
451
+ return `${text.slice(0, maxLength - 3).trimEnd()}...`;
452
+ }
453
+
454
+ function extractDuckDuckGoResults(html, count) {
455
+ const links = [...html.matchAll(ddgLinkRe)].slice(0, count + 5);
456
+ const snippets = [...html.matchAll(ddgSnippetRe)].slice(0, count + 5);
457
+
458
+ return links.slice(0, count).map((match, index) => {
459
+ let url = match[1];
460
+ if (url.includes('uddg=')) {
461
+ try {
462
+ const decoded = decodeURIComponent(url);
463
+ const uddgIndex = decoded.indexOf('uddg=');
464
+ if (uddgIndex !== -1) {
465
+ url = decoded.slice(uddgIndex + 5).split('&')[0];
466
+ }
467
+ } catch {
468
+ // keep original url
469
+ }
470
+ }
471
+
472
+ return {
473
+ title: decodeHtml(match[2].replace(htmlTagRe, '').trim()),
474
+ url,
475
+ description: decodeHtml((snippets[index]?.[1] || '').replace(htmlTagRe, '').trim()),
476
+ };
477
+ });
478
+ }
479
+
480
+ function classifyDuckDuckGoHtml(html, count) {
481
+ const responseHtml = String(html || '');
482
+ const results = extractDuckDuckGoResults(responseHtml, count);
483
+ if (results.length > 0) {
484
+ return {
485
+ kind: 'results',
486
+ results,
487
+ };
488
+ }
489
+
490
+ if (ddgNoResultsRe.test(responseHtml) || ddgNoResultsHeadingRe.test(responseHtml)) {
491
+ return {
492
+ kind: 'no_results',
493
+ results: [],
494
+ };
495
+ }
496
+
497
+ return {
498
+ kind: 'non_result_html',
499
+ results: [],
500
+ error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)',
501
+ };
502
+ }
503
+
504
+ function formatStructuredSearchResults(query, providerName, results) {
505
+ const lines = [
506
+ 'CCS local WebSearch evidence',
507
+ `Provider: ${providerName}`,
508
+ `Query: "${query}"`,
509
+ `Result count: ${results.length}`,
510
+ '',
511
+ ];
512
+
513
+ if (!results.length) {
514
+ lines.push('No results found.');
515
+ return lines.join('\n');
516
+ }
517
+
518
+ for (const [index, result] of results.entries()) {
519
+ lines.push(`${index + 1}. ${result.title}`);
520
+ lines.push(` URL: ${result.url}`);
521
+ if (result.description) {
522
+ lines.push(` Snippet: ${result.description}`);
523
+ }
524
+ lines.push('');
525
+ }
526
+ return lines.join('\n');
527
+ }
528
+
529
+ function buildSuccessHookOutput(query, providerName, content) {
530
+ return {
531
+ hookSpecificOutput: {
532
+ hookEventName: 'PreToolUse',
533
+ permissionDecision: 'deny',
534
+ permissionDecisionReason: `CCS already retrieved WebSearch results locally via ${providerName}. Use the provided context instead of calling native WebSearch for "${query}".`,
535
+ additionalContext: content,
536
+ },
537
+ };
538
+ }
539
+
540
+ function buildFailureHookOutput(query, errors) {
541
+ const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
542
+ return {
543
+ hookSpecificOutput: {
544
+ hookEventName: 'PreToolUse',
545
+ permissionDecision: 'deny',
546
+ permissionDecisionReason: `CCS could not complete local WebSearch for "${query}". Native WebSearch is unavailable for this profile.`,
547
+ additionalContext: `CCS local WebSearch failed for "${query}". Attempted providers: ${detail}`,
548
+ },
549
+ };
550
+ }
551
+
552
+ function emitHookOutput(output) {
553
+ console.log(JSON.stringify(output));
554
+ process.exit(0);
555
+ }
556
+
557
+ async function fetchWithTimeout(url, options, timeoutMs) {
558
+ const controller = new AbortController();
559
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
560
+ try {
561
+ return await fetch(url, { ...options, signal: controller.signal });
562
+ } finally {
563
+ clearTimeout(timer);
564
+ }
565
+ }
566
+
567
+ async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
568
+ const apiKey = getProviderApiKey('brave');
569
+ if (!apiKey) {
570
+ return { success: false, error: 'BRAVE_API_KEY is not set' };
571
+ }
572
+
573
+ const params = new URLSearchParams({
574
+ q: query,
575
+ count: String(getResultCount('brave')),
576
+ });
577
+
578
+ try {
579
+ const response = await fetchWithTimeout(
580
+ `${BRAVE_URL}?${params.toString()}`,
581
+ {
582
+ headers: {
583
+ Accept: 'application/json',
584
+ 'User-Agent': USER_AGENT,
585
+ 'X-Subscription-Token': apiKey,
586
+ },
587
+ },
588
+ timeoutSec * 1000
589
+ );
590
+
591
+ if (!response.ok) {
592
+ const body = await response.text();
593
+ return {
594
+ success: false,
595
+ error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`,
596
+ statusCode: response.status,
597
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
598
+ };
599
+ }
600
+
601
+ const body = await response.json();
602
+ const results = (body.web?.results || []).map((result) => ({
603
+ title: result.title || 'Untitled',
604
+ url: result.url || '',
605
+ description: result.description || '',
606
+ }));
607
+
608
+ return {
609
+ success: true,
610
+ content: formatStructuredSearchResults(query, 'Brave Search', results),
611
+ };
612
+ } catch (error) {
613
+ return {
614
+ success: false,
615
+ error: error.name === 'AbortError' ? 'Brave Search timed out' : error.message,
616
+ };
617
+ }
618
+ }
619
+
620
+ async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
621
+ const baseUrl = getSearxngBaseUrl();
622
+ if (!baseUrl) {
623
+ return { success: false, error: 'SearXNG URL is invalid or not configured' };
624
+ }
625
+
626
+ const params = new URLSearchParams({
627
+ q: query,
628
+ format: 'json',
629
+ });
630
+
631
+ try {
632
+ const response = await fetchWithTimeout(
633
+ `${baseUrl}/search?${params.toString()}`,
634
+ {
635
+ headers: {
636
+ Accept: 'application/json',
637
+ 'User-Agent': USER_AGENT,
638
+ },
639
+ },
640
+ timeoutSec * 1000
641
+ );
642
+
643
+ if (!response.ok) {
644
+ const body = await response.text();
645
+ if (
646
+ response.status === 403 &&
647
+ /format(?:=|\s*)json|json[^\n]{0,40}disabled|disabled[^\n]{0,40}json/i.test(body)
648
+ ) {
649
+ return {
650
+ success: false,
651
+ error: 'SearXNG returned 403: format=json is disabled on this instance',
652
+ statusCode: response.status,
653
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
654
+ };
655
+ }
656
+
657
+ return {
658
+ success: false,
659
+ error: `SearXNG returned ${response.status}: ${body.slice(0, 160)}`,
660
+ statusCode: response.status,
661
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
662
+ };
663
+ }
664
+
665
+ let body;
666
+ try {
667
+ body = await response.json();
668
+ } catch {
669
+ return {
670
+ success: false,
671
+ error: 'SearXNG returned malformed JSON payload',
672
+ };
673
+ }
674
+
675
+ if (!body || typeof body !== 'object' || !Array.isArray(body.results)) {
676
+ return {
677
+ success: false,
678
+ error: 'SearXNG JSON response is missing results[]',
679
+ };
680
+ }
681
+
682
+ const count = getResultCount('searxng');
683
+ const results = body.results.slice(0, count).map((entry) => {
684
+ const result = entry && typeof entry === 'object' ? entry : {};
685
+ const url = typeof result.url === 'string' ? result.url : '';
686
+ const titleSource =
687
+ typeof result.title === 'string' && result.title.trim().length > 0
688
+ ? result.title
689
+ : url || 'Untitled';
690
+ const descriptionSource =
691
+ typeof result.content === 'string'
692
+ ? result.content
693
+ : typeof result.description === 'string'
694
+ ? result.description
695
+ : typeof result.snippet === 'string'
696
+ ? result.snippet
697
+ : '';
698
+
699
+ return {
700
+ title: compactText(titleSource, 120),
701
+ url,
702
+ description: compactText(descriptionSource, 240),
703
+ };
704
+ });
705
+
706
+ return {
707
+ success: true,
708
+ content: formatStructuredSearchResults(query, 'SearXNG', results),
709
+ };
710
+ } catch (error) {
711
+ return {
712
+ success: false,
713
+ error: error.name === 'AbortError' ? 'SearXNG timed out' : error.message,
714
+ };
715
+ }
716
+ }
717
+
718
+ async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
719
+ const apiKey = getProviderApiKey('exa');
720
+ if (!apiKey) {
721
+ return { success: false, error: 'EXA_API_KEY is not set' };
722
+ }
723
+
724
+ try {
725
+ const response = await fetchWithTimeout(
726
+ EXA_URL,
727
+ {
728
+ method: 'POST',
729
+ headers: {
730
+ Accept: 'application/json',
731
+ 'Content-Type': 'application/json',
732
+ 'User-Agent': USER_AGENT,
733
+ 'x-api-key': apiKey,
734
+ },
735
+ body: JSON.stringify({
736
+ query,
737
+ type: 'auto',
738
+ numResults: getResultCount('exa'),
739
+ text: true,
740
+ }),
741
+ },
742
+ timeoutSec * 1000
743
+ );
744
+
745
+ if (!response.ok) {
746
+ const body = await response.text();
747
+ return {
748
+ success: false,
749
+ error: `Exa returned ${response.status}: ${body.slice(0, 160)}`,
750
+ statusCode: response.status,
751
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
752
+ };
753
+ }
754
+
755
+ const body = await response.json();
756
+ const results = (body.results || []).map((result) => ({
757
+ title: compactText(result.title || result.url || 'Untitled', 120),
758
+ url: result.url || '',
759
+ description: compactText(result.text || result.summary || '', 240),
760
+ }));
761
+
762
+ return {
763
+ success: true,
764
+ content: formatStructuredSearchResults(query, 'Exa', results),
765
+ };
766
+ } catch (error) {
767
+ return {
768
+ success: false,
769
+ error: error.name === 'AbortError' ? 'Exa timed out' : error.message,
770
+ };
771
+ }
772
+ }
773
+
774
+ async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
775
+ const apiKey = getProviderApiKey('tavily');
776
+ if (!apiKey) {
777
+ return { success: false, error: 'TAVILY_API_KEY is not set' };
778
+ }
779
+
780
+ try {
781
+ const response = await fetchWithTimeout(
782
+ TAVILY_URL,
783
+ {
784
+ method: 'POST',
785
+ headers: {
786
+ Accept: 'application/json',
787
+ Authorization: `Bearer ${apiKey}`,
788
+ 'Content-Type': 'application/json',
789
+ 'User-Agent': USER_AGENT,
790
+ },
791
+ body: JSON.stringify({
792
+ query,
793
+ search_depth: 'basic',
794
+ max_results: getResultCount('tavily'),
795
+ include_answer: false,
796
+ include_raw_content: false,
797
+ }),
798
+ },
799
+ timeoutSec * 1000
800
+ );
801
+
802
+ if (!response.ok) {
803
+ const body = await response.text();
804
+ return {
805
+ success: false,
806
+ error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`,
807
+ statusCode: response.status,
808
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
809
+ };
810
+ }
811
+
812
+ const body = await response.json();
813
+ const results = (body.results || []).map((result) => ({
814
+ title: compactText(result.title || result.url || 'Untitled', 120),
815
+ url: result.url || '',
816
+ description: compactText(result.content || '', 240),
817
+ }));
818
+
819
+ return {
820
+ success: true,
821
+ content: formatStructuredSearchResults(query, 'Tavily', results),
822
+ };
823
+ } catch (error) {
824
+ return {
825
+ success: false,
826
+ error: error.name === 'AbortError' ? 'Tavily timed out' : error.message,
827
+ };
828
+ }
829
+ }
830
+
831
+ async function tryBochaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
832
+ const apiKey = getProviderApiKey('bocha');
833
+ if (!apiKey) {
834
+ return { success: false, error: 'BOCHA_API_KEY is not set' };
835
+ }
836
+
837
+ const count = getResultCount('bocha');
838
+
839
+ try {
840
+ const response = await fetchWithTimeout(
841
+ BOCHA_URL,
842
+ {
843
+ method: 'POST',
844
+ headers: {
845
+ Accept: 'application/json',
846
+ Authorization: `Bearer ${apiKey}`,
847
+ 'Content-Type': 'application/json',
848
+ 'User-Agent': USER_AGENT,
849
+ },
850
+ body: JSON.stringify({
851
+ query,
852
+ summary: true,
853
+ freshness: 'noLimit',
854
+ count,
855
+ }),
856
+ },
857
+ timeoutSec * 1000
858
+ );
859
+
860
+ if (!response.ok) {
861
+ const body = await response.text();
862
+ return {
863
+ success: false,
864
+ error: `Bocha returned ${response.status}: ${body.slice(0, 160)}`,
865
+ statusCode: response.status,
866
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
867
+ };
868
+ }
869
+
870
+ const body = await response.json();
871
+ const pages = body?.data?.webPages?.value || [];
872
+ const results = pages.slice(0, count).map((entry) => ({
873
+ title: compactText(entry.name || entry.url || 'Untitled', 120),
874
+ url: entry.url || '',
875
+ description: compactText(entry.summary || entry.snippet || '', 240),
876
+ }));
877
+
878
+ return {
879
+ success: true,
880
+ content: formatStructuredSearchResults(query, 'Bocha', results),
881
+ };
882
+ } catch (error) {
883
+ return {
884
+ success: false,
885
+ error: error.name === 'AbortError' ? 'Bocha timed out' : error.message,
886
+ };
887
+ }
888
+ }
889
+
890
+ async function tryMinimaxSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
891
+ const apiKey = getProviderApiKey('minimax');
892
+ if (!apiKey) {
893
+ return { success: false, error: 'MINIMAX_API_KEY is not set' };
894
+ }
895
+
896
+ const url = getMinimaxSearchUrl();
897
+
898
+ try {
899
+ const response = await fetchWithTimeout(
900
+ url,
901
+ {
902
+ method: 'POST',
903
+ headers: {
904
+ Accept: 'application/json',
905
+ Authorization: `Bearer ${apiKey}`,
906
+ 'Content-Type': 'application/json',
907
+ 'User-Agent': USER_AGENT,
908
+ 'MM-API-Source': 'mcc-websearch',
909
+ },
910
+ body: JSON.stringify({ q: query }),
911
+ },
912
+ timeoutSec * 1000
913
+ );
914
+
915
+ if (!response.ok) {
916
+ const body = await response.text();
917
+ return {
918
+ success: false,
919
+ error: `MiniMax Search returned ${response.status}: ${body.slice(0, 160)}`,
920
+ statusCode: response.status,
921
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
922
+ };
923
+ }
924
+
925
+ const body = await response.json();
926
+ const baseResp = body && typeof body === 'object' ? body.base_resp : null;
927
+ if (baseResp && typeof baseResp === 'object' && baseResp.status_code !== 0) {
928
+ return {
929
+ success: false,
930
+ error: `MiniMax Search error ${baseResp.status_code}: ${baseResp.status_msg || 'unknown'}`,
931
+ };
932
+ }
933
+
934
+ // Response shape isn't strictly documented; surface common candidate fields
935
+ // and fall back to the raw payload if structure is unexpected.
936
+ const candidates =
937
+ body?.data?.results ||
938
+ body?.results ||
939
+ body?.data?.organic_results ||
940
+ body?.organic_results ||
941
+ [];
942
+ const count = getResultCount('minimax');
943
+ if (Array.isArray(candidates) && candidates.length > 0) {
944
+ const results = candidates.slice(0, count).map((entry) => ({
945
+ title: compactText(entry.title || entry.name || entry.url || 'Untitled', 120),
946
+ url: entry.url || entry.link || '',
947
+ description: compactText(
948
+ entry.snippet || entry.description || entry.summary || '',
949
+ 240
950
+ ),
951
+ }));
952
+ return {
953
+ success: true,
954
+ content: formatStructuredSearchResults(query, 'MiniMax Search', results),
955
+ };
956
+ }
957
+
958
+ // Unknown shape dump the JSON so the caller still gets evidence.
959
+ return {
960
+ success: true,
961
+ content: [
962
+ 'CCS local WebSearch evidence',
963
+ 'Provider: MiniMax Search',
964
+ `Query: "${query}"`,
965
+ 'Result count: raw (unrecognised response shape)',
966
+ '',
967
+ JSON.stringify(body, null, 2).slice(0, 4000),
968
+ ].join('\n'),
969
+ };
970
+ } catch (error) {
971
+ return {
972
+ success: false,
973
+ error: error.name === 'AbortError' ? 'MiniMax Search timed out' : error.message,
974
+ };
975
+ }
976
+ }
977
+
978
+ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
979
+ try {
980
+ const params = new URLSearchParams({ q: query });
981
+ const response = await fetchWithTimeout(
982
+ `${DDG_URL}?${params.toString()}`,
983
+ {
984
+ headers: {
985
+ Accept: 'text/html',
986
+ 'User-Agent': USER_AGENT,
987
+ },
988
+ },
989
+ timeoutSec * 1000
990
+ );
991
+
992
+ if (!response.ok) {
993
+ return {
994
+ success: false,
995
+ error: `DuckDuckGo returned ${response.status}`,
996
+ statusCode: response.status,
997
+ retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')),
998
+ };
999
+ }
1000
+
1001
+ const html = await response.text();
1002
+ const parsed = classifyDuckDuckGoHtml(html, getResultCount('duckduckgo'));
1003
+ if (parsed.kind === 'non_result_html') {
1004
+ return {
1005
+ success: false,
1006
+ error: `${parsed.error} (status ${response.status})`,
1007
+ statusCode: response.status,
1008
+ };
1009
+ }
1010
+
1011
+ return {
1012
+ success: true,
1013
+ content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results),
1014
+ };
1015
+ } catch (error) {
1016
+ return {
1017
+ success: false,
1018
+ error: error.name === 'AbortError' ? 'DuckDuckGo timed out' : error.message,
1019
+ };
1020
+ }
1021
+ }
1022
+
1023
+ function shouldRetryGeminiWithLegacyPrompt(errorMessage) {
1024
+ const lower = (errorMessage || '').toLowerCase();
1025
+ return (
1026
+ lower.includes('unknown option') ||
1027
+ lower.includes('unknown argument') ||
1028
+ lower.includes('unrecognized option') ||
1029
+ lower.includes('usage: gemini') ||
1030
+ lower.includes('use --prompt') ||
1031
+ lower.includes('using the --prompt option')
1032
+ );
1033
+ }
1034
+
1035
+ function runGeminiCommand(args, timeoutMs) {
1036
+ const result = spawnSync('gemini', args, {
1037
+ encoding: 'utf8',
1038
+ timeout: timeoutMs,
1039
+ maxBuffer: 1024 * 1024 * 2,
1040
+ stdio: ['pipe', 'pipe', 'pipe'],
1041
+ shell: isWindows,
1042
+ });
1043
+
1044
+ if (result.error) {
1045
+ if (result.error.code === 'ENOENT')
1046
+ return { success: false, error: 'Gemini CLI not installed' };
1047
+ throw result.error;
1048
+ }
1049
+ if (result.status !== 0) {
1050
+ return {
1051
+ success: false,
1052
+ error: (result.stderr || '').trim() || `Gemini CLI exited with code ${result.status}`,
1053
+ };
1054
+ }
1055
+
1056
+ const output = (result.stdout || '').trim();
1057
+ if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) {
1058
+ return { success: false, error: 'Empty or too short response from Gemini' };
1059
+ }
1060
+ return { success: true, content: output };
1061
+ }
1062
+
1063
+ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
1064
+ try {
1065
+ const timeoutMs = timeoutSec * 1000;
1066
+ const model = process.env.MCC_WEBSEARCH_GEMINI_MODEL || PROVIDER_CONFIG.gemini.model;
1067
+ const prompt = buildPrompt('gemini', query);
1068
+ const baseArgs = ['--model', model, '--yolo'];
1069
+
1070
+ debug(`Executing Gemini legacy fallback with model ${model}`);
1071
+ const positionalResult = runGeminiCommand([...baseArgs, prompt], timeoutMs);
1072
+ if (positionalResult.success || !shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) {
1073
+ return positionalResult;
1074
+ }
1075
+
1076
+ return runGeminiCommand([...baseArgs, '-p', prompt], timeoutMs);
1077
+ } catch (error) {
1078
+ return {
1079
+ success: false,
1080
+ error: error.killed ? 'Gemini CLI timed out' : error.message || 'Unknown Gemini error',
1081
+ };
1082
+ }
1083
+ }
1084
+
1085
+ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
1086
+ try {
1087
+ const model = process.env.MCC_WEBSEARCH_OPENCODE_MODEL || PROVIDER_CONFIG.opencode.model;
1088
+ const result = spawnSync(
1089
+ 'opencode',
1090
+ ['run', buildPrompt('opencode', query), '--model', model],
1091
+ {
1092
+ encoding: 'utf8',
1093
+ timeout: timeoutSec * 1000,
1094
+ maxBuffer: 1024 * 1024 * 2,
1095
+ stdio: ['pipe', 'pipe', 'pipe'],
1096
+ shell: isWindows,
1097
+ }
1098
+ );
1099
+
1100
+ if (result.error) {
1101
+ if (result.error.code === 'ENOENT')
1102
+ return { success: false, error: 'OpenCode not installed' };
1103
+ throw result.error;
1104
+ }
1105
+ if (result.status !== 0) {
1106
+ return {
1107
+ success: false,
1108
+ error: (result.stderr || '').trim() || `OpenCode exited with code ${result.status}`,
1109
+ };
1110
+ }
1111
+
1112
+ const output = (result.stdout || '').trim();
1113
+ if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) {
1114
+ return { success: false, error: 'Empty or too short response from OpenCode' };
1115
+ }
1116
+ return { success: true, content: output };
1117
+ } catch (error) {
1118
+ return {
1119
+ success: false,
1120
+ error: error.killed ? 'OpenCode timed out' : error.message || 'Unknown OpenCode error',
1121
+ };
1122
+ }
1123
+ }
1124
+
1125
+ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
1126
+ try {
1127
+ const result = spawnSync('grok', [buildPrompt('grok', query)], {
1128
+ encoding: 'utf8',
1129
+ timeout: timeoutSec * 1000,
1130
+ maxBuffer: 1024 * 1024 * 2,
1131
+ stdio: ['pipe', 'pipe', 'pipe'],
1132
+ shell: isWindows,
1133
+ });
1134
+
1135
+ if (result.error) {
1136
+ if (result.error.code === 'ENOENT')
1137
+ return { success: false, error: 'Grok CLI not installed' };
1138
+ throw result.error;
1139
+ }
1140
+ if (result.status !== 0) {
1141
+ return {
1142
+ success: false,
1143
+ error: (result.stderr || '').trim() || `Grok CLI exited with code ${result.status}`,
1144
+ };
1145
+ }
1146
+
1147
+ const output = (result.stdout || '').trim();
1148
+ if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) {
1149
+ return { success: false, error: 'Empty or too short response from Grok' };
1150
+ }
1151
+ return { success: true, content: output };
1152
+ } catch (error) {
1153
+ return {
1154
+ success: false,
1155
+ error: error.killed ? 'Grok CLI timed out' : error.message || 'Unknown Grok error',
1156
+ };
1157
+ }
1158
+ }
1159
+
1160
+ function outputSuccess(query, content, providerName) {
1161
+ emitHookOutput(buildSuccessHookOutput(query, providerName, content));
1162
+ }
1163
+
1164
+ function outputAllFailedMessage(query, errors) {
1165
+ emitHookOutput(buildFailureHookOutput(query, errors));
1166
+ }
1167
+
1168
+ function getConfiguredProviders() {
1169
+ return [
1170
+ {
1171
+ name: 'Exa',
1172
+ id: 'exa',
1173
+ available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')),
1174
+ fn: tryExaSearch,
1175
+ },
1176
+ {
1177
+ name: 'Tavily',
1178
+ id: 'tavily',
1179
+ available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')),
1180
+ fn: tryTavilySearch,
1181
+ },
1182
+ {
1183
+ name: 'Brave Search',
1184
+ id: 'brave',
1185
+ available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')),
1186
+ fn: tryBraveSearch,
1187
+ },
1188
+ {
1189
+ name: 'Bocha',
1190
+ id: 'bocha',
1191
+ available: () => isProviderEnabled('bocha') && Boolean(getProviderApiKey('bocha')),
1192
+ fn: tryBochaSearch,
1193
+ },
1194
+ {
1195
+ name: 'MiniMax Search',
1196
+ id: 'minimax',
1197
+ available: () => isProviderEnabled('minimax') && Boolean(getProviderApiKey('minimax')),
1198
+ fn: tryMinimaxSearch,
1199
+ },
1200
+ {
1201
+ name: 'SearXNG',
1202
+ id: 'searxng',
1203
+ available: () => isProviderEnabled('searxng') && Boolean(getSearxngBaseUrl()),
1204
+ fn: trySearxngSearch,
1205
+ },
1206
+ {
1207
+ name: 'DuckDuckGo',
1208
+ id: 'duckduckgo',
1209
+ available: () => isProviderEnabled('duckduckgo'),
1210
+ fn: tryDuckDuckGoSearch,
1211
+ },
1212
+ {
1213
+ name: 'Gemini CLI',
1214
+ id: 'gemini',
1215
+ available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'),
1216
+ fn: tryGeminiSearch,
1217
+ },
1218
+ {
1219
+ name: 'OpenCode',
1220
+ id: 'opencode',
1221
+ available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'),
1222
+ fn: tryOpenCodeSearch,
1223
+ },
1224
+ {
1225
+ name: 'Grok CLI',
1226
+ id: 'grok',
1227
+ available: () => isProviderEnabled('grok') && isCliAvailable('grok'),
1228
+ fn: tryGrokSearch,
1229
+ },
1230
+ ];
1231
+ }
1232
+
1233
+ function looksLikeQuotaExhaustion(errorMessage) {
1234
+ const lower = String(errorMessage || '').toLowerCase();
1235
+ return (
1236
+ (lower.includes('quota') &&
1237
+ (lower.includes('exceed') ||
1238
+ lower.includes('exhaust') ||
1239
+ lower.includes('deplet') ||
1240
+ lower.includes('limit') ||
1241
+ lower.includes('used up'))) ||
1242
+ lower.includes('insufficient credits') ||
1243
+ lower.includes('credit balance') ||
1244
+ lower.includes('out of credits') ||
1245
+ lower.includes('billing hard limit') ||
1246
+ lower.includes('monthly usage cap')
1247
+ );
1248
+ }
1249
+
1250
+ function looksLikeTransientFailure(errorMessage) {
1251
+ const lower = String(errorMessage || '').toLowerCase();
1252
+ return (
1253
+ lower.includes('timed out') ||
1254
+ lower.includes('timeout') ||
1255
+ lower.includes('temporarily unavailable') ||
1256
+ lower.includes('service unavailable') ||
1257
+ lower.includes('bad gateway') ||
1258
+ lower.includes('gateway timeout') ||
1259
+ lower.includes('socket hang up') ||
1260
+ lower.includes('econnreset') ||
1261
+ lower.includes('fetch failed') ||
1262
+ lower.includes('network')
1263
+ );
1264
+ }
1265
+
1266
+ function classifyProviderFailure(result) {
1267
+ const errorMessage = String(result.error || '');
1268
+ const statusCode =
1269
+ Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null;
1270
+ const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null;
1271
+
1272
+ if (looksLikeQuotaExhaustion(errorMessage)) {
1273
+ return {
1274
+ kind: 'cooldown',
1275
+ reason: 'quota_exhausted',
1276
+ cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC,
1277
+ retryAfterSec,
1278
+ };
1279
+ }
1280
+
1281
+ if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) {
1282
+ if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) {
1283
+ return {
1284
+ kind: 'retry',
1285
+ delayMs: retryAfterSec * 1000,
1286
+ reason: 'rate_limited_short_backoff',
1287
+ retryAfterSec,
1288
+ };
1289
+ }
1290
+
1291
+ return {
1292
+ kind: 'cooldown',
1293
+ reason: 'rate_limited',
1294
+ cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC,
1295
+ retryAfterSec,
1296
+ };
1297
+ }
1298
+
1299
+ if (
1300
+ (statusCode && [502, 503, 504].includes(statusCode)) ||
1301
+ looksLikeTransientFailure(errorMessage)
1302
+ ) {
1303
+ return {
1304
+ kind: 'retry',
1305
+ delayMs: TRANSIENT_RETRY_DELAY_MS,
1306
+ reason: 'transient_failure',
1307
+ retryAfterSec,
1308
+ };
1309
+ }
1310
+
1311
+ return {
1312
+ kind: 'fail',
1313
+ reason: 'non_retryable',
1314
+ retryAfterSec,
1315
+ };
1316
+ }
1317
+
1318
+ async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) {
1319
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) {
1320
+ traceWebSearchEvent('websearch_provider_attempt', {
1321
+ source: 'provider',
1322
+ providerId: provider.id,
1323
+ providerName: provider.name,
1324
+ attempt: attempt + 1,
1325
+ ...fingerprint,
1326
+ });
1327
+
1328
+ const result = await provider.fn(query, timeoutSec);
1329
+ if (result.success) {
1330
+ clearProviderCooldown(provider.id);
1331
+ return result;
1332
+ }
1333
+
1334
+ const policy = classifyProviderFailure(result);
1335
+ if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) {
1336
+ traceWebSearchEvent('websearch_provider_retry_scheduled', {
1337
+ source: 'provider',
1338
+ providerId: provider.id,
1339
+ providerName: provider.name,
1340
+ attempt: attempt + 1,
1341
+ delayMs: policy.delayMs,
1342
+ reason: policy.reason,
1343
+ retryAfterSec: policy.retryAfterSec,
1344
+ ...fingerprint,
1345
+ });
1346
+ await sleep(policy.delayMs);
1347
+ continue;
1348
+ }
1349
+
1350
+ if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') {
1351
+ const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC;
1352
+ const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error);
1353
+ traceWebSearchEvent('websearch_provider_cooldown_applied', {
1354
+ source: 'provider',
1355
+ providerId: provider.id,
1356
+ providerName: provider.name,
1357
+ cooldownUntil: until,
1358
+ cooldownSec,
1359
+ reason: 'rate_limited',
1360
+ retryAfterSec: policy.retryAfterSec,
1361
+ afterRetryExhausted: true,
1362
+ ...fingerprint,
1363
+ });
1364
+ return {
1365
+ ...result,
1366
+ error: `${result.error} (cooldown ${cooldownSec}s)`,
1367
+ };
1368
+ }
1369
+
1370
+ if (policy.kind === 'cooldown') {
1371
+ const until = applyProviderCooldown(
1372
+ provider.id,
1373
+ policy.cooldownSec,
1374
+ policy.reason,
1375
+ result.error
1376
+ );
1377
+ traceWebSearchEvent('websearch_provider_cooldown_applied', {
1378
+ source: 'provider',
1379
+ providerId: provider.id,
1380
+ providerName: provider.name,
1381
+ cooldownUntil: until,
1382
+ cooldownSec: policy.cooldownSec,
1383
+ reason: policy.reason,
1384
+ retryAfterSec: policy.retryAfterSec,
1385
+ ...fingerprint,
1386
+ });
1387
+ return {
1388
+ ...result,
1389
+ error: `${result.error} (cooldown ${policy.cooldownSec}s)`,
1390
+ };
1391
+ }
1392
+
1393
+ return result;
1394
+ }
1395
+
1396
+ return { success: false, error: 'Provider retry policy exhausted' };
1397
+ }
1398
+
1399
+ function getActiveProviders() {
1400
+ return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available());
1401
+ }
1402
+
1403
+ function getActiveProviderIds() {
1404
+ return getActiveProviders().map((provider) => provider.id);
1405
+ }
1406
+
1407
+ function hasAnyActiveProviders() {
1408
+ return getActiveProviders().length > 0;
1409
+ }
1410
+
1411
+ async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
1412
+ const fingerprint = getQueryFingerprint(query);
1413
+ const configuredProviders = getConfiguredProviders();
1414
+ const activeProviders = [];
1415
+
1416
+ for (const provider of configuredProviders) {
1417
+ const cooldown = getProviderCooldown(provider.id);
1418
+ if (cooldown) {
1419
+ traceWebSearchEvent('websearch_provider_cooldown_skip', {
1420
+ source: 'provider',
1421
+ providerId: provider.id,
1422
+ providerName: provider.name,
1423
+ cooldownUntil: cooldown.until,
1424
+ cooldownReason: cooldown.reason,
1425
+ remainingMs: Math.max(0, cooldown.until - Date.now()),
1426
+ ...fingerprint,
1427
+ });
1428
+ continue;
1429
+ }
1430
+
1431
+ if (provider.available()) {
1432
+ activeProviders.push(provider);
1433
+ }
1434
+ }
1435
+
1436
+ debug(
1437
+ `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
1438
+ );
1439
+ traceWebSearchEvent('websearch_provider_run_started', {
1440
+ source: 'provider',
1441
+ activeProviderIds: activeProviders.map((provider) => provider.id),
1442
+ ...fingerprint,
1443
+ });
1444
+
1445
+ if (activeProviders.length === 0) {
1446
+ traceWebSearchEvent('websearch_provider_run_unavailable', {
1447
+ source: 'provider',
1448
+ activeProviderIds: [],
1449
+ ...fingerprint,
1450
+ });
1451
+ return { success: false, noActiveProviders: true, errors: [] };
1452
+ }
1453
+
1454
+ const errors = [];
1455
+ for (const provider of activeProviders) {
1456
+ debug(`Trying ${provider.name}`);
1457
+ const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint);
1458
+ if (result.success) {
1459
+ traceWebSearchEvent('websearch_provider_success', {
1460
+ source: 'provider',
1461
+ providerId: provider.id,
1462
+ providerName: provider.name,
1463
+ ...fingerprint,
1464
+ });
1465
+ return {
1466
+ success: true,
1467
+ providerId: provider.id,
1468
+ providerName: provider.name,
1469
+ content: result.content,
1470
+ };
1471
+ }
1472
+ traceWebSearchEvent('websearch_provider_failure', {
1473
+ source: 'provider',
1474
+ providerId: provider.id,
1475
+ providerName: provider.name,
1476
+ error: result.error,
1477
+ ...fingerprint,
1478
+ });
1479
+ errors.push({ provider: provider.name, error: result.error });
1480
+ }
1481
+
1482
+ traceWebSearchEvent('websearch_provider_run_failed', {
1483
+ source: 'provider',
1484
+ errorCount: errors.length,
1485
+ activeProviderIds: activeProviders.map((provider) => provider.id),
1486
+ ...fingerprint,
1487
+ });
1488
+ return { success: false, noActiveProviders: false, errors };
1489
+ }
1490
+
1491
+ async function processHook(input) {
1492
+ try {
1493
+ if (shouldSkipHook()) {
1494
+ traceWebSearchEvent('websearch_hook_skipped', {
1495
+ source: 'hook',
1496
+ reason: getSkipReason(),
1497
+ });
1498
+ process.exit(0);
1499
+ }
1500
+
1501
+ const data = JSON.parse(input);
1502
+ if (data.tool_name !== 'WebSearch') {
1503
+ process.exit(0);
1504
+ }
1505
+
1506
+ const query = data.tool_input?.query || '';
1507
+ if (!query) {
1508
+ process.exit(0);
1509
+ }
1510
+
1511
+ traceWebSearchEvent('websearch_hook_invoked', {
1512
+ source: 'hook',
1513
+ ...getQueryFingerprint(query),
1514
+ });
1515
+
1516
+ const timeout = Number.parseInt(
1517
+ process.env.MCC_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
1518
+ 10
1519
+ );
1520
+ const result = await runLocalWebSearch(query, timeout);
1521
+ if (result.noActiveProviders) {
1522
+ traceWebSearchEvent('websearch_hook_no_active_providers', {
1523
+ source: 'hook',
1524
+ ...getQueryFingerprint(query),
1525
+ });
1526
+ process.exit(0);
1527
+ }
1528
+
1529
+ if (result.success) {
1530
+ traceWebSearchEvent('websearch_hook_success', {
1531
+ source: 'hook',
1532
+ providerId: result.providerId,
1533
+ providerName: result.providerName,
1534
+ ...getQueryFingerprint(query),
1535
+ });
1536
+ outputSuccess(query, result.content, result.providerName);
1537
+ return;
1538
+ }
1539
+
1540
+ traceWebSearchEvent('websearch_hook_failure', {
1541
+ source: 'hook',
1542
+ errorCount: result.errors.length,
1543
+ ...getQueryFingerprint(query),
1544
+ });
1545
+ outputAllFailedMessage(query, result.errors);
1546
+ } catch (error) {
1547
+ debug(`Hook error: ${error.message}`);
1548
+ traceWebSearchEvent('websearch_hook_error', {
1549
+ source: 'hook',
1550
+ error: error.message,
1551
+ });
1552
+ process.exit(0);
1553
+ }
1554
+ }
1555
+
1556
+ function startFromStdin() {
1557
+ let input = '';
1558
+ process.stdin.setEncoding('utf8');
1559
+ process.stdin.on('data', (chunk) => {
1560
+ input += chunk;
1561
+ });
1562
+ process.stdin.on('end', () => {
1563
+ processHook(input);
1564
+ });
1565
+ process.stdin.on('error', () => {
1566
+ process.exit(0);
1567
+ });
1568
+ }
1569
+
1570
+ if (require.main === module) {
1571
+ startFromStdin();
1572
+ }
1573
+
1574
+ module.exports = {
1575
+ buildFailureHookOutput,
1576
+ buildSuccessHookOutput,
1577
+ classifyDuckDuckGoHtml,
1578
+ extractDuckDuckGoResults,
1579
+ formatStructuredSearchResults,
1580
+ getActiveProviders,
1581
+ hasAnyActiveProviders,
1582
+ runLocalWebSearch,
1583
+ shouldSkipHook,
1584
+ getActiveProviderIds,
1585
+ classifyProviderFailure,
1586
+ getQueryFingerprint,
1587
+ getSkipReason,
1588
+ parseRetryAfterSeconds,
1589
+ traceWebSearchEvent,
1590
+ tryExaSearch,
1591
+ tryTavilySearch,
1592
+ tryDuckDuckGoSearch,
1593
+ tryBraveSearch,
1594
+ trySearxngSearch,
1595
+ tryBochaSearch,
1596
+ tryMinimaxSearch,
1597
+ };