feat(image-analysis): add provider-backed runtime

This commit is contained in:
Tam Nhu Tran committed 2026-04-02 15:44:13 -04:00
1 parent 2c006afbb9
commit 813b2dd4d0
24 files changed
+1715 -487

No files matched your search

+458
View File
@@ -0,0 +1,458 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
const PDF_EXTENSIONS = ['.pdf'];
const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const SCREENSHOT_NAME_REGEX =
/(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i;
const TEMPLATE_FILE_NAMES = {
default: 'default.txt',
screenshot: 'screenshot.txt',
document: 'document.txt',
};
const FALLBACK_PROMPTS = {
default: `Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text verbatim
3. Visual elements (diagrams, charts, UI components, icons)
4. Layout and structure (sections, hierarchy, flow)
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code snippets)
Be comprehensive - this description replaces direct visual access.
The AI assistant reading this cannot see the original image.`,
screenshot: `Analyze this screenshot in detail for a developer who cannot see it.
Focus on:
1. Application/website type and state
2. UI elements visible (buttons, inputs, menus, modals)
3. All text content - transcribe exactly
4. Error messages or notifications (quote exactly)
5. Layout and component hierarchy
6. Interactive elements and their states
7. Console output or logs if visible
8. Any code snippets shown
Be precise - this enables the assistant to help debug or understand the UI.`,
document: `Analyze this document/PDF thoroughly for a developer.
Extract and provide:
1. Document title, type, and structure
2. All text content - transcribe in reading order
3. Tables - format as markdown tables
4. Lists and bullet points - preserve structure
5. Code blocks or technical content
6. Diagrams or flowcharts - describe in detail
7. Headers and section organization
8. Any important metadata visible
Accuracy in text extraction is critical.`,
};
function debugLog(message, data = {}) {
if (!process.env.CCS_DEBUG) return;
const lines = [`[CCS Hook] ${message}`];
for (const [key, value] of Object.entries(data)) {
if (value !== undefined && value !== null && value !== '') {
lines.push(` ${key}: ${value}`);
}
}
console.error(lines.join('\n'));
}
function parseProviderModels(envValue) {
if (!envValue) return {};
return envValue.split(',').reduce((acc, pair) => {
const [provider, ...modelParts] = pair.split(':');
const model = modelParts.join(':').trim();
if (provider && model) {
acc[provider.trim()] = model;
}
return acc;
}, {});
}
function normalizeTemplateName(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(TEMPLATE_FILE_NAMES, normalized) ? normalized : null;
}
function selectPromptTemplate(filePath, requestedTemplate) {
const explicitTemplate = normalizeTemplateName(requestedTemplate);
if (explicitTemplate) {
return explicitTemplate;
}
const extension = path.extname(filePath).toLowerCase();
if (PDF_EXTENSIONS.includes(extension)) {
return 'document';
}
return SCREENSHOT_NAME_REGEX.test(path.basename(filePath)) ? 'screenshot' : 'default';
}
function readPromptFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8').trim();
return content.length > 0 ? content : null;
} catch {
return null;
}
}
function loadPromptTemplate(filePath, requestedTemplate, focus) {
const template = selectPromptTemplate(filePath, requestedTemplate);
const promptsDir = process.env.CCS_IMAGE_ANALYSIS_PROMPTS_DIR || '';
const promptPath = promptsDir
? path.join(promptsDir, TEMPLATE_FILE_NAMES[template])
: null;
const promptText = (promptPath && readPromptFile(promptPath)) || FALLBACK_PROMPTS[template];
if (!focus || !focus.trim()) {
return {
template,
promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback',
prompt: promptText,
};
}
return {
template,
promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback',
prompt: `${promptText}\n\nSpecific focus:\n${focus.trim()}`,
};
}
function getCurrentProvider() {
return process.env.CCS_CURRENT_PROVIDER || '';
}
function getConfiguredModel() {
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
if (explicitModel && explicitModel.trim()) {
return explicitModel.trim();
}
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
return providerModels[getCurrentProvider()] || DEFAULT_MODEL;
}
function getModelsToTry() {
const models = [];
const seen = new Set();
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
if (explicitModel && explicitModel.trim()) {
models.push(explicitModel.trim());
seen.add(explicitModel.trim());
}
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const providerModel = providerModels[getCurrentProvider()];
if (providerModel && !seen.has(providerModel)) {
models.push(providerModel);
seen.add(providerModel);
}
if (models.length === 0) {
models.push(DEFAULT_MODEL);
}
return models;
}
function getRuntimeBaseUrl() {
const runtimePath = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH || '')
.trim()
.replace(/\/+$/, '');
const explicitBaseUrl = process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL;
if (explicitBaseUrl && explicitBaseUrl.trim()) {
const normalizedBaseUrl = explicitBaseUrl.trim().replace(/\/+$/, '');
if (!runtimePath) {
return normalizedBaseUrl;
}
try {
const parsed = new URL(normalizedBaseUrl);
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
if (normalizedPath === runtimePath) {
return normalizedBaseUrl;
}
if (normalizedPath.length > 0 && normalizedPath !== '/') {
return normalizedBaseUrl;
}
parsed.pathname = runtimePath;
return parsed.toString().replace(/\/+$/, '');
} catch {
return `${normalizedBaseUrl}${runtimePath}`;
}
}
const port = Number.parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
return `http://127.0.0.1:${port}${runtimePath}`;
}
function getRuntimeEndpoint() {
return `${getRuntimeBaseUrl()}/v1/messages`;
}
function getApiKey() {
if (Object.prototype.hasOwnProperty.call(process.env, 'CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY')) {
const explicitApiKey = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY || '').trim();
return explicitApiKey || 'ccs-internal-managed';
}
return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed';
}
function getTimeoutMs(timeoutMs) {
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
return timeoutMs;
}
const timeoutSec = Number.parseInt(
process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
10
);
return Math.max(1, Math.min(600, timeoutSec)) * 1000;
}
function isAnalyzableFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
}
function getMediaType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return (
{
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
}[ext] || 'application/octet-stream'
);
}
function encodeFileToBase64(filePath) {
return fs.readFileSync(filePath).toString('base64');
}
function buildContentBlock(base64Data, mediaType) {
const source = {
type: 'base64',
media_type: mediaType,
data: base64Data,
};
if (mediaType === 'application/pdf') {
return {
type: 'document',
source,
};
}
return {
type: 'image',
source,
};
}
function extractTextContent(response) {
if (!response || !Array.isArray(response.content)) {
return null;
}
const textBlocks = response.content
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.filter((text) => text.trim());
return textBlocks.length > 0 ? textBlocks.join('\n\n') : null;
}
function parseCliProxyResponse(data) {
const response = JSON.parse(data);
const text = extractTextContent(response);
if (!text) {
throw new Error('No text content in response');
}
return text;
}
function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
return new Promise((resolve, reject) => {
const endpoint = new URL(getRuntimeEndpoint());
const transport = endpoint.protocol === 'https:' ? https : http;
const requestBody = JSON.stringify({
model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
buildContentBlock(base64Data, mediaType),
],
},
],
});
const req = transport.request(
{
protocol: endpoint.protocol,
hostname: endpoint.hostname,
port: endpoint.port,
path: `${endpoint.pathname}${endpoint.search}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': getApiKey(),
},
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 401 || res.statusCode === 403) {
reject(new Error(`AUTH_ERROR:${res.statusCode}`));
return;
}
if (res.statusCode === 429) {
reject(new Error(`RATE_LIMIT:${res.headers['retry-after'] || ''}`));
return;
}
if (res.statusCode !== 200) {
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
return;
}
try {
resolve(parseCliProxyResponse(data));
} catch (error) {
reject(error);
}
});
}
);
req.on('error', (error) => reject(error));
req.on('timeout', () => {
req.destroy();
reject(new Error('TIMEOUT'));
});
req.write(requestBody);
req.end();
});
}
async function analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs) {
const models = getModelsToTry();
let lastError = null;
for (const [index, model] of models.entries()) {
try {
debugLog(`Trying model ${index + 1}/${models.length}`, { model });
const description = await analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs);
return { description, model };
} catch (error) {
lastError = error;
const message = error.message || '';
if (
index === models.length - 1 ||
['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED'].some((token) =>
message.includes(token)
)
) {
throw error;
}
}
}
throw lastError || new Error('No models configured for image analysis');
}
async function analyzeFile(filePath, options = {}) {
const stats = fs.statSync(filePath);
if (stats.size >= MAX_FILE_SIZE_BYTES) {
throw new Error(`FILE_TOO_LARGE:${stats.size}`);
}
const timeoutMs = getTimeoutMs(options.timeoutMs);
const { template, prompt, promptSource } = loadPromptTemplate(
filePath,
options.template,
options.focus
);
const model = getConfiguredModel();
debugLog('Starting image analysis', {
file: path.basename(filePath),
size: `${(stats.size / 1024).toFixed(1)} KB`,
provider: getCurrentProvider() || 'unknown',
model,
modelsToTry: getModelsToTry().join(' -> '),
timeout: `${timeoutMs / 1000}s`,
endpoint: getRuntimeEndpoint(),
template,
promptSource,
});
const base64Data = encodeFileToBase64(filePath);
const mediaType = getMediaType(filePath);
debugLog('File encoded', {
mediaType,
base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`,
});
const result = await analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs);
debugLog('Analysis complete', {
responseLength: `${result.description.length} chars`,
model: result.model,
template,
});
return {
description: result.description,
model: result.model,
fileSize: stats.size,
mediaType,
template,
};
}
module.exports = {
DEFAULT_MODEL,
DEFAULT_TIMEOUT_SEC,
MAX_FILE_SIZE_BYTES,
analyzeFile,
getRuntimeEndpoint,
isAnalyzableFile,
parseProviderModels,
selectPromptTemplate,
};
+27 -418
View File
@@ -24,7 +24,7 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs');
// ============================================================================
// PLATFORM DETECTION
@@ -36,19 +36,8 @@ const isWindows = process.platform === 'win32';
// CONFIGURATION
// ============================================================================
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
const PDF_EXTENSIONS = ['.pdf'];
const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const CLIPROXY_HOST = '127.0.0.1';
const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
const CLIPROXY_PATH = '/v1/messages';
// API key passed via env from cliproxy-executor, defaults to CCS internal key
const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed';
// ============================================================================
// ERROR CODES (for categorization)
@@ -65,23 +54,6 @@ const ERROR_CODES = {
UNKNOWN: 'UNKNOWN',
};
// Default analysis prompt
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text
3. Visual elements (diagrams, charts, UI components)
4. Layout and structure
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code)
Be comprehensive - this description replaces direct visual access.`;
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Output debug information to stderr
* Only outputs when CCS_DEBUG=1
@@ -105,19 +77,19 @@ function debugLog(message, data = {}) {
*/
function getDebugContext(filePath, stats) {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const model = providerModels[currentProvider] || DEFAULT_MODEL;
const model =
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[currentProvider] ||
DEFAULT_MODEL;
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const modelsToTry = getModelsToTry();
return {
file: path.basename(filePath),
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
provider: currentProvider,
model: model,
modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model,
timeout: `${timeout}s`,
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`,
endpoint: process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)',
};
}
@@ -126,319 +98,12 @@ function getDebugContext(filePath, stats) {
*/
function getProviderContext() {
const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const model = providerModels[provider] || DEFAULT_MODEL;
const model =
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[provider] ||
DEFAULT_MODEL;
return { provider, model };
}
/**
* Parse provider_models env var to object
* Format: provider:model,provider:model
*/
function parseProviderModels(envValue) {
if (!envValue) return {};
const result = {};
envValue.split(',').forEach((pair) => {
const [provider, model] = pair.split(':');
if (provider && model && model.trim()) {
result[provider.trim()] = model.trim();
}
});
return result;
}
/**
* Extract concatenated text content from CLIProxy response blocks.
* Skips thinking and other non-text blocks.
*/
function extractTextContent(response) {
if (!response || !Array.isArray(response.content)) {
return null;
}
const textBlocks = response.content
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.filter((text) => text.trim());
if (textBlocks.length === 0) {
return null;
}
return textBlocks.join('\n\n');
}
/**
* Parse raw CLIProxy response body and extract text content.
*/
function parseCliProxyResponse(data) {
let response;
try {
response = JSON.parse(data);
} catch (err) {
throw new Error(`Failed to parse response: ${err.message}`);
}
const text = extractTextContent(response);
if (!text) {
throw new Error('No text content in response');
}
return text;
}
/**
* Get model for current provider from provider_models mapping
* Returns primary model only (for display/logging)
*/
function getModelForProvider() {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
return providerModels[currentProvider] || DEFAULT_MODEL;
}
/**
* Get list of models to try in order:
* 1. provider_models[current_provider] (if exists)
* 2. DEFAULT_MODEL when no provider-specific vision model is configured
*
* ANTHROPIC_MODEL is intentionally ignored here because the chat model may
* not be vision-capable on the current provider route.
*/
function getModelsToTry() {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const models = [];
const seen = new Set();
// 1. Provider-specific model
if (providerModels[currentProvider]) {
models.push(providerModels[currentProvider]);
seen.add(providerModels[currentProvider]);
}
// 2. Default model — only when no provider-specific model is configured,
// since DEFAULT_MODEL (gemini-2.5-flash) may not be routable on the
// current provider's CLIProxy endpoint (e.g. codex, claude)
if (models.length === 0 && !seen.has(DEFAULT_MODEL)) {
models.push(DEFAULT_MODEL);
seen.add(DEFAULT_MODEL);
}
return models;
}
/**
* Analyze with retry logic - tries models in order until one succeeds
*/
async function analyzeWithRetry(base64Data, mediaType, timeoutMs) {
const models = getModelsToTry();
// Defensive check - should never happen but provides clear error
if (models.length === 0) {
throw new Error('No models configured for image analysis');
}
let lastError = null;
for (let i = 0; i < models.length; i++) {
const model = models[i];
try {
debugLog(`Trying model ${i + 1}/${models.length}`, { model });
const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
if (i > 0) {
debugLog('Retry succeeded', { model, attempt: i + 1 });
}
return { description: result, model };
} catch (err) {
lastError = err;
const isLastModel = i === models.length - 1;
// Don't retry on certain errors (auth, rate limit, timeout, file access, network)
const errMsg = err.message || '';
const noRetryPatterns = [
'AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT',
'EACCES', 'EPERM', 'ECONNREFUSED',
'ENOTFOUND', 'ENETUNREACH', 'EAI_AGAIN' // Network errors - no point retrying
];
const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p));
if (shouldNotRetry || isLastModel) {
debugLog('Analysis failed, no more retries', {
model,
error: errMsg,
reason: shouldNotRetry ? 'non-retryable error' : 'last model'
});
throw err;
}
debugLog('Model failed, trying next', {
model,
error: errMsg.substring(0, 100),
nextModel: models[i + 1]
});
}
}
throw lastError || new Error('No models available');
}
/**
* Check if file is an analyzable image or PDF
*/
function isAnalyzableFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
}
/**
* Get MIME type from file extension
*/
function getMediaType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* Encode file to base64
*/
function encodeFileToBase64(filePath) {
const content = fs.readFileSync(filePath);
return content.toString('base64');
}
/**
* Check if CLIProxy is available
*/
function isCliProxyAvailable() {
return new Promise((resolve) => {
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: '/',
method: 'GET',
timeout: 2000,
},
(res) => {
resolve(res.statusCode >= 200 && res.statusCode < 500);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* Analyze file via CLIProxy vision API
*/
function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
return new Promise((resolve, reject) => {
const requestBody = JSON.stringify({
model: model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: DEFAULT_PROMPT },
{
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: base64Data,
},
},
],
},
],
});
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: CLIPROXY_PATH,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': CLIPROXY_API_KEY,
},
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('error', (err) => {
reject(err);
});
res.on('end', () => {
// Categorize by status code
if (res.statusCode === 401 || res.statusCode === 403) {
reject(new Error(`AUTH_ERROR:${res.statusCode}`));
return;
}
if (res.statusCode === 429) {
const retryAfter = res.headers['retry-after'];
reject(new Error(`RATE_LIMIT:${retryAfter || ''}`));
return;
}
if (res.statusCode !== 200) {
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
return;
}
if (!data || !data.trim()) {
reject(new Error('Empty response from CLIProxy'));
return;
}
try {
const text = parseCliProxyResponse(data);
resolve(text);
} catch (err) {
reject(err);
}
});
}
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('TIMEOUT'));
});
req.write(requestBody);
req.end();
});
}
/**
* Format analysis description for Claude (matches websearch format)
*/
@@ -520,29 +185,6 @@ function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
process.exit(2);
}
/**
* CLIProxy unavailable error
*/
function outputCliProxyUnavailableError(filePath, endpoint) {
const output = formatErrorOutput(
filePath,
ERROR_CODES.CLIPROXY_UNAVAILABLE,
`CLIProxy not available at ${endpoint}`,
[
'CLIProxy service may not be running',
'Start with: ccs config (opens dashboard, starts CLIProxy)',
'Or manually: ccs cliproxy start',
`Verify: curl ${endpoint}`,
'Check status: ccs doctor',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Authentication error
*/
function outputAuthError(filePath, statusCode) {
const { provider } = getProviderContext();
const output = formatErrorOutput(
@@ -758,10 +400,11 @@ function shouldSkipHook() {
}
// Check if current provider has a vision model configured
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
if (!providerModels[currentProvider]) {
if (!explicitModel?.trim() && !providerModels[currentProvider]) {
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
configured_providers: Object.keys(providerModels).join(', ') || 'none',
});
@@ -832,57 +475,15 @@ async function processHook() {
process.exit(0);
}
// Check if file exists
if (!fs.existsSync(filePath)) {
// Let native Read handle the error
process.exit(0);
}
// Check file size
const stats = fs.statSync(filePath);
if (stats.size >= MAX_FILE_SIZE_BYTES) {
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
return;
}
const debugContext = getDebugContext(filePath, null);
debugLog('Image analysis runtime prepared', debugContext);
// Check CLIProxy availability
const cliProxyAvailable = await isCliProxyAvailable();
if (!cliProxyAvailable) {
debugLog('Blocking: CLIProxy not available', {
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`,
action: 'blocking to prevent context overflow',
});
outputCliProxyUnavailableFallback(filePath);
return;
}
const model = getModelForProvider();
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000;
// Get debug context before analysis
const debugContext = getDebugContext(filePath, stats);
debugLog('Starting image analysis', debugContext);
// Encode file to base64
const base64Data = encodeFileToBase64(filePath);
const mediaType = getMediaType(filePath);
debugLog('File encoded', {
mediaType: mediaType,
base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`,
});
// Analyze via CLIProxy with retry logic
const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs);
debugLog('Analysis complete', {
responseLength: `${description.length} chars`,
model: usedModel,
});
// Output success
outputSuccess(filePath, description, usedModel, stats.size);
const result = await analyzeFile(filePath);
outputSuccess(filePath, result.description, result.model, result.fileSize);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Error:', err.message);
@@ -893,7 +494,10 @@ async function processHook() {
// Categorize error by message pattern
const errMsg = err.message || '';
if (errMsg.startsWith('AUTH_ERROR:')) {
if (errMsg.startsWith('FILE_TOO_LARGE:')) {
const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024;
outputFileTooLargeError(filePath, fileSizeMb, 10);
} else if (errMsg.startsWith('AUTH_ERROR:')) {
const statusCode = parseInt(errMsg.split(':')[1], 10);
outputAuthError(filePath, statusCode);
} else if (errMsg.startsWith('RATE_LIMIT:')) {
@@ -907,8 +511,13 @@ async function processHook() {
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
outputTimeoutError(filePath, timeout);
} else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) {
outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`);
} else if (
errMsg.includes('ECONNREFUSED') ||
errMsg.includes('ENOTFOUND') ||
errMsg.includes('ENETUNREACH') ||
errMsg.includes('EAI_AGAIN')
) {
outputCliProxyUnavailableFallback(filePath);
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
outputFileAccessError(filePath, errMsg);
} else {
+337
View File
@@ -0,0 +1,337 @@
#!/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:
'Absolute or workspace-relative path 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 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 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 = resolveFilePath(toolArgs);
if (!filePath) {
writeError(id, -32602, `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) {
const newlineIndex = inputBuffer.indexOf(0x0a);
if (newlineIndex === -1) {
return;
}
const lineBuffer = inputBuffer.subarray(0, newlineIndex);
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
const line = lineBuffer.toString('utf8').replace(/\r$/, '').trim();
if (!line) {
continue;
}
try {
const message = JSON.parse(line);
Promise.resolve(handleMessage(message)).catch((error) => {
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);
});