diff --git a/libs/utils/package.json b/libs/utils/package.json new file mode 100644 index 00000000..3be9294e --- /dev/null +++ b/libs/utils/package.json @@ -0,0 +1,21 @@ +{ + "name": "@automaker/utils", + "version": "1.0.0", + "description": "Shared utility functions for AutoMaker", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "keywords": ["automaker", "utils"], + "author": "", + "license": "MIT", + "dependencies": { + "@automaker/types": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/libs/utils/src/conversation-utils.ts b/libs/utils/src/conversation-utils.ts new file mode 100644 index 00000000..ae08a2cb --- /dev/null +++ b/libs/utils/src/conversation-utils.ts @@ -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 '@automaker/types'; + +/** + * 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, + })); +} diff --git a/libs/utils/src/error-handler.ts b/libs/utils/src/error-handler.ts new file mode 100644 index 00000000..ad5314e1 --- /dev/null +++ b/libs/utils/src/error-handler.ts @@ -0,0 +1,110 @@ +/** + * 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 + */ + +import type { ErrorType, ErrorInfo } from '@automaker/types'; + +/** + * 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 a user-initiated cancellation + * + * @param errorMessage - The error message to check + * @returns True if the error is a user-initiated cancellation + */ +export function isCancellationError(errorMessage: string): boolean { + const lowerMessage = errorMessage.toLowerCase(); + return ( + lowerMessage.includes("cancelled") || + lowerMessage.includes("canceled") || + lowerMessage.includes("stopped") || + lowerMessage.includes("aborted") + ); +} + +/** + * 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") + ); +} + +/** + * 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); + const isCancellation = isCancellationError(message); + + let type: ErrorType; + if (isAuth) { + type = "authentication"; + } else if (isAbort) { + type = "abort"; + } else if (isCancellation) { + type = "cancellation"; + } else if (error instanceof Error) { + type = "execution"; + } else { + type = "unknown"; + } + + return { + type, + message, + isAbort, + isAuth, + isCancellation, + 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; +} diff --git a/libs/utils/src/fs-utils.ts b/libs/utils/src/fs-utils.ts new file mode 100644 index 00000000..5b67124a --- /dev/null +++ b/libs/utils/src/fs-utils.ts @@ -0,0 +1,67 @@ +/** + * File system utilities that handle symlinks safely + */ + +import fs from "fs/promises"; +import path from "path"; + +/** + * Create a directory, handling symlinks safely to avoid ELOOP errors. + * If the path already exists as a directory or symlink, returns success. + */ +export async function mkdirSafe(dirPath: string): Promise { + const resolvedPath = path.resolve(dirPath); + + // Check if path already exists using lstat (doesn't follow symlinks) + try { + const stats = await fs.lstat(resolvedPath); + // Path exists - if it's a directory or symlink, consider it success + if (stats.isDirectory() || stats.isSymbolicLink()) { + return; + } + // It's a file - can't create directory + throw new Error(`Path exists and is not a directory: ${resolvedPath}`); + } catch (error: any) { + // ENOENT means path doesn't exist - we should create it + if (error.code !== "ENOENT") { + // Some other error (could be ELOOP in parent path) + // If it's ELOOP, the path involves symlinks - don't try to create + if (error.code === "ELOOP") { + console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`); + return; + } + throw error; + } + } + + // Path doesn't exist, create it + try { + await fs.mkdir(resolvedPath, { recursive: true }); + } catch (error: any) { + // Handle race conditions and symlink issues + if (error.code === "EEXIST" || error.code === "ELOOP") { + return; + } + throw error; + } +} + +/** + * Check if a path exists, handling symlinks safely. + * Returns true if the path exists as a file, directory, or symlink. + */ +export async function existsSafe(filePath: string): Promise { + try { + await fs.lstat(filePath); + return true; + } catch (error: any) { + if (error.code === "ENOENT") { + return false; + } + // ELOOP or other errors - path exists but is problematic + if (error.code === "ELOOP") { + return true; // Symlink exists, even if looping + } + throw error; + } +} diff --git a/libs/utils/src/image-handler.ts b/libs/utils/src/image-handler.ts new file mode 100644 index 00000000..d99ca452 --- /dev/null +++ b/libs/utils/src/image-handler.ts @@ -0,0 +1,114 @@ +/** + * 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"; +import type { ImageData, ImageContentBlock } from '@automaker/types'; + +/** + * MIME type mapping for image file extensions + */ +const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +} as const; + +/** + * 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 { + 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 { + 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; +} diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts new file mode 100644 index 00000000..694b999b --- /dev/null +++ b/libs/utils/src/index.ts @@ -0,0 +1,50 @@ +/** + * @automaker/utils + * Shared utility functions for AutoMaker + */ + +// Error handling +export { + isAbortError, + isCancellationError, + isAuthenticationError, + classifyError, + getUserFriendlyErrorMessage, +} from './error-handler'; + +// Conversation utilities +export { + extractTextFromContent, + normalizeContentBlocks, + formatHistoryAsText, + convertHistoryToMessages, +} from './conversation-utils'; + +// Image handling +export { + getMimeTypeForImage, + readImageAsBase64, + convertImagesToContentBlocks, + formatImagePathsForPrompt, +} from './image-handler'; + +// Prompt building +export { + buildPromptWithImages, + type PromptContent, + type PromptWithImages, +} from './prompt-builder'; + +// Logger +export { + createLogger, + getLogLevel, + setLogLevel, + LogLevel, +} from './logger'; + +// File system utilities +export { + mkdirSafe, + existsSafe, +} from './fs-utils'; diff --git a/libs/utils/src/logger.ts b/libs/utils/src/logger.ts new file mode 100644 index 00000000..44a82543 --- /dev/null +++ b/libs/utils/src/logger.ts @@ -0,0 +1,74 @@ +/** + * Simple logger utility with log levels + * Configure via LOG_LEVEL environment variable: error, warn, info, debug + * Defaults to 'info' if not set + */ + +export enum LogLevel { + ERROR = 0, + WARN = 1, + INFO = 2, + DEBUG = 3, +} + +const LOG_LEVEL_NAMES: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; + +let currentLogLevel: LogLevel = LogLevel.INFO; + +// Initialize log level from environment variable +const envLogLevel = process.env.LOG_LEVEL?.toLowerCase(); +if (envLogLevel && LOG_LEVEL_NAMES[envLogLevel] !== undefined) { + currentLogLevel = LOG_LEVEL_NAMES[envLogLevel]; +} + +/** + * Create a logger instance with a context prefix + */ +export function createLogger(context: string) { + const prefix = `[${context}]`; + + return { + error: (...args: unknown[]): void => { + if (currentLogLevel >= LogLevel.ERROR) { + console.error(prefix, ...args); + } + }, + + warn: (...args: unknown[]): void => { + if (currentLogLevel >= LogLevel.WARN) { + console.warn(prefix, ...args); + } + }, + + info: (...args: unknown[]): void => { + if (currentLogLevel >= LogLevel.INFO) { + console.log(prefix, ...args); + } + }, + + debug: (...args: unknown[]): void => { + if (currentLogLevel >= LogLevel.DEBUG) { + console.log(prefix, "[DEBUG]", ...args); + } + }, + }; +} + +/** + * Get the current log level + */ +export function getLogLevel(): LogLevel { + return currentLogLevel; +} + +/** + * Set the log level programmatically (useful for testing) + */ +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; +} diff --git a/libs/utils/src/prompt-builder.ts b/libs/utils/src/prompt-builder.ts new file mode 100644 index 00000000..ee0065fc --- /dev/null +++ b/libs/utils/src/prompt-builder.ts @@ -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"; + +/** + * 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 { + // 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 }; +} diff --git a/libs/utils/tsconfig.json b/libs/utils/tsconfig.json new file mode 100644 index 00000000..54e9774b --- /dev/null +++ b/libs/utils/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "types": ["node"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}