mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 00:17:09 +00:00
feat: add first-class CCS WebSearch runtime
This commit is contained in:
1 parent
c45b2a1138
commit
e260df7178
26 files changed
+1241
-158
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user