refactor: eliminate code duplication with shared utilities

Created 5 new utility modules in apps/server/src/lib/ to eliminate ~320 lines of duplicated code:
- image-handler.ts: Centralized image processing (MIME types, base64, content blocks)
- prompt-builder.ts: Standardized prompt building with image attachments
- model-resolver.ts: Model alias resolution and provider routing
- conversation-utils.ts: Conversation history processing for providers
- error-handler.ts: Error classification and user-friendly messages

Updated services and providers to use shared utilities:
- agent-service.ts: -51 lines (removed duplicate image handling, model logic)
- auto-mode-service.ts: -75 lines (removed MODEL_MAP, duplicate utilities)
- claude-provider.ts: -10 lines (uses conversation-utils)
- codex-provider.ts: -5 lines (uses conversation-utils)

Added comprehensive documentation:
- docs/server/utilities.md: Complete reference for all 9 lib utilities
- docs/server/providers.md: Provider architecture guide with examples

Benefits:
- Single source of truth for critical business logic
- Improved maintainability and testability
- Consistent behavior across services and providers
- Better documentation for future development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-13 04:26:58 +01:00
parent 0519aba820
commit 7cbdb3db73
11 changed files with 2008 additions and 188 deletions

View File

@@ -0,0 +1,97 @@
/**
* Conversation history utilities for processing message history
*
* Provides standardized conversation history handling:
* - Extract text from content (string or array format)
* - Normalize content blocks to array format
* - Format history as plain text for CLI-based providers
* - Convert history to Claude SDK message format
*/
import type { ConversationMessage } from "../providers/types.js";
/**
* Extract plain text from message content (handles both string and array formats)
*
* @param content - Message content (string or array of content blocks)
* @returns Extracted text content
*/
export function extractTextFromContent(
content: string | Array<{ type: string; text?: string; source?: object }>
): string {
if (typeof content === "string") {
return content;
}
// Extract text blocks only
return content
.filter((block) => block.type === "text")
.map((block) => block.text || "")
.join("\n");
}
/**
* Normalize message content to array format
*
* @param content - Message content (string or array)
* @returns Content as array of blocks
*/
export function normalizeContentBlocks(
content: string | Array<{ type: string; text?: string; source?: object }>
): Array<{ type: string; text?: string; source?: object }> {
if (Array.isArray(content)) {
return content;
}
return [{ type: "text", text: content }];
}
/**
* Format conversation history as plain text for CLI-based providers
*
* @param history - Array of conversation messages
* @returns Formatted text with role labels
*/
export function formatHistoryAsText(history: ConversationMessage[]): string {
if (history.length === 0) {
return "";
}
let historyText = "Previous conversation:\n\n";
for (const msg of history) {
const contentText = extractTextFromContent(msg.content);
const role = msg.role === "user" ? "User" : "Assistant";
historyText += `${role}: ${contentText}\n\n`;
}
historyText += "---\n\n";
return historyText;
}
/**
* Convert conversation history to Claude SDK message format
*
* @param history - Array of conversation messages
* @returns Array of Claude SDK formatted messages
*/
export function convertHistoryToMessages(
history: ConversationMessage[]
): Array<{
type: "user" | "assistant";
session_id: string;
message: {
role: "user" | "assistant";
content: Array<{ type: string; text?: string; source?: object }>;
};
parent_tool_use_id: null;
}> {
return history.map((historyMsg) => ({
type: historyMsg.role,
session_id: "",
message: {
role: historyMsg.role,
content: normalizeContentBlocks(historyMsg.content),
},
parent_tool_use_id: null,
}));
}

View File

