#!/usr/bin/env node /** * CCS WebSearch Hook - deterministic search backends with legacy CLI fallback * * Primary providers: * - Exa Search API * - Tavily Search API * - Brave Search API * - SearXNG JSON API * - DuckDuckGo HTML search * * Legacy compatibility fallback: * - Gemini CLI * - OpenCode * - Grok CLI */ const { spawnSync } = require('child_process'); const { createHash } = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const isWindows = process.platform === 'win32'; const DEFAULT_TIMEOUT_SEC = 55; const DEFAULT_RESULT_COUNT = 5; const MIN_VALID_RESPONSE_LENGTH = 20; const EXA_URL = 'https://api.exa.ai/search'; const TAVILY_URL = 'https://api.tavily.com/search'; const DDG_URL = 'https://html.duckduckgo.com/html/'; const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search'; const USER_AGENT = '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'; const PROVIDER_STATE_FILE = 'websearch-provider-state.json'; const SHORT_RETRY_AFTER_MAX_SEC = 3; const TRANSIENT_RETRY_DELAY_MS = 750; const TRANSIENT_RETRY_ATTEMPTS = 1; const DEFAULT_RATE_LIMIT_COOLDOWN_SEC = 120; const DEFAULT_QUOTA_COOLDOWN_SEC = 900; const MAX_PROVIDER_COOLDOWN_SEC = 60 * 60; const SHARED_INSTRUCTIONS = `Instructions: 1. Search the web for current, up-to-date information 2. Provide a comprehensive summary of the search results 3. Include relevant URLs/sources when available 4. Be concise but thorough - prioritize key facts 5. Focus on factual information from reliable sources 6. If results conflict, note the discrepancy 7. Format output clearly with sections if the topic is complex`; const PROVIDER_CONFIG = { gemini: { model: 'gemini-2.5-flash', toolInstruction: 'Use the google_web_search tool to find current information.', quirks: null, }, opencode: { model: 'opencode/grok-code', toolInstruction: 'Search the web using your built-in capabilities.', quirks: null, }, grok: { model: 'grok-3', toolInstruction: 'Use your web search capabilities to find information.', quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.', }, }; const ddgLinkRe = /]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; const ddgSnippetRe = /([\s\S]*?)<\/a>/g; const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i; const ddgNoResultsHeadingRe = /No results found for/i; const htmlTagRe = /<[^>]+>/g; function debug(message) { if (process.env.CCS_DEBUG) { console.error(`[CCS Hook] ${message}`); } } function getCcsDirPath() { if ((process.env.CCS_DIR || '').trim()) { return path.resolve(process.env.CCS_DIR.trim()); } if ((process.env.CCS_HOME || '').trim()) { return path.join(path.resolve(process.env.CCS_HOME.trim()), '.ccs'); } const home = (process.env.HOME || process.env.USERPROFILE || '').trim(); if (home) { return path.join(home, '.ccs'); } return path.join(process.cwd(), '.ccs'); } function isTraceEnabled() { return process.env.CCS_WEBSEARCH_TRACE === '1' || process.env.CCS_DEBUG === '1'; } function normalizeSafePrefix(inputPath) { return `${path.resolve(inputPath)}${path.sep}`; } function getSafeTracePrefixes() { return [ normalizeSafePrefix(path.join(getCcsDirPath(), 'logs')), normalizeSafePrefix(os.tmpdir()), normalizeSafePrefix('/var/log'), ]; } function getProviderStatePath() { return path.join(getCcsDirPath(), 'cache', PROVIDER_STATE_FILE); } function readProviderState() { try { const statePath = getProviderStatePath(); if (!fs.existsSync(statePath)) { return { cooldowns: {} }; } const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')); const cooldowns = parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object' ? parsed.cooldowns : {}; return { cooldowns }; } catch { return { cooldowns: {} }; } } function writeProviderState(state) { try { const statePath = getProviderStatePath(); fs.mkdirSync(path.dirname(statePath), { recursive: true }); const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(state, null, 2) + '\n', 'utf8'); fs.renameSync(tempPath, statePath); } catch { // Best-effort only. } } function sanitizeProviderState(state) { const now = Date.now(); const nextCooldowns = {}; let changed = false; for (const [providerId, entry] of Object.entries(state.cooldowns || {})) { if (!entry || typeof entry !== 'object') { changed = true; continue; } const until = Number.parseInt(String(entry.until || ''), 10); if (!Number.isFinite(until) || until <= now) { changed = true; continue; } nextCooldowns[providerId] = { until, reason: typeof entry.reason === 'string' ? entry.reason : 'rate_limited', updatedAt: Number.parseInt(String(entry.updatedAt || ''), 10) || now, sourceError: typeof entry.sourceError === 'string' ? entry.sourceError : '', }; } return { state: { cooldowns: nextCooldowns }, changed, }; } function getProviderCooldown(providerId) { const { state, changed } = sanitizeProviderState(readProviderState()); if (changed) { writeProviderState(state); } return state.cooldowns[providerId] || null; } function clearProviderCooldown(providerId) { const { state } = sanitizeProviderState(readProviderState()); if (!(providerId in state.cooldowns)) { return; } delete state.cooldowns[providerId]; writeProviderState(state); } function applyProviderCooldown(providerId, cooldownSec, reason, sourceError) { const clampedCooldownSec = Math.max( 1, Math.min(MAX_PROVIDER_COOLDOWN_SEC, Math.floor(cooldownSec)) ); const { state } = sanitizeProviderState(readProviderState()); const until = Date.now() + clampedCooldownSec * 1000; state.cooldowns[providerId] = { until, reason, updatedAt: Date.now(), sourceError: sourceError || '', }; writeProviderState(state); return until; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getAllowedTraceFileOverride() { const configured = (process.env.CCS_WEBSEARCH_TRACE_FILE || '').trim(); if (!configured) { return null; } const resolved = path.resolve(configured); if (getSafeTracePrefixes().some((prefix) => resolved.startsWith(prefix))) { return resolved; } return null; } function getTraceFilePath() { const fallback = path.join(getCcsDirPath(), 'logs', 'websearch-trace.jsonl'); return getAllowedTraceFileOverride() || fallback; } function traceWebSearchEvent(event, payload = {}) { if (!isTraceEnabled()) { return; } try { const traceFilePath = getTraceFilePath(); fs.mkdirSync(path.dirname(traceFilePath), { recursive: true }); fs.appendFileSync( traceFilePath, JSON.stringify({ at: new Date().toISOString(), event, launchId: process.env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null, launcher: process.env.CCS_WEBSEARCH_TRACE_LAUNCHER || null, profileType: process.env.CCS_PROFILE_TYPE || null, pid: process.pid, ...payload, }) + '\n', 'utf8' ); } catch { // Best-effort only. } } function readHeaderValue(headers, headerName) { if (!headers) { return ''; } if (typeof headers.get === 'function') { return headers.get(headerName) || ''; } const direct = headers[headerName] ?? headers[String(headerName).toLowerCase()]; if (Array.isArray(direct)) { return direct[0] || ''; } return typeof direct === 'string' ? direct : ''; } function parseRetryAfterSeconds(rawValue) { const value = String(rawValue || '').trim(); if (!value) { return null; } const asSeconds = Number.parseInt(value, 10); if (Number.isFinite(asSeconds) && asSeconds > 0) { return asSeconds; } const asDate = Date.parse(value); if (Number.isFinite(asDate)) { const deltaSec = Math.ceil((asDate - Date.now()) / 1000); return deltaSec > 0 ? deltaSec : null; } return null; } function getQueryFingerprint(query) { const normalizedQuery = typeof query === 'string' ? query.trim() : ''; return { queryHash: normalizedQuery ? createHash('sha256').update(normalizedQuery).digest('hex').slice(0, 16) : null, queryLength: normalizedQuery.length, }; } function getSkipReason() { if (process.env.CCS_WEBSEARCH_SKIP === '1') return 'skip_flag'; const profileType = process.env.CCS_PROFILE_TYPE; if (profileType === 'account') return 'native_account_profile'; if (profileType === 'default') return 'native_default_profile'; if (process.env.CCS_WEBSEARCH_ENABLED === '0') return 'disabled'; return null; } function shouldSkipHook() { return getSkipReason() !== null; } function isCliAvailable(cmd) { try { const whichCmd = isWindows ? 'where.exe' : 'which'; const result = spawnSync(whichCmd, [cmd], { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], }); return result.status === 0 && result.stdout.trim().length > 0; } catch { return false; } } function isProviderEnabled(provider) { return process.env[`CCS_WEBSEARCH_${provider.toUpperCase()}`] === '1'; } function hasEnvValue(name) { return (process.env[name] || '').trim().length > 0; } function getFirstEnvValue(names) { for (const name of names) { if (hasEnvValue(name)) { return process.env[name].trim(); } } return ''; } function getProviderApiKey(providerId) { switch (providerId) { case 'brave': return getFirstEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']); case 'exa': return getFirstEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']); case 'tavily': return getFirstEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']); default: return ''; } } function getResultCount(provider) { const raw = process.env[`CCS_WEBSEARCH_${provider.toUpperCase()}_MAX_RESULTS`]; const parsed = Number.parseInt(raw || '', 10); return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT; } function getSearxngBaseUrl() { const raw = (process.env.CCS_WEBSEARCH_SEARXNG_URL || '').trim(); if (!raw) { return ''; } try { const parsed = new URL(raw); if (!['http:', 'https:'].includes(parsed.protocol) || parsed.search || parsed.hash) { return ''; } if (parsed.username || parsed.password) { return ''; } let pathname = parsed.pathname.replace(/\/+$/, ''); if (pathname.toLowerCase().endsWith('/search')) { pathname = pathname.slice(0, -'/search'.length); } parsed.pathname = pathname || '/'; parsed.search = ''; parsed.hash = ''; return parsed.toString().replace(/\/+$/, ''); } catch { return ''; } } function buildPrompt(providerId, query) { const config = PROVIDER_CONFIG[providerId]; const parts = [ `Search the web for: ${query}`, '', config.toolInstruction, '', SHARED_INSTRUCTIONS, ]; if (config.quirks) { parts.push('', `Note: ${config.quirks}`); } return parts.join('\n'); } function decodeHtml(value) { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>'); } function compactText(value, maxLength = 280) { const text = String(value || '') .replace(/\s+/g, ' ') .trim(); if (text.length <= maxLength) { return text; } return `${text.slice(0, maxLength - 3).trimEnd()}...`; } function extractDuckDuckGoResults(html, count) { const links = [...html.matchAll(ddgLinkRe)].slice(0, count + 5); const snippets = [...html.matchAll(ddgSnippetRe)].slice(0, count + 5); return links.slice(0, count).map((match, index) => { let url = match[1]; if (url.includes('uddg=')) { try { const decoded = decodeURIComponent(url); const uddgIndex = decoded.indexOf('uddg='); if (uddgIndex !== -1) { url = decoded.slice(uddgIndex + 5).split('&')[0]; } } catch { // keep original url } } return { title: decodeHtml(match[2].replace(htmlTagRe, '').trim()), url, description: decodeHtml((snippets[index]?.[1] || '').replace(htmlTagRe, '').trim()), }; }); } function classifyDuckDuckGoHtml(html, count) { const responseHtml = String(html || ''); const results = extractDuckDuckGoResults(responseHtml, count); if (results.length > 0) { return { kind: 'results', results, }; } if (ddgNoResultsRe.test(responseHtml) || ddgNoResultsHeadingRe.test(responseHtml)) { return { kind: 'no_results', results: [], }; } return { kind: 'non_result_html', results: [], error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)', }; } function formatStructuredSearchResults(query, providerName, results) { const lines = [ 'CCS local WebSearch evidence', `Provider: ${providerName}`, `Query: "${query}"`, `Result count: ${results.length}`, '', ]; if (!results.length) { lines.push('No results found.'); return lines.join('\n'); } for (const [index, result] of results.entries()) { lines.push(`${index + 1}. ${result.title}`); lines.push(` URL: ${result.url}`); if (result.description) { lines.push(` Snippet: ${result.description}`); } lines.push(''); } return lines.join('\n'); } function buildSuccessHookOutput(query, providerName, content) { return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `CCS already retrieved WebSearch results locally via ${providerName}. Use the provided context instead of calling native WebSearch for "${query}".`, additionalContext: content, }, }; } function buildFailureHookOutput(query, errors) { const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | '); return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `CCS could not complete local WebSearch for "${query}". Native WebSearch is unavailable for this profile.`, additionalContext: `CCS local WebSearch failed for "${query}". Attempted providers: ${detail}`, }, }; } function emitHookOutput(output) { console.log(JSON.stringify(output)); process.exit(0); } async function fetchWithTimeout(url, options, timeoutMs) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } } async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const apiKey = getProviderApiKey('brave'); if (!apiKey) { return { success: false, error: 'BRAVE_API_KEY is not set' }; } const params = new URLSearchParams({ q: query, count: String(getResultCount('brave')), }); try { const response = await fetchWithTimeout( `${BRAVE_URL}?${params.toString()}`, { headers: { Accept: 'application/json', 'User-Agent': USER_AGENT, 'X-Subscription-Token': apiKey, }, }, timeoutSec * 1000 ); if (!response.ok) { const body = await response.text(); return { success: false, error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`, statusCode: response.status, retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } const body = await response.json(); const results = (body.web?.results || []).map((result) => ({ title: result.title || 'Untitled', url: result.url || '', description: result.description || '', })); return { success: true, content: formatStructuredSearchResults(query, 'Brave Search', results), }; } catch (error) { return { success: false, error: error.name === 'AbortError' ? 'Brave Search timed out' : error.message, }; } } async function trySearxngSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const baseUrl = getSearxngBaseUrl(); if (!baseUrl) { return { success: false, error: 'SearXNG URL is invalid or not configured' }; } const params = new URLSearchParams({ q: query, format: 'json', }); try { const response = await fetchWithTimeout( `${baseUrl}/search?${params.toString()}`, { headers: { Accept: 'application/json', 'User-Agent': USER_AGENT, }, }, timeoutSec * 1000 ); if (!response.ok) { const body = await response.text(); if ( response.status === 403 && /format(?:=|\s*)json|json[^\n]{0,40}disabled|disabled[^\n]{0,40}json/i.test(body) ) { return { success: false, error: 'SearXNG returned 403: format=json is disabled on this instance', statusCode: response.status, retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } return { success: false, error: `SearXNG returned ${response.status}: ${body.slice(0, 160)}`, statusCode: response.status, retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } let body; try { body = await response.json(); } catch { return { success: false, error: 'SearXNG returned malformed JSON payload', }; } if (!body || typeof body !== 'object' || !Array.isArray(body.results)) { return { success: false, error: 'SearXNG JSON response is missing results[]', }; } const count = getResultCount('searxng'); const results = body.results.slice(0, count).map((entry) => { const result = entry && typeof entry === 'object' ? entry : {}; const url = typeof result.url === 'string' ? result.url : ''; const titleSource = typeof result.title === 'string' && result.title.trim().length > 0 ? result.title : url || 'Untitled'; const descriptionSource = typeof result.content === 'string' ? result.content : typeof result.description === 'string' ? result.description : typeof result.snippet === 'string' ? result.snippet : ''; return { title: compactText(titleSource, 120), url, description: compactText(descriptionSource, 240), }; }); return { success: true, content: formatStructuredSearchResults(query, 'SearXNG', results), }; } catch (error) { return { success: false, error: error.name === 'AbortError' ? 'SearXNG timed out' : error.message, }; } } async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const apiKey = getProviderApiKey('exa'); if (!apiKey) { return { success: false, error: 'EXA_API_KEY is not set' }; } try { const response = await fetchWithTimeout( EXA_URL, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': USER_AGENT, 'x-api-key': apiKey, }, body: JSON.stringify({ query, type: 'auto', numResults: getResultCount('exa'), text: true, }), }, timeoutSec * 1000 ); if (!response.ok) { const body = await response.text(); return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}`, statusCode: response.status, retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } const body = await response.json(); const results = (body.results || []).map((result) => ({ title: compactText(result.title || result.url || 'Untitled', 120), url: result.url || '', description: compactText(result.text || result.summary || '', 240), })); return { success: true, content: formatStructuredSearchResults(query, 'Exa', results), }; } catch (error) { return { success: false, error: error.name === 'AbortError' ? 'Exa timed out' : error.message, }; } } async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const apiKey = getProviderApiKey('tavily'); if (!apiKey) { return { success: false, error: 'TAVILY_API_KEY is not set' }; } try { const response = await fetchWithTimeout( TAVILY_URL, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': USER_AGENT, }, body: JSON.stringify({ query, search_depth: 'basic', max_results: getResultCount('tavily'), include_answer: false, include_raw_content: false, }), }, timeoutSec * 1000 ); if (!response.ok) { const body = await response.text(); return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}`, statusCode: response.status, retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } const body = await response.json(); const results = (body.results || []).map((result) => ({ title: compactText(result.title || result.url || 'Untitled', 120), url: result.url || '', description: compactText(result.content || '', 240), })); return { success: true, content: formatStructuredSearchResults(query, 'Tavily', results), }; } catch (error) { return { success: false, error: error.name === 'AbortError' ? 'Tavily timed out' : error.message, }; } } async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const params = new URLSearchParams({ q: query }); const response = await fetchWithTimeout( `${DDG_URL}?${params.toString()}`, { headers: { Accept: 'text/html', 'User-Agent': USER_AGENT, }, }, timeoutSec * 1000 ); if (!response.ok) { return { success: false, error: `DuckDuckGo returned ${response.status}`, statusCode: response.status, retryAfterSec: parseRetryAfterSeconds(readHeaderValue(response.headers, 'retry-after')), }; } const html = await response.text(); const parsed = classifyDuckDuckGoHtml(html, getResultCount('duckduckgo')); if (parsed.kind === 'non_result_html') { return { success: false, error: `${parsed.error} (status ${response.status})`, statusCode: response.status, }; } return { success: true, content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results), }; } catch (error) { return { success: false, error: error.name === 'AbortError' ? 'DuckDuckGo timed out' : error.message, }; } } function shouldRetryGeminiWithLegacyPrompt(errorMessage) { const lower = (errorMessage || '').toLowerCase(); return ( lower.includes('unknown option') || lower.includes('unknown argument') || lower.includes('unrecognized option') || lower.includes('usage: gemini') || lower.includes('use --prompt') || lower.includes('using the --prompt option') ); } function runGeminiCommand(args, timeoutMs) { const result = spawnSync('gemini', args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], shell: isWindows, }); if (result.error) { if (result.error.code === 'ENOENT') return { success: false, error: 'Gemini CLI not installed' }; throw result.error; } if (result.status !== 0) { return { success: false, error: (result.stderr || '').trim() || `Gemini CLI exited with code ${result.status}`, }; } const output = (result.stdout || '').trim(); if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from Gemini' }; } return { success: true, content: output }; } function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const timeoutMs = timeoutSec * 1000; const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || PROVIDER_CONFIG.gemini.model; const prompt = buildPrompt('gemini', query); const baseArgs = ['--model', model, '--yolo']; debug(`Executing Gemini legacy fallback with model ${model}`); const positionalResult = runGeminiCommand([...baseArgs, prompt], timeoutMs); if (positionalResult.success || !shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) { return positionalResult; } return runGeminiCommand([...baseArgs, '-p', prompt], timeoutMs); } catch (error) { return { success: false, error: error.killed ? 'Gemini CLI timed out' : error.message || 'Unknown Gemini error', }; } } function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || PROVIDER_CONFIG.opencode.model; const result = spawnSync( 'opencode', ['run', buildPrompt('opencode', query), '--model', model], { encoding: 'utf8', timeout: timeoutSec * 1000, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], shell: isWindows, } ); if (result.error) { if (result.error.code === 'ENOENT') return { success: false, error: 'OpenCode not installed' }; throw result.error; } if (result.status !== 0) { return { success: false, error: (result.stderr || '').trim() || `OpenCode exited with code ${result.status}`, }; } const output = (result.stdout || '').trim(); if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from OpenCode' }; } return { success: true, content: output }; } catch (error) { return { success: false, error: error.killed ? 'OpenCode timed out' : error.message || 'Unknown OpenCode error', }; } } function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const result = spawnSync('grok', [buildPrompt('grok', query)], { encoding: 'utf8', timeout: timeoutSec * 1000, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], shell: isWindows, }); if (result.error) { if (result.error.code === 'ENOENT') return { success: false, error: 'Grok CLI not installed' }; throw result.error; } if (result.status !== 0) { return { success: false, error: (result.stderr || '').trim() || `Grok CLI exited with code ${result.status}`, }; } const output = (result.stdout || '').trim(); if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from Grok' }; } return { success: true, content: output }; } catch (error) { return { success: false, error: error.killed ? 'Grok CLI timed out' : error.message || 'Unknown Grok error', }; } } function outputSuccess(query, content, providerName) { emitHookOutput(buildSuccessHookOutput(query, providerName, content)); } function outputAllFailedMessage(query, errors) { emitHookOutput(buildFailureHookOutput(query, errors)); } function getConfiguredProviders() { return [ { name: 'Exa', id: 'exa', available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')), fn: tryExaSearch, }, { name: 'Tavily', id: 'tavily', available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')), fn: tryTavilySearch, }, { name: 'Brave Search', id: 'brave', available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')), fn: tryBraveSearch, }, { name: 'SearXNG', id: 'searxng', available: () => isProviderEnabled('searxng') && Boolean(getSearxngBaseUrl()), fn: trySearxngSearch, }, { name: 'DuckDuckGo', id: 'duckduckgo', available: () => isProviderEnabled('duckduckgo'), fn: tryDuckDuckGoSearch, }, { name: 'Gemini CLI', id: 'gemini', available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'), fn: tryGeminiSearch, }, { name: 'OpenCode', id: 'opencode', available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'), fn: tryOpenCodeSearch, }, { name: 'Grok CLI', id: 'grok', available: () => isProviderEnabled('grok') && isCliAvailable('grok'), fn: tryGrokSearch, }, ]; } function looksLikeQuotaExhaustion(errorMessage) { const lower = String(errorMessage || '').toLowerCase(); return ( (lower.includes('quota') && (lower.includes('exceed') || lower.includes('exhaust') || lower.includes('deplet') || lower.includes('limit') || lower.includes('used up'))) || lower.includes('insufficient credits') || lower.includes('credit balance') || lower.includes('out of credits') || lower.includes('billing hard limit') || lower.includes('monthly usage cap') ); } function looksLikeTransientFailure(errorMessage) { const lower = String(errorMessage || '').toLowerCase(); return ( lower.includes('timed out') || lower.includes('timeout') || lower.includes('temporarily unavailable') || lower.includes('service unavailable') || lower.includes('bad gateway') || lower.includes('gateway timeout') || lower.includes('socket hang up') || lower.includes('econnreset') || lower.includes('fetch failed') || lower.includes('network') ); } function classifyProviderFailure(result) { const errorMessage = String(result.error || ''); const statusCode = Number.isFinite(result.statusCode) && result.statusCode > 0 ? result.statusCode : null; const retryAfterSec = Number.isFinite(result.retryAfterSec) ? result.retryAfterSec : null; if (looksLikeQuotaExhaustion(errorMessage)) { return { kind: 'cooldown', reason: 'quota_exhausted', cooldownSec: retryAfterSec || DEFAULT_QUOTA_COOLDOWN_SEC, retryAfterSec, }; } if (statusCode === 429 || /too many requests|rate limit/i.test(errorMessage)) { if (retryAfterSec && retryAfterSec <= SHORT_RETRY_AFTER_MAX_SEC) { return { kind: 'retry', delayMs: retryAfterSec * 1000, reason: 'rate_limited_short_backoff', retryAfterSec, }; } return { kind: 'cooldown', reason: 'rate_limited', cooldownSec: retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC, retryAfterSec, }; } if ( (statusCode && [502, 503, 504].includes(statusCode)) || looksLikeTransientFailure(errorMessage) ) { return { kind: 'retry', delayMs: TRANSIENT_RETRY_DELAY_MS, reason: 'transient_failure', retryAfterSec, }; } return { kind: 'fail', reason: 'non_retryable', retryAfterSec, }; } async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) { for (let attempt = 0; attempt <= TRANSIENT_RETRY_ATTEMPTS; attempt += 1) { traceWebSearchEvent('websearch_provider_attempt', { source: 'provider', providerId: provider.id, providerName: provider.name, attempt: attempt + 1, ...fingerprint, }); const result = await provider.fn(query, timeoutSec); if (result.success) { clearProviderCooldown(provider.id); return result; } const policy = classifyProviderFailure(result); if (policy.kind === 'retry' && attempt < TRANSIENT_RETRY_ATTEMPTS) { traceWebSearchEvent('websearch_provider_retry_scheduled', { source: 'provider', providerId: provider.id, providerName: provider.name, attempt: attempt + 1, delayMs: policy.delayMs, reason: policy.reason, retryAfterSec: policy.retryAfterSec, ...fingerprint, }); await sleep(policy.delayMs); continue; } if (policy.kind === 'retry' && policy.reason === 'rate_limited_short_backoff') { const cooldownSec = policy.retryAfterSec || DEFAULT_RATE_LIMIT_COOLDOWN_SEC; const until = applyProviderCooldown(provider.id, cooldownSec, 'rate_limited', result.error); traceWebSearchEvent('websearch_provider_cooldown_applied', { source: 'provider', providerId: provider.id, providerName: provider.name, cooldownUntil: until, cooldownSec, reason: 'rate_limited', retryAfterSec: policy.retryAfterSec, afterRetryExhausted: true, ...fingerprint, }); return { ...result, error: `${result.error} (cooldown ${cooldownSec}s)`, }; } if (policy.kind === 'cooldown') { const until = applyProviderCooldown( provider.id, policy.cooldownSec, policy.reason, result.error ); traceWebSearchEvent('websearch_provider_cooldown_applied', { source: 'provider', providerId: provider.id, providerName: provider.name, cooldownUntil: until, cooldownSec: policy.cooldownSec, reason: policy.reason, retryAfterSec: policy.retryAfterSec, ...fingerprint, }); return { ...result, error: `${result.error} (cooldown ${policy.cooldownSec}s)`, }; } return result; } return { success: false, error: 'Provider retry policy exhausted' }; } function getActiveProviders() { return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available()); } function getActiveProviderIds() { return getActiveProviders().map((provider) => provider.id); } function hasAnyActiveProviders() { return getActiveProviders().length > 0; } async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { const fingerprint = getQueryFingerprint(query); const configuredProviders = getConfiguredProviders(); const activeProviders = []; for (const provider of configuredProviders) { const cooldown = getProviderCooldown(provider.id); if (cooldown) { traceWebSearchEvent('websearch_provider_cooldown_skip', { source: 'provider', providerId: provider.id, providerName: provider.name, cooldownUntil: cooldown.until, cooldownReason: cooldown.reason, remainingMs: Math.max(0, cooldown.until - Date.now()), ...fingerprint, }); continue; } if (provider.available()) { activeProviders.push(provider); } } debug( `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` ); traceWebSearchEvent('websearch_provider_run_started', { source: 'provider', activeProviderIds: activeProviders.map((provider) => provider.id), ...fingerprint, }); if (activeProviders.length === 0) { traceWebSearchEvent('websearch_provider_run_unavailable', { source: 'provider', activeProviderIds: [], ...fingerprint, }); return { success: false, noActiveProviders: true, errors: [] }; } const errors = []; for (const provider of activeProviders) { debug(`Trying ${provider.name}`); const result = await runProviderWithPolicy(provider, query, timeoutSec, fingerprint); if (result.success) { traceWebSearchEvent('websearch_provider_success', { source: 'provider', providerId: provider.id, providerName: provider.name, ...fingerprint, }); return { success: true, providerId: provider.id, providerName: provider.name, content: result.content, }; } traceWebSearchEvent('websearch_provider_failure', { source: 'provider', providerId: provider.id, providerName: provider.name, error: result.error, ...fingerprint, }); errors.push({ provider: provider.name, error: result.error }); } traceWebSearchEvent('websearch_provider_run_failed', { source: 'provider', errorCount: errors.length, activeProviderIds: activeProviders.map((provider) => provider.id), ...fingerprint, }); return { success: false, noActiveProviders: false, errors }; } async function processHook(input) { try { if (shouldSkipHook()) { traceWebSearchEvent('websearch_hook_skipped', { source: 'hook', reason: getSkipReason(), }); process.exit(0); } const data = JSON.parse(input); if (data.tool_name !== 'WebSearch') { process.exit(0); } const query = data.tool_input?.query || ''; if (!query) { process.exit(0); } traceWebSearchEvent('websearch_hook_invoked', { source: 'hook', ...getQueryFingerprint(query), }); const timeout = Number.parseInt( process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`, 10 ); const result = await runLocalWebSearch(query, timeout); if (result.noActiveProviders) { traceWebSearchEvent('websearch_hook_no_active_providers', { source: 'hook', ...getQueryFingerprint(query), }); process.exit(0); } if (result.success) { traceWebSearchEvent('websearch_hook_success', { source: 'hook', providerId: result.providerId, providerName: result.providerName, ...getQueryFingerprint(query), }); outputSuccess(query, result.content, result.providerName); return; } traceWebSearchEvent('websearch_hook_failure', { source: 'hook', errorCount: result.errors.length, ...getQueryFingerprint(query), }); outputAllFailedMessage(query, result.errors); } catch (error) { debug(`Hook error: ${error.message}`); traceWebSearchEvent('websearch_hook_error', { source: 'hook', error: error.message, }); process.exit(0); } } function startFromStdin() { let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { processHook(input); }); process.stdin.on('error', () => { process.exit(0); }); } if (require.main === module) { startFromStdin(); } module.exports = { buildFailureHookOutput, buildSuccessHookOutput, classifyDuckDuckGoHtml, extractDuckDuckGoResults, formatStructuredSearchResults, getActiveProviders, hasAnyActiveProviders, runLocalWebSearch, shouldSkipHook, getActiveProviderIds, classifyProviderFailure, getQueryFingerprint, getSkipReason, parseRetryAfterSeconds, traceWebSearchEvent, tryExaSearch, tryTavilySearch, tryDuckDuckGoSearch, tryBraveSearch, trySearxngSearch, };