mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
441 lines
12 KiB
JavaScript
441 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function loadRuntimeModule() {
|
|
const candidates = [
|
|
path.join(__dirname, 'image-analysis-runtime.cjs'),
|
|
path.join(__dirname, '../hooks/image-analysis-runtime.cjs'),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate)) {
|
|
return require(candidate);
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`ccs-image-analysis runtime not found. Checked: ${candidates.map((candidate) => path.basename(candidate)).join(', ')}`
|
|
);
|
|
}
|
|
|
|
const { analyzeFile, isAnalyzableFile } = loadRuntimeModule();
|
|
|
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
const SERVER_NAME = 'ccs-image-analysis';
|
|
const SERVER_VERSION = '1.0.0';
|
|
const TOOL_NAME = 'ImageAnalysis';
|
|
const TOOL_ALIASES = ['AnalyzeImage', 'ReadImage'];
|
|
const TEMPLATE_NAMES = ['default', 'screenshot', 'document'];
|
|
const TOOL_DESCRIPTION =
|
|
'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.';
|
|
|
|
function isSupportedToolName(name) {
|
|
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
|
|
}
|
|
|
|
function shouldExposeTools() {
|
|
return (
|
|
process.env.CCS_IMAGE_ANALYSIS_ENABLED === '1' &&
|
|
process.env.CCS_IMAGE_ANALYSIS_SKIP !== '1' &&
|
|
Boolean(process.env.CCS_CURRENT_PROVIDER || process.env.CCS_IMAGE_ANALYSIS_MODEL)
|
|
);
|
|
}
|
|
|
|
function getTools() {
|
|
if (!shouldExposeTools()) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
name: TOOL_NAME,
|
|
description: TOOL_DESCRIPTION,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
filePath: {
|
|
type: 'string',
|
|
description:
|
|
'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.',
|
|
},
|
|
focus: {
|
|
type: 'string',
|
|
description:
|
|
'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".',
|
|
},
|
|
template: {
|
|
type: 'string',
|
|
enum: TEMPLATE_NAMES,
|
|
description:
|
|
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
|
|
},
|
|
},
|
|
required: ['filePath'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function writeMessage(message) {
|
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
}
|
|
|
|
function writeResponse(id, result) {
|
|
writeMessage({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result,
|
|
});
|
|
}
|
|
|
|
function writeError(id, code, message) {
|
|
writeMessage({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code,
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
|
|
function formatResult(filePath, result, focus) {
|
|
const lines = [
|
|
'[Image Analysis via CCS]',
|
|
'',
|
|
`File: ${path.basename(filePath)} (${(result.fileSize / 1024).toFixed(1)} KB)`,
|
|
`Model: ${result.model}`,
|
|
`Template: ${result.template}`,
|
|
];
|
|
|
|
if (focus && focus.trim()) {
|
|
lines.push(`Focus: ${focus.trim()}`);
|
|
}
|
|
|
|
lines.push('', '---', '', result.description);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function normalizeTemplate(value) {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = value.trim().toLowerCase();
|
|
return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined;
|
|
}
|
|
|
|
function normalizePathForComparison(value) {
|
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
}
|
|
|
|
function isPathWithinWorkspace(workspaceRoot, candidatePath) {
|
|
const relativePath = path.relative(workspaceRoot, candidatePath);
|
|
return (
|
|
relativePath === '' ||
|
|
(!relativePath.startsWith('..') &&
|
|
!relativePath.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relativePath))
|
|
);
|
|
}
|
|
|
|
function resolveFilePath(toolArgs) {
|
|
if (!toolArgs || typeof toolArgs !== 'object') {
|
|
return '';
|
|
}
|
|
|
|
const candidates = [toolArgs.filePath, toolArgs.file_path, toolArgs.path];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
return candidate.trim();
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function resolveWorkspaceFilePath(toolArgs) {
|
|
const requestedPath = resolveFilePath(toolArgs);
|
|
if (!requestedPath) {
|
|
return { filePath: '', error: null };
|
|
}
|
|
|
|
const workspaceRoot = (() => {
|
|
try {
|
|
return fs.realpathSync(process.cwd());
|
|
} catch {
|
|
return path.resolve(process.cwd());
|
|
}
|
|
})();
|
|
const absolutePath = path.resolve(process.cwd(), requestedPath);
|
|
const comparisonPath = (() => {
|
|
if (fs.existsSync(absolutePath)) {
|
|
return fs.realpathSync(absolutePath);
|
|
}
|
|
|
|
const suffixSegments = [];
|
|
let currentPath = absolutePath;
|
|
while (!fs.existsSync(currentPath)) {
|
|
const parentPath = path.dirname(currentPath);
|
|
if (parentPath === currentPath) {
|
|
break;
|
|
}
|
|
suffixSegments.unshift(path.basename(currentPath));
|
|
currentPath = parentPath;
|
|
}
|
|
|
|
const resolvedExistingPath = fs.existsSync(currentPath)
|
|
? fs.realpathSync(currentPath)
|
|
: path.resolve(currentPath);
|
|
return path.join(resolvedExistingPath, ...suffixSegments);
|
|
})();
|
|
|
|
if (
|
|
!isPathWithinWorkspace(
|
|
normalizePathForComparison(workspaceRoot),
|
|
normalizePathForComparison(comparisonPath)
|
|
)
|
|
) {
|
|
return {
|
|
filePath: '',
|
|
error: 'ImageAnalysis only allows files inside the current workspace.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
filePath: absolutePath,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function resolveFocus(toolArgs) {
|
|
return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0
|
|
? toolArgs.focus.trim()
|
|
: undefined;
|
|
}
|
|
|
|
function formatErrorDetail(filePath, error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
if (message.startsWith('FILE_TOO_LARGE:')) {
|
|
const fileSizeBytes = Number.parseInt(message.split(':')[1], 10);
|
|
const sizeMb = Number.isFinite(fileSizeBytes) ? (fileSizeBytes / 1024 / 1024).toFixed(1) : '?';
|
|
return `ImageAnalysis cannot process ${path.basename(filePath)} because it is too large (${sizeMb} MB). The limit is 10 MB.`;
|
|
}
|
|
|
|
if (message.startsWith('AUTH_ERROR:')) {
|
|
return `ImageAnalysis failed because CCS vision auth for this provider is unavailable (${message.split(':')[1]}).`;
|
|
}
|
|
|
|
if (message.startsWith('RATE_LIMIT:')) {
|
|
return `ImageAnalysis hit a provider rate limit while analyzing ${path.basename(filePath)}.`;
|
|
}
|
|
|
|
if (message.startsWith('API_ERROR:')) {
|
|
return `ImageAnalysis failed at the CCS provider route while analyzing ${path.basename(filePath)}.`;
|
|
}
|
|
|
|
if (
|
|
message === 'TIMEOUT' ||
|
|
message.includes('timed out') ||
|
|
message.includes('timeout') ||
|
|
message.includes('ECONNREFUSED') ||
|
|
message.includes('ENOTFOUND') ||
|
|
message.includes('ENETUNREACH') ||
|
|
message.includes('EAI_AGAIN')
|
|
) {
|
|
return `ImageAnalysis could not reach the configured CCS provider route for ${path.basename(filePath)}.`;
|
|
}
|
|
|
|
if (message.includes('EACCES') || message.includes('EPERM')) {
|
|
return `ImageAnalysis could not read ${filePath} because access was denied.`;
|
|
}
|
|
|
|
return `ImageAnalysis failed for ${path.basename(filePath)}: ${message}`;
|
|
}
|
|
|
|
async function handleToolCall(message) {
|
|
const id = message.id;
|
|
const params = message.params || {};
|
|
const toolArgs = params.arguments || {};
|
|
const toolName = params.name || '<missing>';
|
|
|
|
if (!isSupportedToolName(toolName)) {
|
|
writeError(id, -32602, `Unknown tool: ${toolName}`);
|
|
return;
|
|
}
|
|
|
|
if (!shouldExposeTools()) {
|
|
writeResponse(id, {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'CCS ImageAnalysis is unavailable for this profile or no provider-backed vision route is ready.',
|
|
},
|
|
],
|
|
isError: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs);
|
|
if (!filePath) {
|
|
writeError(
|
|
id,
|
|
-32602,
|
|
filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
writeResponse(id, {
|
|
content: [{ type: 'text', text: `ImageAnalysis could not find file: ${filePath}` }],
|
|
isError: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!isAnalyzableFile(filePath)) {
|
|
writeResponse(id, {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `ImageAnalysis only supports image and PDF files. Use Read for ${path.basename(filePath)} instead.`,
|
|
},
|
|
],
|
|
isError: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const focus = resolveFocus(toolArgs);
|
|
const template = normalizeTemplate(toolArgs.template);
|
|
|
|
try {
|
|
const result = await analyzeFile(filePath, { focus, template });
|
|
writeResponse(id, {
|
|
content: [{ type: 'text', text: formatResult(filePath, result, focus) }],
|
|
});
|
|
} catch (error) {
|
|
writeResponse(id, {
|
|
content: [{ type: 'text', text: formatErrorDetail(filePath, error) }],
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
let inputBuffer = Buffer.alloc(0);
|
|
|
|
function processIncomingBuffer() {
|
|
while (true) {
|
|
let body;
|
|
const startsWithLegacyHeaders = inputBuffer
|
|
.slice(0, Math.min(inputBuffer.length, 32))
|
|
.toString('utf8')
|
|
.toLowerCase()
|
|
.startsWith('content-length:');
|
|
|
|
if (startsWithLegacyHeaders) {
|
|
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;
|
|
}
|
|
|
|
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
|
inputBuffer = inputBuffer.slice(messageEnd);
|
|
} else {
|
|
const newlineIndex = inputBuffer.indexOf('\n');
|
|
if (newlineIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
|
inputBuffer = inputBuffer.slice(newlineIndex + 1);
|
|
if (!body) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const message = JSON.parse(body);
|
|
Promise.resolve(handleMessage(message)).catch((error) => {
|
|
if (message && message.id !== undefined) {
|
|
writeError(message.id, -32603, (error && error.message) || 'Internal error');
|
|
} else if (process.env.CCS_DEBUG) {
|
|
console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error(
|
|
`[ccs-image-analysis] Failed to parse JSON-RPC message: ${
|
|
error instanceof Error ? error.message : error
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
process.stdin.on('data', (chunk) => {
|
|
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
|
processIncomingBuffer();
|
|
});
|
|
|
|
process.stdin.on('end', () => {
|
|
process.exit(0);
|
|
});
|