@@ -0,0 +1,104 @@
/**
* Error handling utilities for standardized error classification
*
* Provides utilities for:
* - Detecting abort/cancellation errors
* - Detecting authentication errors
* - Classifying errors by type
* - Generating user-friendly error messages
*/
/**
* Check if an error is an abort/cancellation error
*
* @param error - The error to check
* @returns True if the error is an abort error
*/
export function isAbortError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === "AbortError" || error.message.includes("abort"))
);
}
/**
* Check if an error is an authentication/API key error
*
* @param errorMessage - The error message to check
* @returns True if the error is authentication-related
*/
export function isAuthenticationError(errorMessage: string): boolean {
return (
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed") ||
errorMessage.includes("Fix external API key")
);
}
/**
* Error type classification
*/
export type ErrorType = "authentication" | "abort" | "execution" | "unknown";
/**
* Classified error information
*/
export interface ErrorInfo {
type: ErrorType;
message: string;
isAbort: boolean;
isAuth: boolean;
originalError: unknown;
}
/**
* Classify an error into a specific type
*
* @param error - The error to classify
* @returns Classified error information
*/
export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || "Unknown error");
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
let type: ErrorType;
if (isAuth) {
type = "authentication";
} else if (isAbort) {
type = "abort";
} else if (error instanceof Error) {
type = "execution";
} else {
type = "unknown";
}
return {
type,
message,
isAbort,
isAuth,
originalError: error,
};
}
/**
* Get a user-friendly error message
*
* @param error - The error to convert
* @returns User-friendly error message
*/
export function getUserFriendlyErrorMessage(error: unknown): string {
const info = classifyError(error);
if (info.isAbort) {
return "Operation was cancelled";
}
if (info.isAuth) {
return "Authentication failed. Please check your API key.";
}
return info.message;
}

View File

@@ -0,0 +1,135 @@
/**
* Image handling utilities for processing image files
*
* Provides utilities for:
* - MIME type detection based on file extensions
* - Base64 encoding of image files
* - Content block generation for Claude SDK format
* - Path resolution (relative/absolute)
*/
import fs from "fs/promises";
import path from "path";
/**
* MIME type mapping for image file extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
} as const;
/**
* Image data with base64 encoding and metadata
*/
export interface ImageData {
base64: string;
mimeType: string;
filename: string;
originalPath: string;
}
/**
* Content block for image (Claude SDK format)
*/
export interface ImageContentBlock {
type: "image";
source: {
type: "base64";
media_type: string;
data: string;
};
}
/**
* Get MIME type for an image file based on extension
*
* @param imagePath - Path to the image file
* @returns MIME type string (defaults to "image/png" for unknown extensions)
*/
export function getMimeTypeForImage(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || "image/png";
}
/**
* Read an image file and convert to base64 with metadata
*
* @param imagePath - Path to the image file
* @returns Promise resolving to image data with base64 encoding
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const mimeType = getMimeTypeForImage(imagePath);
return {
base64: base64Data,
mimeType,
filename: path.basename(imagePath),
originalPath: imagePath,
};
}
/**
* Convert image paths to content blocks (Claude SDK format)
* Handles both relative and absolute paths
*
* @param imagePaths - Array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @returns Promise resolving to array of image content blocks
*/
export async function convertImagesToContentBlocks(
imagePaths: string[],
workDir?: string
): Promise<ImageContentBlock[]> {
const blocks: ImageContentBlock[] = [];
for (const imagePath of imagePaths) {
try {
// Resolve to absolute path if needed
const absolutePath = workDir && !path.isAbsolute(imagePath)
? path.join(workDir, imagePath)
: imagePath;
const imageData = await readImageAsBase64(absolutePath);
blocks.push({
type: "image",
source: {
type: "base64",
media_type: imageData.mimeType,
data: imageData.base64,
},
});
} catch (error) {
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
// Continue processing other images
}
}
return blocks;
}
/**
* Build a list of image paths for text prompts
* Formats image paths as a bulleted list for inclusion in text prompts
*
* @param imagePaths - Array of image file paths
* @returns Formatted string with image paths, or empty string if no images
*/
export function formatImagePathsForPrompt(imagePaths: string[]): string {
if (imagePaths.length === 0) {
return "";
}
let text = "\n\nAttached images:\n";
for (const imagePath of imagePaths) {
text += `- ${imagePath}\n`;
}
return text;
}

View File

