feat: add first-class CCS WebSearch runtime

This commit is contained in:
Tam Nhu Tran committed 2026-03-30 16:10:39 -04:00
1 parent c45b2a1138
commit e260df7178
26 files changed
+1241 -158

No files matched your search

+94 -61
View File
@@ -576,6 +576,90 @@ 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: '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 getActiveProviders() {
return getConfiguredProviders().filter((provider) => provider.available());
}
function hasAnyActiveProviders() {
return getActiveProviders().length > 0;
}
async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
const activeProviders = getActiveProviders();
debug(
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
);
if (activeProviders.length === 0) {
return { success: false, noActiveProviders: true, errors: [] };
}
const errors = [];
for (const provider of activeProviders) {
debug(`Trying ${provider.name}`);
const result = await provider.fn(query, timeoutSec);
if (result.success) {
return {
success: true,
providerId: provider.id,
providerName: provider.name,
content: result.content,
};
}
errors.push({ provider: provider.name, error: result.error });
}
return { success: false, noActiveProviders: false, errors };
}
async function processHook(input) {
try {
if (shouldSkipHook()) {
@@ -596,72 +680,17 @@ async function processHook(input) {
process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
10
);
const providers = [
{
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: '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,
},
];
const activeProviders = providers.filter((provider) => provider.available());
debug(
`Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}`
);
if (activeProviders.length === 0) {
const result = await runLocalWebSearch(query, timeout);
if (result.noActiveProviders) {
process.exit(0);
}
const errors = [];
for (const provider of activeProviders) {
debug(`Trying ${provider.name}`);
const result = await provider.fn(query, timeout);
if (result.success) {
outputSuccess(query, result.content, provider.name);
return;
}
errors.push({ provider: provider.name, error: result.error });
if (result.success) {
outputSuccess(query, result.content, result.providerName);
return;
}
outputAllFailedMessage(query, errors);
outputAllFailedMessage(query, result.errors);
} catch (error) {
debug(`Hook error: ${error.message}`);
process.exit(0);
@@ -691,6 +720,10 @@ module.exports = {
buildSuccessHookOutput,
extractDuckDuckGoResults,
formatStructuredSearchResults,
getActiveProviders,
hasAnyActiveProviders,
runLocalWebSearch,
shouldSkipHook,
tryExaSearch,
tryTavilySearch,
tryDuckDuckGoSearch,
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env node
const {
hasAnyActiveProviders,
runLocalWebSearch,
shouldSkipHook,
} = require('../hooks/websearch-transformer.cjs');
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_NAME = 'ccs-websearch';
const SERVER_VERSION = '1.0.0';
const TOOL_NAME = 'search';
const TOOL_DESCRIPTION =
'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.';
let inputBuffer = Buffer.alloc(0);
function shouldExposeTools() {
return !shouldSkipHook() && hasAnyActiveProviders();
}
function getTools() {
if (!shouldExposeTools()) {
return [];
}
return [
{
name: TOOL_NAME,
description: TOOL_DESCRIPTION,
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to run against CCS WebSearch providers.',
},
},
required: ['query'],
additionalProperties: false,
},
},
];
}
function writeMessage(message) {
const body = Buffer.from(JSON.stringify(message), 'utf8');
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
process.stdout.write(body);
}
function writeResponse(id, result) {
writeMessage({
jsonrpc: '2.0',
id,
result,
});
}
function writeError(id, code, message) {
writeMessage({
jsonrpc: '2.0',
id,
error: {
code,
message,
},
});
}
async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
const toolArgs = params.arguments || {};
if (params.name !== TOOL_NAME) {
writeError(id, -32602, `Unknown tool: ${params.name || '<missing>'}`);
return;
}
if (!shouldExposeTools()) {
writeResponse(id, {
content: [
{
type: 'text',
text: 'CCS WebSearch is unavailable for this profile or no providers are ready.',
},
],
isError: true,
});
return;
}
const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : '';
if (!query) {
writeError(id, -32602, 'Tool "search" requires a non-empty string query.');
return;
}
const result = await runLocalWebSearch(query);
if (result.success) {
writeResponse(id, {
content: [{ type: 'text', text: result.content }],
});
return;
}
const errorDetail =
result.noActiveProviders || result.errors.length === 0
? 'No active WebSearch providers are ready.'
: result.errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
writeResponse(id, {
content: [
{
type: 'text',
text: `CCS local WebSearch failed for "${query}". ${errorDetail}`,
},
],
isError: true,
});
}
async function handleMessage(message) {
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
return;
}
switch (message.method) {
case 'initialize':
writeResponse(message.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: {},
},
serverInfo: {
name: SERVER_NAME,
version: SERVER_VERSION,
},
});
return;
case 'notifications/initialized':
return;
case 'ping':
writeResponse(message.id, {});
return;
case 'tools/list':
writeResponse(message.id, { tools: getTools() });
return;
case 'tools/call':
await handleToolCall(message);
return;
default:
if (message.id !== undefined) {
writeError(message.id, -32601, `Method not found: ${message.method}`);
}
}
}
function parseMessages() {
while (true) {
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
if (!contentLengthMatch) {
inputBuffer = Buffer.alloc(0);
return;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageEnd = headerEnd + 4 + contentLength;
if (inputBuffer.length < messageEnd) {
return;
}
const body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
inputBuffer = inputBuffer.slice(messageEnd);
let message;
try {
message = JSON.parse(body);
} catch {
continue;
}
Promise.resolve(handleMessage(message)).catch((error) => {
if (message && message.id !== undefined) {
writeError(message.id, -32603, (error && error.message) || 'Internal error');
}
});
}
}
process.stdin.on('data', (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, chunk]);
parseMessages();
});
process.stdin.on('error', () => {
process.exit(0);
});
process.stdin.resume();