diff --git a/apps/server/src/routes/common.ts b/apps/server/src/routes/common.ts index 650e1ead..c2bc9a84 100644 --- a/apps/server/src/routes/common.ts +++ b/apps/server/src/routes/common.ts @@ -3,367 +3,23 @@ */ import { createLogger } from "@automaker/utils"; -import fs from "fs/promises"; -import path from "path"; -import { exec } from "child_process"; -import { promisify } from "util"; + +// Re-export git utilities from shared package +export { + BINARY_EXTENSIONS, + GIT_STATUS_MAP, + type FileStatus, + isGitRepo, + parseGitStatus, + generateSyntheticDiffForNewFile, + appendUntrackedFileDiffs, + listAllFilesInDirectory, + generateDiffsForNonGitDirectory, + getGitRepositoryDiffs, +} from "@automaker/git-utils"; type Logger = ReturnType; -const execAsync = promisify(exec); -const logger = createLogger("Common"); - -// Max file size for generating synthetic diffs (1MB) -const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024; - -// Binary file extensions to skip -const BINARY_EXTENSIONS = new Set([ - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg", - ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", - ".zip", ".tar", ".gz", ".rar", ".7z", - ".exe", ".dll", ".so", ".dylib", - ".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv", - ".ttf", ".otf", ".woff", ".woff2", ".eot", - ".db", ".sqlite", ".sqlite3", - ".pyc", ".pyo", ".class", ".o", ".obj", -]); - -// Status map for git status codes -// Git porcelain format uses XY where X=staging area, Y=working tree -const GIT_STATUS_MAP: Record = { - M: "Modified", - A: "Added", - D: "Deleted", - R: "Renamed", - C: "Copied", - U: "Updated", - "?": "Untracked", - "!": "Ignored", - " ": "Unmodified", -}; - -/** - * Get a readable status text from git status codes - * Handles both single character and XY format status codes - */ -function getStatusText(indexStatus: string, workTreeStatus: string): string { - // Untracked files - if (indexStatus === "?" && workTreeStatus === "?") { - return "Untracked"; - } - - // Ignored files - if (indexStatus === "!" && workTreeStatus === "!") { - return "Ignored"; - } - - // Prioritize staging area status, then working tree - const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus; - - // Handle combined statuses - if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") { - // Both staging and working tree have changes - const indexText = GIT_STATUS_MAP[indexStatus] || "Changed"; - const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed"; - if (indexText === workText) { - return indexText; - } - return `${indexText} (staged), ${workText} (unstaged)`; - } - - return GIT_STATUS_MAP[primaryStatus] || "Changed"; -} - -/** - * File status interface for git status results - */ -export interface FileStatus { - status: string; - path: string; - statusText: string; -} - -/** - * Check if a file is likely binary based on extension - */ -function isBinaryFile(filePath: string): boolean { - const ext = path.extname(filePath).toLowerCase(); - return BINARY_EXTENSIONS.has(ext); -} - -/** - * Check if a path is a git repository - */ -export async function isGitRepo(repoPath: string): Promise { - try { - await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath }); - return true; - } catch { - return false; - } -} - -/** - * Parse the output of `git status --porcelain` into FileStatus array - * Git porcelain format: XY PATH where X=staging area status, Y=working tree status - * For renamed files: XY ORIG_PATH -> NEW_PATH - */ -export function parseGitStatus(statusOutput: string): FileStatus[] { - return statusOutput - .split("\n") - .filter(Boolean) - .map((line) => { - // Git porcelain format uses two status characters: XY - // X = status in staging area (index) - // Y = status in working tree - const indexStatus = line[0] || " "; - const workTreeStatus = line[1] || " "; - - // File path starts at position 3 (after "XY ") - let filePath = line.slice(3); - - // Handle renamed files (format: "R old_path -> new_path") - if (indexStatus === "R" || workTreeStatus === "R") { - const arrowIndex = filePath.indexOf(" -> "); - if (arrowIndex !== -1) { - filePath = filePath.slice(arrowIndex + 4); // Use new path - } - } - - // Determine the primary status character for backwards compatibility - // Prioritize staging area status, then working tree - let primaryStatus: string; - if (indexStatus === "?" && workTreeStatus === "?") { - primaryStatus = "?"; // Untracked - } else if (indexStatus !== " " && indexStatus !== "?") { - primaryStatus = indexStatus; // Staged change - } else { - primaryStatus = workTreeStatus; // Working tree change - } - - return { - status: primaryStatus, - path: filePath, - statusText: getStatusText(indexStatus, workTreeStatus), - }; - }); -} - -/** - * Generate a synthetic unified diff for an untracked (new) file - * This is needed because `git diff HEAD` doesn't include untracked files - */ -export async function generateSyntheticDiffForNewFile( - basePath: string, - relativePath: string -): Promise { - const fullPath = path.join(basePath, relativePath); - - try { - // Check if it's a binary file - if (isBinaryFile(relativePath)) { - return `diff --git a/${relativePath} b/${relativePath} -new file mode 100644 -index 0000000..0000000 -Binary file ${relativePath} added -`; - } - - // Get file stats to check size - const stats = await fs.stat(fullPath); - if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) { - const sizeKB = Math.round(stats.size / 1024); - return `diff --git a/${relativePath} b/${relativePath} -new file mode 100644 -index 0000000..0000000 ---- /dev/null -+++ b/${relativePath} -@@ -0,0 +1 @@ -+[File too large to display: ${sizeKB}KB] -`; - } - - // Read file content - const content = await fs.readFile(fullPath, "utf-8"); - const hasTrailingNewline = content.endsWith("\n"); - const lines = content.split("\n"); - - // Remove trailing empty line if the file ends with newline - if (lines.length > 0 && lines.at(-1) === "") { - lines.pop(); - } - - // Generate diff format - const lineCount = lines.length; - const addedLines = lines.map(line => `+${line}`).join("\n"); - - let diff = `diff --git a/${relativePath} b/${relativePath} -new file mode 100644 -index 0000000..0000000 ---- /dev/null -+++ b/${relativePath} -@@ -0,0 +1,${lineCount} @@ -${addedLines}`; - - // Add "No newline at end of file" indicator if needed - if (!hasTrailingNewline && content.length > 0) { - diff += "\n\\ No newline at end of file"; - } - - return diff + "\n"; - } catch (error) { - // Log the error for debugging - logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error); - // Return a placeholder diff - return `diff --git a/${relativePath} b/${relativePath} -new file mode 100644 -index 0000000..0000000 ---- /dev/null -+++ b/${relativePath} -@@ -0,0 +1 @@ -+[Unable to read file content] -`; - } -} - -/** - * Generate synthetic diffs for all untracked files and combine with existing diff - */ -export async function appendUntrackedFileDiffs( - basePath: string, - existingDiff: string, - files: Array<{ status: string; path: string }> -): Promise { - // Find untracked files (status "?") - const untrackedFiles = files.filter(f => f.status === "?"); - - if (untrackedFiles.length === 0) { - return existingDiff; - } - - // Generate synthetic diffs for each untracked file - const syntheticDiffs = await Promise.all( - untrackedFiles.map(f => generateSyntheticDiffForNewFile(basePath, f.path)) - ); - - // Combine existing diff with synthetic diffs - const combinedDiff = existingDiff + syntheticDiffs.join(""); - - return combinedDiff; -} - -/** - * List all files in a directory recursively (for non-git repositories) - * Excludes hidden files/folders and common build artifacts - */ -export async function listAllFilesInDirectory( - basePath: string, - relativePath: string = "" -): Promise { - const files: string[] = []; - const fullPath = path.join(basePath, relativePath); - - // Directories to skip - const skipDirs = new Set([ - "node_modules", ".git", ".automaker", "dist", "build", - ".next", ".nuxt", "__pycache__", ".cache", "coverage" - ]); - - try { - const entries = await fs.readdir(fullPath, { withFileTypes: true }); - - for (const entry of entries) { - // Skip hidden files/folders (except we want to allow some) - if (entry.name.startsWith(".") && entry.name !== ".env") { - continue; - } - - const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; - - if (entry.isDirectory()) { - if (!skipDirs.has(entry.name)) { - const subFiles = await listAllFilesInDirectory(basePath, entryRelPath); - files.push(...subFiles); - } - } else if (entry.isFile()) { - files.push(entryRelPath); - } - } - } catch (error) { - // Log the error to help diagnose file system issues - logger.error(`Error reading directory ${fullPath}:`, error); - } - - return files; -} - -/** - * Generate diffs for all files in a non-git directory - * Treats all files as "new" files - */ -export async function generateDiffsForNonGitDirectory( - basePath: string -): Promise<{ diff: string; files: FileStatus[] }> { - const allFiles = await listAllFilesInDirectory(basePath); - - const files: FileStatus[] = allFiles.map(filePath => ({ - status: "?", - path: filePath, - statusText: "New", - })); - - // Generate synthetic diffs for all files - const syntheticDiffs = await Promise.all( - files.map(f => generateSyntheticDiffForNewFile(basePath, f.path)) - ); - - return { - diff: syntheticDiffs.join(""), - files, - }; -} - -/** - * Get git repository diffs for a given path - * Handles both git repos and non-git directories - */ -export async function getGitRepositoryDiffs( - repoPath: string -): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> { - // Check if it's a git repository - const isRepo = await isGitRepo(repoPath); - - if (!isRepo) { - // Not a git repo - list all files and treat them as new - const result = await generateDiffsForNonGitDirectory(repoPath); - return { - diff: result.diff, - files: result.files, - hasChanges: result.files.length > 0, - }; - } - - // Get git diff and status - const { stdout: diff } = await execAsync("git diff HEAD", { - cwd: repoPath, - maxBuffer: 10 * 1024 * 1024, - }); - const { stdout: status } = await execAsync("git status --porcelain", { - cwd: repoPath, - }); - - const files = parseGitStatus(status); - - // Generate synthetic diffs for untracked (new) files - const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files); - - return { - diff: combinedDiff, - files, - hasChanges: files.length > 0, - }; -} - /** * Get error message from error object */ diff --git a/apps/ui/src/config/model-config.ts b/apps/ui/src/config/model-config.ts index 1d8cb190..99f9ca64 100644 --- a/apps/ui/src/config/model-config.ts +++ b/apps/ui/src/config/model-config.ts @@ -6,48 +6,12 @@ * - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations */ -/** - * Claude model aliases for convenience - */ -export const CLAUDE_MODEL_MAP: Record = { - haiku: "claude-haiku-4-5", - sonnet: "claude-sonnet-4-20250514", - opus: "claude-opus-4-5-20251101", -} as const; +// Import shared model constants and types +import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from "@automaker/types"; +import { resolveModelString } from "@automaker/model-resolver"; -/** - * Default models per use case - */ -export const DEFAULT_MODELS = { - chat: "claude-opus-4-5-20251101", - default: "claude-opus-4-5-20251101", -} as const; - -/** - * Resolve a model alias to a full model string - */ -export function resolveModelString( - modelKey?: string, - defaultModel: string = DEFAULT_MODELS.default -): string { - if (!modelKey) { - return defaultModel; - } - - // Full Claude model string - pass through - if (modelKey.includes("claude-")) { - return modelKey; - } - - // Check alias map - const resolved = CLAUDE_MODEL_MAP[modelKey]; - if (resolved) { - return resolved; - } - - // Unknown key - use default - return defaultModel; -} +// Re-export for backward compatibility +export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, resolveModelString }; /** * Get the model for chat operations @@ -64,13 +28,13 @@ export function getChatModel(explicitModel?: string): string { } const envModel = - process.env.AUTOMAKER_MODEL_CHAT || process.env.AUTOMAKER_MODEL_DEFAULT; + import.meta.env.AUTOMAKER_MODEL_CHAT || import.meta.env.AUTOMAKER_MODEL_DEFAULT; if (envModel) { return resolveModelString(envModel); } - return DEFAULT_MODELS.chat; + return DEFAULT_MODELS.claude; } /** @@ -91,4 +55,3 @@ export const CHAT_TOOLS = [ * Default max turns for chat */ export const CHAT_MAX_TURNS = 1000; - diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts index feb33678..4f8c9f73 100644 --- a/apps/ui/src/lib/agent-context-parser.ts +++ b/apps/ui/src/lib/agent-context-parser.ts @@ -3,6 +3,8 @@ * Extracts useful information from agent context files for display in kanban cards */ +import { DEFAULT_MODELS } from "@automaker/types"; + export interface AgentTaskInfo { // Task list extracted from TodoWrite tool calls todos: { @@ -27,7 +29,7 @@ export interface AgentTaskInfo { /** * Default model used by the feature executor */ -export const DEFAULT_MODEL = "claude-opus-4-5-20251101"; +export const DEFAULT_MODEL = DEFAULT_MODELS.claude; /** * Formats a model name for display