diff --git a/templates/dev.js b/templates/dev.js index 052865f2..982ab2e0 100644 --- a/templates/dev.js +++ b/templates/dev.js @@ -98,6 +98,26 @@ const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); +// Add a simple loading indicator function +function startLoadingIndicator(message) { + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + + process.stdout.write(`${message} `); + + return setInterval(() => { + readline.cursorTo(process.stdout, message.length + 1); + process.stdout.write(frames[i]); + i = (i + 1) % frames.length; + }, 80); +} + +function stopLoadingIndicator(interval) { + clearInterval(interval); + readline.cursorTo(process.stdout, 0); + readline.clearLine(process.stdout, 0); +} + function readJSON(filepath) { if (!fs.existsSync(filepath)) return null; const content = fs.readFileSync(filepath, 'utf8'); @@ -108,10 +128,17 @@ function writeJSON(filepath, data) { fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8'); } -async function callClaude(prdContent, prdPath, numTasks) { +async function callClaude(prdContent, prdPath, numTasks, retryCount = 0) { + const MAX_RETRIES = 3; + const INITIAL_BACKOFF_MS = 1000; + log('info', `Starting Claude API call to process PRD from ${prdPath}...`); log('debug', `PRD content length: ${prdContent.length} characters`); + // Start loading indicator + const loadingMessage = `Waiting for Claude to generate tasks${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`; + const loadingIndicator = startLoadingIndicator(loadingMessage); + const TASKS_JSON_TEMPLATE = ` { "meta": { @@ -154,26 +181,197 @@ async function callClaude(prdContent, prdPath, numTasks) { } log('debug', "System prompt:", systemPrompt); - log('info', "Sending request to Claude API..."); - const response = await anthropic.messages.create({ - max_tokens: CONFIG.maxTokens, - model: CONFIG.model, - temperature: CONFIG.temperature, - messages: [ - { - role: "user", - content: prdContent + try { + // Calculate appropriate max tokens based on PRD size + let maxTokens = CONFIG.maxTokens; + // Rough estimate: 1 token ≈ 4 characters + const estimatedPrdTokens = Math.ceil(prdContent.length / 4); + // Ensure we have enough tokens for the response + if (estimatedPrdTokens > maxTokens / 2) { + // If PRD is large, increase max tokens if possible + const suggestedMaxTokens = Math.min(32000, estimatedPrdTokens * 2); + if (suggestedMaxTokens > maxTokens) { + log('info', `PRD is large (est. ${estimatedPrdTokens} tokens). Increasing max_tokens to ${suggestedMaxTokens}.`); + maxTokens = suggestedMaxTokens; } - ], - system: systemPrompt - }); - log('info', "Received response from Claude API!"); + } + + // Determine if we should use streaming based on PRD size + // For PRDs larger than 20,000 characters (roughly 5,000 tokens), use streaming + const useStreaming = prdContent.length > 20000; + + if (useStreaming) { + log('info', `Large PRD detected (${prdContent.length} characters). Using streaming API...`); + return await handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, loadingIndicator); + } else { + log('info', "Sending request to Claude API..."); + + const response = await anthropic.messages.create({ + max_tokens: maxTokens, + model: CONFIG.model, + temperature: CONFIG.temperature, + messages: [ + { + role: "user", + content: prdContent + } + ], + system: systemPrompt + }); + + // Stop loading indicator + stopLoadingIndicator(loadingIndicator); + log('info', "Received response from Claude API!"); - // Extract the text content from the response - const textContent = response.content[0].text; - log('debug', `Response length: ${textContent.length} characters`); + // Extract the text content from the response + const textContent = response.content[0].text; + return processClaudeResponse(textContent, numTasks, retryCount, prdContent, prdPath); + } + } catch (error) { + // Stop loading indicator + stopLoadingIndicator(loadingIndicator); + + // Check if this is the streaming recommendation error + if (error.message && error.message.includes("Streaming is strongly recommended")) { + log('info', "Claude recommends streaming for this large PRD. Switching to streaming mode..."); + try { + // Calculate appropriate max tokens based on PRD size + let maxTokens = CONFIG.maxTokens; + const estimatedPrdTokens = Math.ceil(prdContent.length / 4); + const suggestedMaxTokens = Math.min(32000, estimatedPrdTokens * 2); + if (suggestedMaxTokens > maxTokens) { + maxTokens = suggestedMaxTokens; + } + + // Restart the loading indicator + const newLoadingIndicator = startLoadingIndicator(loadingMessage); + return await handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, newLoadingIndicator); + } catch (streamingError) { + log('error', "Error with streaming API call:", streamingError); + throw streamingError; + } + } + + log('error', "Error calling Claude API:", error); + + // Implement exponential backoff for retries + if (retryCount < MAX_RETRIES) { + const backoffTime = INITIAL_BACKOFF_MS * Math.pow(2, retryCount); + log('info', `Retrying in ${backoffTime/1000} seconds (attempt ${retryCount + 1}/${MAX_RETRIES})...`); + + await new Promise(resolve => setTimeout(resolve, backoffTime)); + + // If we have a numTasks parameter and it's greater than 3, try again with fewer tasks + if (numTasks && numTasks > 3) { + const reducedTasks = Math.max(3, Math.floor(numTasks * 0.7)); // Reduce by 30%, minimum 3 + log('info', `Retrying with reduced task count: ${reducedTasks} (was ${numTasks})`); + return callClaude(prdContent, prdPath, reducedTasks, retryCount + 1); + } else { + // Otherwise, just retry with the same parameters + return callClaude(prdContent, prdPath, numTasks, retryCount + 1); + } + } + + // If we've exhausted all retries, ask the user what to do + console.log("\nClaude API call failed after multiple attempts."); + console.log("Options:"); + console.log("1. Retry with the same parameters"); + console.log("2. Retry with fewer tasks (if applicable)"); + console.log("3. Abort"); + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise((resolve, reject) => { + readline.question('Enter your choice (1-3): ', async (choice) => { + readline.close(); + + switch (choice) { + case '1': + console.log("Retrying with the same parameters..."); + resolve(await callClaude(prdContent, prdPath, numTasks, 0)); // Reset retry count + break; + case '2': + if (numTasks && numTasks > 2) { + const reducedTasks = Math.max(2, Math.floor(numTasks * 0.5)); // Reduce by 50%, minimum 2 + console.log(`Retrying with reduced task count: ${reducedTasks} (was ${numTasks})...`); + resolve(await callClaude(prdContent, prdPath, reducedTasks, 0)); // Reset retry count + } else { + console.log("Cannot reduce task count further. Retrying with the same parameters..."); + resolve(await callClaude(prdContent, prdPath, numTasks, 0)); // Reset retry count + } + break; + case '3': + default: + console.log("Aborting..."); + reject(new Error("User aborted after multiple failed attempts")); + break; + } + }); + }); + } +} + +// Helper function to handle streaming requests to Claude API +async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, loadingIndicator) { + log('info', "Sending streaming request to Claude API..."); + let fullResponse = ''; + let streamComplete = false; + let streamError = null; + + try { + const stream = await anthropic.messages.create({ + max_tokens: maxTokens, + model: CONFIG.model, + temperature: CONFIG.temperature, + messages: [ + { + role: "user", + content: prdContent + } + ], + system: systemPrompt, + stream: true + }); + + // Update loading indicator to show streaming progress + let dotCount = 0; + const streamingInterval = setInterval(() => { + readline.cursorTo(process.stdout, 0); + process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); + dotCount = (dotCount + 1) % 4; + }, 500); + + // Process the stream + for await (const chunk of stream) { + if (chunk.type === 'content_block_delta' && chunk.delta.text) { + fullResponse += chunk.delta.text; + } + } + + clearInterval(streamingInterval); + streamComplete = true; + + // Stop loading indicator + stopLoadingIndicator(loadingIndicator); + log('info', "Completed streaming response from Claude API!"); + log('debug', `Streaming response length: ${fullResponse.length} characters`); + + return processClaudeResponse(fullResponse, numTasks, 0, prdContent, prdPath); + } catch (error) { + clearInterval(streamingInterval); + stopLoadingIndicator(loadingIndicator); + log('error', "Error during streaming response:", error); + throw error; + } +} + +// Helper function to process Claude's response text +function processClaudeResponse(textContent, numTasks, retryCount, prdContent, prdPath) { try { // Check if the response is wrapped in a Markdown code block and extract the JSON log('info', "Parsing response as JSON..."); @@ -186,12 +384,40 @@ async function callClaude(prdContent, prdPath, numTasks) { // Try to parse the response as JSON const parsedJson = JSON.parse(jsonText); + + // Check if the response seems incomplete (e.g., missing closing brackets) + if (!parsedJson.tasks || parsedJson.tasks.length === 0) { + log('warn', "Parsed JSON has no tasks. Response may be incomplete."); + + // If we have a numTasks parameter and it's greater than 5, try again with fewer tasks + if (numTasks && numTasks > 5 && retryCount < MAX_RETRIES) { + const reducedTasks = Math.max(5, Math.floor(numTasks * 0.7)); // Reduce by 30%, minimum 5 + log('info', `Retrying with reduced task count: ${reducedTasks} (was ${numTasks})`); + return callClaude(prdContent, prdPath, reducedTasks, retryCount + 1); + } + } + log('info', `Successfully parsed JSON with ${parsedJson.tasks?.length || 0} tasks`); return parsedJson; } catch (error) { log('error', "Failed to parse Claude's response as JSON:", error); log('debug', "Raw response:", textContent); - throw new Error("Failed to parse Claude's response as JSON. See console for details."); + + // Check if we should retry with different parameters + if (retryCount < MAX_RETRIES) { + // If we have a numTasks parameter, try again with fewer tasks + if (numTasks && numTasks > 3) { + const reducedTasks = Math.max(3, Math.floor(numTasks * 0.6)); // Reduce by 40%, minimum 3 + log('info', `Retrying with reduced task count: ${reducedTasks} (was ${numTasks})`); + return callClaude(prdContent, prdPath, reducedTasks, retryCount + 1); + } else { + // Otherwise, just retry with the same parameters + log('info', `Retrying Claude API call (attempt ${retryCount + 1}/${MAX_RETRIES})...`); + return callClaude(prdContent, prdPath, numTasks, retryCount + 1); + } + } + + throw new Error("Failed to parse Claude's response as JSON after multiple attempts. See console for details."); } } @@ -210,32 +436,38 @@ async function parsePRD(prdPath, tasksPath, numTasks) { // call claude to generate the tasks.json log('info', "Calling Claude to generate tasks from PRD..."); - const claudeResponse = await callClaude(prdContent, prdPath, numTasks); - let tasks = claudeResponse.tasks || []; - log('info', `Claude generated ${tasks.length} tasks from the PRD`); + + try { + const claudeResponse = await callClaude(prdContent, prdPath, numTasks); + let tasks = claudeResponse.tasks || []; + log('info', `Claude generated ${tasks.length} tasks from the PRD`); - // Limit the number of tasks if specified - if (numTasks && numTasks > 0 && numTasks < tasks.length) { - log('info', `Limiting to the first ${numTasks} tasks as specified`); - tasks = tasks.slice(0, numTasks); + // Limit the number of tasks if specified + if (numTasks && numTasks > 0 && numTasks < tasks.length) { + log('info', `Limiting to the first ${numTasks} tasks as specified`); + tasks = tasks.slice(0, numTasks); + } + + log('info', "Creating tasks.json data structure..."); + const data = { + meta: { + projectName: CONFIG.projectName, + version: CONFIG.projectVersion, + source: prdPath, + description: "Tasks generated from PRD", + totalTasksGenerated: claudeResponse.tasks?.length || 0, + tasksIncluded: tasks.length + }, + tasks + }; + + log('info', `Writing ${tasks.length} tasks to ${tasksPath}...`); + writeJSON(tasksPath, data); + log('info', `Parsed PRD from '${prdPath}' -> wrote ${tasks.length} tasks to '${tasksPath}'.`); + } catch (error) { + log('error', `Failed to generate tasks from PRD: ${error.message}`); + process.exit(1); } - - log('info', "Creating tasks.json data structure..."); - const data = { - meta: { - projectName: CONFIG.projectName, - version: CONFIG.projectVersion, - source: prdPath, - description: "Tasks generated from PRD", - totalTasksGenerated: claudeResponse.tasks?.length || 0, - tasksIncluded: tasks.length - }, - tasks - }; - - log('info', `Writing ${tasks.length} tasks to ${tasksPath}...`); - writeJSON(tasksPath, data); - log('info', `Parsed PRD from '${prdPath}' -> wrote ${tasks.length} tasks to '${tasksPath}'.`); } //