/** * Agent Context Parser * Extracts useful information from agent context files for display in kanban cards */ export interface AgentTaskInfo { // Task list extracted from TodoWrite tool calls todos: { content: string; status: 'pending' | 'in_progress' | 'completed'; }[]; // Progress stats toolCallCount: number; lastToolUsed?: string; // Phase info currentPhase?: 'planning' | 'action' | 'verification'; // Summary (if feature is completed) summary?: string; // Estimated progress percentage based on phase and tool calls progressPercentage: number; } /** * Default model used by the feature executor */ export const DEFAULT_MODEL = 'claude-opus-4-5-20251101'; /** * Formats a model name for display */ export function formatModelName(model: string): string { // Claude models if (model.includes('opus')) return 'Opus 4.5'; if (model.includes('sonnet')) return 'Sonnet 4.5'; if (model.includes('haiku')) return 'Haiku 4.5'; // Codex/GPT models - specific formatting 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'; // Generic fallbacks for other GPT models if (model.startsWith('gpt-')) return model.toUpperCase(); if (model.match(/^o\d/)) return model.toUpperCase(); // o1, o3, etc. // Cursor models 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'; // Default: split by dash and capitalize return model.split('-').slice(1, 3).join(' '); } /** * Helper to extract a balanced JSON object from a string starting at a given position */ 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; } /** * Extracts todos from the context content * Looks for TodoWrite tool calls in the format: * šŸ”§ Tool: TodoWrite * Input: {"todos": [{"content": "...", "status": "..."}]} */ function extractTodos(content: string): AgentTaskInfo['todos'] { const todos: AgentTaskInfo['todos'] = []; // Find all occurrences of TodoWrite tool calls const todoWriteMarker = 'šŸ”§ Tool: TodoWrite'; let searchStart = 0; while (true) { const markerIdx = content.indexOf(todoWriteMarker, searchStart); if (markerIdx === -1) break; // Look for "Input:" after the marker const inputIdx = content.indexOf('Input:', markerIdx); if (inputIdx === -1 || inputIdx > markerIdx + 100) { searchStart = markerIdx + 1; continue; } // Find the start of the JSON object const jsonStart = content.indexOf('{', inputIdx); if (jsonStart === -1) { searchStart = markerIdx + 1; continue; } // Extract the complete JSON object 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)) { // Clear previous todos - we want the latest state 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 { // Ignore parse errors } } searchStart = markerIdx + 1; } // Also try to extract from markdown task lists as fallback 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; } /** * Counts tool calls in the content */ function countToolCalls(content: string): number { const matches = content.match(/šŸ”§\s*Tool:/g); return matches?.length || 0; } /** * Gets the last tool used */ 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; } /** * Determines the current phase from the content */ function getCurrentPhase(content: string): 'planning' | 'action' | 'verification' | undefined { // Find the last phase marker 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'; } /** * Cleans up fragmented streaming text by removing spurious newlines * This handles cases where streaming providers send partial text chunks * that got separated by newlines during accumulation */ function cleanFragmentedText(content: string): string { // Remove newlines that break up words (newline between letters) // e.g., "sum\n\nmary" -> "summary" let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2'); // Also clean up fragmented XML-like tags // e.g., "" -> "" 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, ''); return cleaned; } /** * Extracts a summary from completed feature context * Looks for content between and tags * Returns the LAST summary found to ensure we get the most recent/updated one */ function extractSummary(content: string): string | undefined { // First, clean up any fragmented text from streaming const cleanedContent = cleanFragmentedText(content); // Define regex patterns to try in order of priority // Each pattern specifies which capture group contains the summary content const regexesToTry = [ { regex: /([\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; } /** * Calculates progress percentage based on phase and context * Uses a more dynamic approach that better reflects actual progress */ function calculateProgress( phase: AgentTaskInfo['currentPhase'], toolCallCount: number, todos: AgentTaskInfo['todos'] ): number { // If we have todos, primarily use them for progress calculation if (todos.length > 0) { const completedCount = todos.filter((t) => t.status === 'completed').length; const inProgressCount = todos.filter((t) => t.status === 'in_progress').length; // Weight: completed = 1, in_progress = 0.5, pending = 0 const progress = ((completedCount + inProgressCount * 0.5) / todos.length) * 90; // Add a small base amount and cap at 95% return Math.min(5 + progress, 95); } // Fallback: use phase-based progress with tool call scaling let phaseProgress = 0; switch (phase) { case 'planning': // Planning phase: 5-25% phaseProgress = 5 + Math.min(toolCallCount * 1, 20); break; case 'action': // Action phase: 25-75% based on tool calls (logarithmic scaling) phaseProgress = 25 + Math.min(Math.log2(toolCallCount + 1) * 10, 50); break; case 'verification': // Verification phase: 75-95% phaseProgress = 75 + Math.min(toolCallCount * 0.5, 20); break; default: // Starting: just use tool calls phaseProgress = Math.min(toolCallCount * 0.5, 10); } return Math.min(Math.round(phaseProgress), 95); } /** * Parses agent context content and extracts useful information */ 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, }; } /** * Quick stats for display in card badges */ export interface QuickStats { toolCalls: number; completedTasks: number; totalTasks: number; phase?: string; } /** * Extracts quick stats from context for compact display */ 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, }; }