| |
| |
| |
| |
|
|
| import type { ClaudeCompatibleProvider } from '@automaker/types'; |
|
|
| export interface AgentTaskInfo { |
| |
| todos: { |
| content: string; |
| status: 'pending' | 'in_progress' | 'completed'; |
| }[]; |
|
|
| |
| toolCallCount: number; |
| lastToolUsed?: string; |
|
|
| |
| currentPhase?: 'planning' | 'action' | 'verification'; |
|
|
| |
| summary?: string; |
|
|
| |
| progressPercentage: number; |
| } |
|
|
| |
| |
| |
| export const DEFAULT_MODEL = 'claude-opus-4-6'; |
|
|
| |
| |
| |
| export interface FormatModelNameOptions { |
| |
| providerId?: string; |
| |
| claudeCompatibleProviders?: ClaudeCompatibleProvider[]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function formatModelName(model: string, options?: FormatModelNameOptions): string { |
| |
| if (options?.providerId && options?.claudeCompatibleProviders) { |
| const provider = options.claudeCompatibleProviders.find((p) => p.id === options.providerId); |
| if (provider?.models) { |
| const providerModel = provider.models.find((m) => m.id === model); |
| if (providerModel?.displayName) { |
| return providerModel.displayName; |
| } |
| } |
| } |
|
|
| |
| if (model.includes('opus-4-6') || model === 'claude-opus') return 'Opus 4.6'; |
| if (model.includes('opus')) return 'Opus 4.5'; |
| if (model.includes('sonnet-4-6') || model === 'claude-sonnet') return 'Sonnet 4.6'; |
| if (model.includes('sonnet')) return 'Sonnet 4.5'; |
| if (model.includes('haiku')) return 'Haiku 4.5'; |
|
|
| |
| if (model === 'codex-gpt-5.3-codex') return 'GPT-5.3 Codex'; |
| if (model === 'codex-gpt-5.2-codex') return 'GPT-5.2 Codex'; |
| if (model === 'codex-gpt-5.2') return 'GPT-5.2'; |
| if (model === 'codex-gpt-5.1-codex-max') return 'GPT-5.1 Max'; |
| if (model === 'codex-gpt-5.1-codex-mini') return 'GPT-5.1 Mini'; |
| if (model === 'codex-gpt-5.1') return 'GPT-5.1'; |
| |
| if (model.startsWith('gpt-')) return model.toUpperCase(); |
| if (model.match(/^o\d/)) return model.toUpperCase(); |
|
|
| |
| if (model === 'cursor-auto' || model === 'auto') return 'Cursor Auto'; |
| if (model === 'cursor-composer-1' || model === 'composer-1') return 'Composer 1'; |
| if (model.startsWith('cursor-sonnet')) return 'Cursor Sonnet'; |
| if (model.startsWith('cursor-opus')) return 'Cursor Opus'; |
| if (model.startsWith('cursor-gpt')) return model.replace('cursor-', '').replace('gpt-', 'GPT-'); |
| if (model.startsWith('cursor-gemini')) |
| return model.replace('cursor-', 'Cursor ').replace('gemini', 'Gemini'); |
| if (model.startsWith('cursor-grok')) return 'Cursor Grok'; |
|
|
| |
| if (model === 'opencode-big-pickle') return 'Big Pickle'; |
| if (model === 'opencode-glm-5-free') return 'GLM 5 Free'; |
| if (model === 'opencode-gpt-5-nano') return 'GPT-5 Nano'; |
| if (model === 'opencode-kimi-k2.5-free') return 'Kimi K2.5'; |
| if (model === 'opencode-minimax-m2.5-free') return 'MiniMax M2.5'; |
|
|
| |
| if (model.includes('/') && !model.includes('://')) { |
| const slashIndex = model.indexOf('/'); |
| const modelName = model.substring(slashIndex + 1); |
| |
| let lastSegment = modelName.split('/').pop()!; |
| |
| const tierMatch = lastSegment.match(/:(free|extended|beta|preview)$/i); |
| if (tierMatch) { |
| lastSegment = lastSegment.slice(0, lastSegment.length - tierMatch[0].length); |
| } |
| |
| const cleanedName = lastSegment.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); |
| |
| if (tierMatch) { |
| const capitalizedTier = |
| tierMatch[1].charAt(0).toUpperCase() + tierMatch[1].slice(1).toLowerCase(); |
| return `${cleanedName} (${capitalizedTier})`; |
| } |
| return cleanedName; |
| } |
|
|
| |
| return model.split('-').slice(1, 3).join(' '); |
| } |
|
|
| |
| |
| |
| function extractJsonObject(str: string, startIdx: number): string | null { |
| if (str[startIdx] !== '{') return null; |
|
|
| let depth = 0; |
| let inString = false; |
| let escapeNext = false; |
|
|
| for (let i = startIdx; i < str.length; i++) { |
| const char = str[i]; |
|
|
| if (escapeNext) { |
| escapeNext = false; |
| continue; |
| } |
|
|
| if (char === '\\' && inString) { |
| escapeNext = true; |
| continue; |
| } |
|
|
| if (char === '"' && !escapeNext) { |
| inString = !inString; |
| continue; |
| } |
|
|
| if (inString) continue; |
|
|
| if (char === '{') depth++; |
| else if (char === '}') { |
| depth--; |
| if (depth === 0) { |
| return str.slice(startIdx, i + 1); |
| } |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function extractTodos(content: string): AgentTaskInfo['todos'] { |
| const todos: AgentTaskInfo['todos'] = []; |
|
|
| |
| const todoWriteMarker = '🔧 Tool: TodoWrite'; |
| let searchStart = 0; |
|
|
| while (true) { |
| const markerIdx = content.indexOf(todoWriteMarker, searchStart); |
| if (markerIdx === -1) break; |
|
|
| |
| const inputIdx = content.indexOf('Input:', markerIdx); |
| if (inputIdx === -1 || inputIdx > markerIdx + 100) { |
| searchStart = markerIdx + 1; |
| continue; |
| } |
|
|
| |
| const jsonStart = content.indexOf('{', inputIdx); |
| if (jsonStart === -1) { |
| searchStart = markerIdx + 1; |
| continue; |
| } |
|
|
| |
| const jsonStr = extractJsonObject(content, jsonStart); |
| if (jsonStr) { |
| try { |
| const parsed = JSON.parse(jsonStr) as { |
| todos?: Array<{ content: string; status: string }>; |
| }; |
| if (parsed.todos && Array.isArray(parsed.todos)) { |
| |
| todos.length = 0; |
| for (const item of parsed.todos) { |
| if (item.content && item.status) { |
| todos.push({ |
| content: item.content, |
| status: item.status as 'pending' | 'in_progress' | 'completed', |
| }); |
| } |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|
| searchStart = markerIdx + 1; |
| } |
|
|
| |
| if (todos.length === 0) { |
| const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g); |
| for (const match of markdownTodos) { |
| const isCompleted = match[1].toLowerCase() === 'x'; |
| const todoContent = match[2].trim(); |
| if (!todos.some((t) => t.content === todoContent)) { |
| todos.push({ |
| content: todoContent, |
| status: isCompleted ? 'completed' : 'pending', |
| }); |
| } |
| } |
| } |
|
|
| return todos; |
| } |
|
|
| |
| |
| |
| function countToolCalls(content: string): number { |
| const matches = content.match(/🔧\s*Tool:/g); |
| return matches?.length || 0; |
| } |
|
|
| |
| |
| |
| function getLastToolUsed(content: string): string | undefined { |
| const matches = [...content.matchAll(/🔧\s*Tool:\s*(\S+)/g)]; |
| if (matches.length > 0) { |
| return matches[matches.length - 1][1]; |
| } |
| return undefined; |
| } |
|
|
| |
| |
| |
| function getCurrentPhase(content: string): 'planning' | 'action' | 'verification' | undefined { |
| |
| const planningIndex = content.lastIndexOf('📋'); |
| const actionIndex = content.lastIndexOf('⚡'); |
| const verificationIndex = content.lastIndexOf('✅'); |
|
|
| const maxIndex = Math.max(planningIndex, actionIndex, verificationIndex); |
|
|
| if (maxIndex === -1) return undefined; |
| if (maxIndex === verificationIndex) return 'verification'; |
| if (maxIndex === actionIndex) return 'action'; |
| return 'planning'; |
| } |
|
|
| |
| |
| |
| |
| |
| function cleanFragmentedText(content: string): string { |
| |
| |
| let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2'); |
|
|
| |
| |
| cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>'); |
| cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>'); |
|
|
| return cleaned; |
| } |
|
|
| |
| |
| |
| |
| |
| function extractSummary(content: string): string | undefined { |
| |
| const cleanedContent = cleanFragmentedText(content); |
|
|
| |
| |
| const regexesToTry = [ |
| { regex: /<summary>([\s\S]*?)<\/summary>/gi, group: 1 }, |
| { regex: /## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/gi, group: 1 }, |
| { |
| regex: |
| /✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/gi, |
| group: 0, |
| }, |
| { |
| regex: /(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/gi, |
| group: 1, |
| }, |
| ]; |
|
|
| for (const { regex, group } of regexesToTry) { |
| const matches = [...cleanedContent.matchAll(regex)]; |
| if (matches.length > 0) { |
| const lastMatch = matches[matches.length - 1]; |
| return cleanFragmentedText(lastMatch[group]).trim(); |
| } |
| } |
|
|
| return undefined; |
| } |
|
|
| |
| |
| |
| |
| function calculateProgress( |
| phase: AgentTaskInfo['currentPhase'], |
| toolCallCount: number, |
| todos: AgentTaskInfo['todos'] |
| ): number { |
| |
| if (todos.length > 0) { |
| const completedCount = todos.filter((t) => t.status === 'completed').length; |
| const inProgressCount = todos.filter((t) => t.status === 'in_progress').length; |
|
|
| |
| const progress = ((completedCount + inProgressCount * 0.5) / todos.length) * 90; |
|
|
| |
| return Math.min(5 + progress, 95); |
| } |
|
|
| |
| let phaseProgress = 0; |
| switch (phase) { |
| case 'planning': |
| |
| phaseProgress = 5 + Math.min(toolCallCount * 1, 20); |
| break; |
| case 'action': |
| |
| phaseProgress = 25 + Math.min(Math.log2(toolCallCount + 1) * 10, 50); |
| break; |
| case 'verification': |
| |
| phaseProgress = 75 + Math.min(toolCallCount * 0.5, 20); |
| break; |
| default: |
| |
| phaseProgress = Math.min(toolCallCount * 0.5, 10); |
| } |
|
|
| return Math.min(Math.round(phaseProgress), 95); |
| } |
|
|
| |
| |
| |
| export function parseAgentContext(content: string): AgentTaskInfo { |
| if (!content || !content.trim()) { |
| return { |
| todos: [], |
| toolCallCount: 0, |
| progressPercentage: 0, |
| }; |
| } |
|
|
| const todos = extractTodos(content); |
| const toolCallCount = countToolCalls(content); |
| const lastToolUsed = getLastToolUsed(content); |
| const currentPhase = getCurrentPhase(content); |
| const summary = extractSummary(content); |
| const progressPercentage = calculateProgress(currentPhase, toolCallCount, todos); |
|
|
| return { |
| todos, |
| toolCallCount, |
| lastToolUsed, |
| currentPhase, |
| summary, |
| progressPercentage, |
| }; |
| } |
|
|
| |
| |
| |
| export interface QuickStats { |
| toolCalls: number; |
| completedTasks: number; |
| totalTasks: number; |
| phase?: string; |
| } |
|
|
| |
| |
| |
| export function getQuickStats(content: string): QuickStats { |
| const info = parseAgentContext(content); |
| return { |
| toolCalls: info.toolCallCount, |
| completedTasks: info.todos.filter((t) => t.status === 'completed').length, |
| totalTasks: info.todos.length, |
| phase: info.currentPhase, |
| }; |
| } |
|
|