@hileeon/mcc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.claude/CLAUDE.md +204 -0
  2. package/.claude/agents/.gitkeep +0 -0
  3. package/.claude/settings.json +9 -0
  4. package/.claude/skills/.gitkeep +0 -0
  5. package/README.md +127 -0
  6. package/dist/accounts/instance-manager.d.ts +11 -0
  7. package/dist/accounts/instance-manager.d.ts.map +1 -0
  8. package/dist/accounts/instance-manager.js +89 -0
  9. package/dist/accounts/instance-manager.js.map +1 -0
  10. package/dist/accounts/shared-manager.d.ts +25 -0
  11. package/dist/accounts/shared-manager.d.ts.map +1 -0
  12. package/dist/accounts/shared-manager.js +186 -0
  13. package/dist/accounts/shared-manager.js.map +1 -0
  14. package/dist/accounts/store.d.ts +30 -0
  15. package/dist/accounts/store.d.ts.map +1 -0
  16. package/dist/accounts/store.js +128 -0
  17. package/dist/accounts/store.js.map +1 -0
  18. package/dist/core/model-router.d.ts +30 -0
  19. package/dist/core/model-router.d.ts.map +1 -0
  20. package/dist/core/model-router.js +64 -0
  21. package/dist/core/model-router.js.map +1 -0
  22. package/dist/dashboard-server.d.ts +5 -0
  23. package/dist/dashboard-server.d.ts.map +1 -0
  24. package/dist/dashboard-server.js +387 -0
  25. package/dist/dashboard-server.js.map +1 -0
  26. package/dist/mcc.d.ts +8 -0
  27. package/dist/mcc.d.ts.map +1 -0
  28. package/dist/mcc.js +474 -0
  29. package/dist/mcc.js.map +1 -0
  30. package/dist/mcp/external-registry.d.ts +24 -0
  31. package/dist/mcp/external-registry.d.ts.map +1 -0
  32. package/dist/mcp/external-registry.js +99 -0
  33. package/dist/mcp/external-registry.js.map +1 -0
  34. package/dist/mcp/installer.d.ts +31 -0
  35. package/dist/mcp/installer.d.ts.map +1 -0
  36. package/dist/mcp/installer.js +273 -0
  37. package/dist/mcp/installer.js.map +1 -0
  38. package/dist/mcp/mcp-config.d.ts +86 -0
  39. package/dist/mcp/mcp-config.d.ts.map +1 -0
  40. package/dist/mcp/mcp-config.js +178 -0
  41. package/dist/mcp/mcp-config.js.map +1 -0
  42. package/dist/mcp/registry.d.ts +23 -0
  43. package/dist/mcp/registry.d.ts.map +1 -0
  44. package/dist/mcp/registry.js +100 -0
  45. package/dist/mcp/registry.js.map +1 -0
  46. package/dist/proxy/proxy-daemon.d.ts +27 -0
  47. package/dist/proxy/proxy-daemon.d.ts.map +1 -0
  48. package/dist/proxy/proxy-daemon.js +192 -0
  49. package/dist/proxy/proxy-daemon.js.map +1 -0
  50. package/dist/proxy/proxy-entry.d.ts +11 -0
  51. package/dist/proxy/proxy-entry.d.ts.map +1 -0
  52. package/dist/proxy/proxy-entry.js +74 -0
  53. package/dist/proxy/proxy-entry.js.map +1 -0
  54. package/dist/proxy/proxy-paths.d.ts +27 -0
  55. package/dist/proxy/proxy-paths.d.ts.map +1 -0
  56. package/dist/proxy/proxy-paths.js +125 -0
  57. package/dist/proxy/proxy-paths.js.map +1 -0
  58. package/dist/proxy/proxy-server.d.ts +20 -0
  59. package/dist/proxy/proxy-server.d.ts.map +1 -0
  60. package/dist/proxy/proxy-server.js +280 -0
  61. package/dist/proxy/proxy-server.js.map +1 -0
  62. package/dist/proxy/upstream-url.d.ts +7 -0
  63. package/dist/proxy/upstream-url.d.ts.map +1 -0
  64. package/dist/proxy/upstream-url.js +38 -0
  65. package/dist/proxy/upstream-url.js.map +1 -0
  66. package/dist/shared/logger.d.ts +23 -0
  67. package/dist/shared/logger.d.ts.map +1 -0
  68. package/dist/shared/logger.js +184 -0
  69. package/dist/shared/logger.js.map +1 -0
  70. package/dist/shared/provider-preset-catalog.d.ts +41 -0
  71. package/dist/shared/provider-preset-catalog.d.ts.map +1 -0
  72. package/dist/shared/provider-preset-catalog.js +299 -0
  73. package/dist/shared/provider-preset-catalog.js.map +1 -0
  74. package/docs/decisions.md +33 -0
  75. package/docs/lessons.md +8 -0
  76. package/docs/product.md +37 -0
  77. package/lib/mcp/mcc-image-analysis-server.cjs +454 -0
  78. package/lib/mcp/mcc-websearch-server.cjs +339 -0
  79. package/lib/mcp-hooks/image-analysis-runtime.cjs +510 -0
  80. package/lib/mcp-hooks/image-analyzer-transformer.cjs +526 -0
  81. package/lib/mcp-hooks/websearch-transformer.cjs +1421 -0
  82. package/lib/proxy/config/config-loader-facade.js +24 -0
  83. package/lib/proxy/glmt/delta-accumulator.js +363 -0
  84. package/lib/proxy/glmt/glmt-transformer.js +204 -0
  85. package/lib/proxy/glmt/index.js +41 -0
  86. package/lib/proxy/glmt/locale-enforcer.js +69 -0
  87. package/lib/proxy/glmt/pipeline/content-transformer.js +162 -0
  88. package/lib/proxy/glmt/pipeline/index.js +20 -0
  89. package/lib/proxy/glmt/pipeline/request-transformer.js +116 -0
  90. package/lib/proxy/glmt/pipeline/response-builder.js +205 -0
  91. package/lib/proxy/glmt/pipeline/stream-parser.js +234 -0
  92. package/lib/proxy/glmt/pipeline/tool-call-handler.js +78 -0
  93. package/lib/proxy/glmt/pipeline/types.js +6 -0
  94. package/lib/proxy/glmt/reasoning-enforcer.js +151 -0
  95. package/lib/proxy/glmt/sse-parser.js +102 -0
  96. package/lib/proxy/services/logging.js +13 -0
  97. package/lib/proxy/transformers/request-transformer.js +452 -0
  98. package/lib/proxy/transformers/sse-stream-transformer.js +199 -0
  99. package/lib/shared/logger.cjs +138 -0
  100. package/package.json +35 -0
  101. package/src/accounts/instance-manager.ts +58 -0
  102. package/src/accounts/shared-manager.ts +154 -0
  103. package/src/accounts/store.ts +111 -0
  104. package/src/core/model-router.ts +82 -0
  105. package/src/dashboard-server.ts +407 -0
  106. package/src/mcc.ts +474 -0
  107. package/src/mcp/external-registry.ts +73 -0
  108. package/src/mcp/installer.ts +258 -0
  109. package/src/mcp/mcp-config.ts +168 -0
  110. package/src/mcp/registry.ts +89 -0
  111. package/src/proxy/proxy-daemon.ts +184 -0
  112. package/src/proxy/proxy-entry.ts +63 -0
  113. package/src/proxy/proxy-paths.ts +97 -0
  114. package/src/proxy/proxy-server.ts +278 -0
  115. package/src/proxy/upstream-url.ts +38 -0
  116. package/src/shared/logger.ts +140 -0
  117. package/src/shared/provider-preset-catalog.ts +340 -0
  118. package/tsconfig.json +33 -0
  119. package/ui/.prettierrc +9 -0
  120. package/ui/index.html +12 -0
  121. package/ui/package.json +33 -0
  122. package/ui/postcss.config.js +6 -0
  123. package/ui/src/App.tsx +753 -0
  124. package/ui/src/components/ui/button.tsx +48 -0
  125. package/ui/src/components/ui/card.tsx +50 -0
  126. package/ui/src/components/ui/input.tsx +21 -0
  127. package/ui/src/components/ui/label.tsx +20 -0
  128. package/ui/src/components/ui/select.tsx +80 -0
  129. package/ui/src/components/ui/switch.tsx +26 -0
  130. package/ui/src/components/ui/tabs.tsx +52 -0
  131. package/ui/src/index.css +33 -0
  132. package/ui/src/lib/api.ts +185 -0
  133. package/ui/src/lib/utils.ts +6 -0
  134. package/ui/src/main.tsx +10 -0
  135. package/ui/src/vite-env.d.ts +1 -0
  136. package/ui/tailwind.config.js +49 -0
  137. package/ui/tsconfig.json +25 -0
  138. package/ui/vite.config.ts +20 -0
@@ -0,0 +1,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 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
+ };