mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add @automaker/utils package
- Extract error handling utilities (isAbortError, classifyError, etc.) - Extract conversation utilities (formatHistoryAsText, etc.) - Extract image handling utilities (readImageAsBase64, etc.) - Extract prompt building utilities (buildPromptWithImages) - Extract logger utilities (createLogger, setLogLevel) - Extract file system utilities (mkdirSafe, existsSafe) All utilities now use @automaker/types for type imports. Provides shared utility functions for both server and UI. Dependencies: @automaker/types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
21
libs/utils/package.json
Normal file
21
libs/utils/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
97
libs/utils/src/conversation-utils.ts
Normal file
97
libs/utils/src/conversation-utils.ts
Normal 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 '@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,
|
||||
}));
|
||||
}
|
||||
110
libs/utils/src/error-handler.ts
Normal file
110
libs/utils/src/error-handler.ts
Normal file
@@ -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;
|
||||
}
|
||||
67
libs/utils/src/fs-utils.ts
Normal file
67
libs/utils/src/fs-utils.ts
Normal file
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
114
libs/utils/src/image-handler.ts
Normal file
114
libs/utils/src/image-handler.ts
Normal file
@@ -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<string, string> = {
|
||||
".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<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;
|
||||
}
|
||||
50
libs/utils/src/index.ts
Normal file
50
libs/utils/src/index.ts
Normal file
@@ -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';
|
||||
74
libs/utils/src/logger.ts
Normal file
74
libs/utils/src/logger.ts
Normal file
@@ -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<string, LogLevel> = {
|
||||
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;
|
||||
}
|
||||
79
libs/utils/src/prompt-builder.ts
Normal file
79
libs/utils/src/prompt-builder.ts
Normal 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";
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
20
libs/utils/tsconfig.json
Normal file
20
libs/utils/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user