mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-07 10:13:08 +00:00
- Added `isAdaptiveThinkingModel` utility to improve model identification logic in the AddFeatureDialog. - Updated the ThinkingLevelSelector to conditionally display information based on available thinking levels. - Enhanced model name formatting in agent-context-parser to include 'GPT-5.3 Codex' for better clarity. These changes improve the user experience by refining model handling and UI feedback related to adaptive thinking capabilities.
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
/**
|
|
* 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-6';
|
|
|
|
/**
|
|
* Formats a model name for display
|
|
*/
|
|
export function formatModelName(model: string): string {
|
|
// Claude models
|
|
if (model.includes('opus-4-6')) return 'Opus 4.6';
|
|
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.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';
|
|
// 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., "<sum\n\nmary>" -> "<summary>"
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Extracts a summary from completed feature context
|
|
* Looks for content between <summary> and </summary> 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: /<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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|