@@ -0,0 +1,88 @@
/**
* Model resolution utilities for handling model string mapping
*
* Provides centralized model resolution logic:
* - Maps Claude model aliases to full model strings
* - Detects and passes through OpenAI/Codex models
* - Provides default models per provider
* - Handles multiple model sources with priority
*/
/**
* Model alias mapping for Claude models
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
} as const;
/**
* Default models per provider
*/
export const DEFAULT_MODELS = {
claude: "claude-opus-4-5-20251101",
openai: "gpt-5.2",
} as const;
/**
* Resolve a model key/alias to a full model string
*
* @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514")
* @param defaultModel - Fallback model if modelKey is undefined
* @returns Full model string
*/
export function resolveModelString(
modelKey?: string,
defaultModel: string = DEFAULT_MODELS.claude
): string {
// No model specified - use default
if (!modelKey) {
return defaultModel;
}
// OpenAI/Codex models - pass through unchanged
if (modelKey.startsWith("gpt-") || modelKey.startsWith("o")) {
console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`);
return modelKey;
}
// Full Claude model string - pass through unchanged
if (modelKey.includes("claude-")) {
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
return modelKey;
}
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
return resolved;
}
// Unknown model key - use default
console.warn(
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
);
return defaultModel;
}
/**
* Get the effective model from multiple sources
* Priority: explicit model > session model > default
*
* @param explicitModel - Explicitly provided model (highest priority)
* @param sessionModel - Model from session (medium priority)
* @param defaultModel - Fallback default model (lowest priority)
* @returns Resolved model string
*/
export function getEffectiveModel(
explicitModel?: string,
sessionModel?: string,
defaultModel?: string
): string {
return resolveModelString(
explicitModel || sessionModel,
defaultModel
);
}

View File

@@ -0,0 +1,79 @@
/**
* Prompt building utilities for constructing prompts with images
*
* Provides standardized prompt building that:
* - Combines text prompts with image attachments
* - Handles content block array generation
* - Optionally includes image paths in text
* - Supports both vision and non-vision models
*/
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
/**
* Content that can be either simple text or structured blocks
*/
export type PromptContent = string | Array<{
type: string;
text?: string;
source?: object;
}>;
/**
* Result of building a prompt with optional images
*/
export interface PromptWithImages {
content: PromptContent;
hasImages: boolean;
}
/**
* Build a prompt with optional image attachments
*
* @param basePrompt - The text prompt
* @param imagePaths - Optional array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @param includeImagePaths - Whether to append image paths to the text (default: false)
* @returns Promise resolving to prompt content and metadata
*/
export async function buildPromptWithImages(
basePrompt: string,
imagePaths?: string[],
workDir?: string,
includeImagePaths: boolean = false
): Promise<PromptWithImages> {
// No images - return plain text
if (!imagePaths || imagePaths.length === 0) {
return { content: basePrompt, hasImages: false };
}
// Build text content with optional image path listing
let textContent = basePrompt;
if (includeImagePaths) {
textContent += formatImagePathsForPrompt(imagePaths);
}
// Build content blocks array
const contentBlocks: Array<{
type: string;
text?: string;
source?: object;
}> = [];
// Add text block if we have text
if (textContent.trim()) {
contentBlocks.push({ type: "text", text: textContent });
}
// Add image blocks
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
contentBlocks.push(...imageBlocks);
// Return appropriate format
const content: PromptContent =
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
? contentBlocks
: textContent;
return { content, hasImages: true };
}

View File

@@ -7,6 +7,7 @@
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { BaseProvider } from "./base-provider.js";
import { convertHistoryToMessages, normalizeContentBlocks } from "../lib/conversation-utils.js";
import type {
ExecuteOptions,
ProviderMessage,
@@ -64,19 +65,10 @@ export class ClaudeProvider extends BaseProvider {
if (conversationHistory && conversationHistory.length > 0) {
// Multi-turn conversation with history
promptPayload = (async function* () {
// Yield all previous messages first
for (const historyMsg of conversationHistory) {
yield {
type: historyMsg.role,
session_id: "",
message: {
role: historyMsg.role,
content: Array.isArray(historyMsg.content)
? historyMsg.content
: [{ type: "text", text: historyMsg.content }],
},
parent_tool_use_id: null,
};
// Yield history messages using utility
const historyMessages = convertHistoryToMessages(conversationHistory);
for (const msg of historyMessages) {
yield msg;
}
// Yield current prompt
@@ -85,9 +77,7 @@ export class ClaudeProvider extends BaseProvider {
session_id: "",
message: {
role: "user" as const,
content: Array.isArray(prompt)
? prompt
: [{ type: "text", text: prompt }],
content: normalizeContentBlocks(prompt),
},
parent_tool_use_id: null,
};

View File

@@ -9,6 +9,7 @@ import { BaseProvider } from "./base-provider.js";
import { CodexCliDetector } from "./codex-cli-detector.js";
import { codexConfigManager } from "./codex-config-manager.js";
import { spawnJSONLProcess } from "../lib/subprocess-manager.js";
import { formatHistoryAsText } from "../lib/conversation-utils.js";
import type {
ExecuteOptions,
ProviderMessage,
@@ -99,14 +100,8 @@ export class CodexProvider extends BaseProvider {
// Add conversation history
if (conversationHistory && conversationHistory.length > 0) {
let historyText = "Previous conversation:\n\n";
for (const msg of conversationHistory) {
const contentText = typeof msg.content === "string"
? msg.content
: msg.content.map(c => c.text || "").join("");
historyText += `${msg.role === "user" ? "User" : "Assistant"}: ${contentText}\n\n`;
}
combinedPrompt = `${historyText}---\n\nCurrent request:\n${combinedPrompt}`;
const historyText = formatHistoryAsText(conversationHistory);
combinedPrompt = `${historyText}Current request:\n${combinedPrompt}`;
}
// Build command arguments

View File

@@ -9,6 +9,12 @@ import fs from "fs/promises";
import type { EventEmitter } from "../lib/events.js";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import {
readImageAsBase64,
} from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { getEffectiveModel } from "../lib/model-resolver.js";
import { isAbortError } from "../lib/error-handler.js";
interface Message {
id: string;
@@ -123,22 +129,11 @@ export class AgentService {
if (imagePaths && imagePaths.length > 0) {
for (const imagePath of imagePaths) {
try {
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
const imageData = await readImageAsBase64(imagePath);
images.push({
data: base64Data,
mimeType: mediaType,
filename: path.basename(imagePath),
data: imageData.base64,
mimeType: imageData.mimeType,
filename: imageData.filename,
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
@@ -175,7 +170,7 @@ export class AgentService {
try {
// Use session model, parameter model, or default
const effectiveModel = model || session.model || "claude-opus-4-5-20251101";
const effectiveModel = getEffectiveModel(model, session.model);
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
@@ -205,59 +200,13 @@ export class AgentService {
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
};
// Build prompt content
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
message;
// Append image paths to prompt text (like old implementation)
if (imagePaths && imagePaths.length > 0) {
let enhancedMessage = message;
// Append image file paths to the message text
enhancedMessage += "\n\nAttached images:\n";
for (const imagePath of imagePaths) {
enhancedMessage += `- ${imagePath}\n`;
}
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
if (enhancedMessage && enhancedMessage.trim()) {
contentBlocks.push({ type: "text", text: enhancedMessage });
}
for (const imagePath of imagePaths) {
try {
const imageBuffer = await fs.readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
contentBlocks.push({
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Data,
},
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
promptContent = contentBlocks;
} else {
promptContent = enhancedMessage;
}
}
// Build prompt content with images
const { content: promptContent } = await buildPromptWithImages(
message,
imagePaths,
undefined, // no workDir for agent service
true // include image paths in text
);
// Set the prompt in options
options.prompt = promptContent;
@@ -335,7 +284,7 @@ export class AgentService {
message: currentAssistantMessage,
};
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
if (isAbortError(error)) {
session.isRunning = false;
session.abortController = null;
return { success: false, aborted: true };

View File

@@ -17,16 +17,12 @@ import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter, EventType } from "../lib/events.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
const execAsync = promisify(exec);
// Model name mappings for Claude (matching electron version)
const MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
};
interface Feature {
id: string;
title: string;
@@ -38,36 +34,6 @@ interface Feature {
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string; [key: string]: unknown }>;
}
/**
* Get model string from feature's model property
* Supports model keys like "opus", "sonnet", "haiku" or full model strings
* Also supports OpenAI/Codex models like "gpt-5.2", "gpt-5.1-codex", etc.
*/
function getModelString(feature: Feature): string {
const modelKey = feature.model || "opus"; // Default to opus
// Check if it's an OpenAI/Codex model (starts with "gpt-" or "o" for O-series)
if (modelKey.startsWith("gpt-") || modelKey.startsWith("o")) {
console.log(`[AutoMode] Using OpenAI/Codex model from feature ${feature.id}: ${modelKey} (passing through)`);
return modelKey;
}
// If it's already a full Claude model string (contains "claude-"), use it directly
if (modelKey.includes("claude-")) {
console.log(`[AutoMode] Using Claude model from feature ${feature.id}: ${modelKey} (full model string)`);
return modelKey;
}
// Otherwise, look it up in the Claude model map
const modelString = MODEL_MAP[modelKey] || MODEL_MAP.opus;
if (modelString !== MODEL_MAP.opus || modelKey === "opus") {
console.log(`[AutoMode] Resolved Claude model for feature ${feature.id}: "${modelKey}" -> "${modelString}"`);
} else {
console.warn(`[AutoMode] Unknown model key "${modelKey}" for feature ${feature.id}, defaulting to "${modelString}"`);
}
return modelString;
}
interface RunningFeature {
featureId: string;
projectPath: string;
@@ -246,7 +212,7 @@ export class AutoModeService {
);
// Get model from feature
const model = getModelString(feature);
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`);
// Run the agent with the feature's model and images
@@ -262,7 +228,9 @@ export class AutoModeService {
projectPath,
});
} catch (error) {
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
const errorInfo = classifyError(error);
if (errorInfo.isAbort) {
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
passes: false,
@@ -270,17 +238,12 @@ export class AutoModeService {
projectPath,
});
} else {
const errorMessage = (error as Error).message || "Unknown error";
const isAuthError = errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "backlog");
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: errorMessage,
errorType: isAuthError ? "authentication" : "execution",
error: errorInfo.message,
errorType: errorInfo.isAuth ? "authentication" : "execution",
projectPath,
});
}
@@ -382,7 +345,7 @@ export class AutoModeService {
try {
// Load feature to get its model
const feature = await this.loadFeature(projectPath, featureId);
const model = feature ? getModelString(feature) : MODEL_MAP.opus;
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
// Update feature status to in_progress
@@ -513,7 +476,7 @@ Please continue from where you left off and address the new instructions above.`
projectPath,
});
} catch (error) {
if (!(error instanceof AbortError)) {
if (!isAbortError(error)) {
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: (error as Error).message,
@@ -909,7 +872,7 @@ When done, summarize what you implemented and any notes for the developer.`;
imagePaths?: string[],
model?: string
): Promise<void> {
const finalModel = model || MODEL_MAP.opus;
const finalModel = resolveModelString(model, DEFAULT_MODELS.claude);
console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`);
// Get provider for this model
@@ -919,51 +882,13 @@ When done, summarize what you implemented and any notes for the developer.`;
`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`
);
// Build prompt content with images (like AgentService)
let promptContent: string | Array<{ type: string; text?: string; source?: object }> = prompt;
if (imagePaths && imagePaths.length > 0) {
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
// Add text block first
contentBlocks.push({ type: "text", text: prompt });
// Add image blocks (for vision models)
for (const imagePath of imagePaths) {
try {
// Make path absolute by prepending workDir if it's relative
const absolutePath = path.isAbsolute(imagePath)
? imagePath
: path.join(workDir, imagePath);
const imageBuffer = await fs.readFile(absolutePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypeMap: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
const mediaType = mimeTypeMap[ext] || "image/png";
contentBlocks.push({
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Data,
},
});
} catch (error) {
console.error(`[AutoMode] Failed to load image ${imagePath}:`, error);
}
}
promptContent = contentBlocks;
}
// Build prompt content with images using utility
const { content: promptContent } = await buildPromptWithImages(
prompt,
imagePaths,
workDir,
false // don't duplicate paths in text
);
const options: ExecuteOptions = {
prompt: promptContent,