UNPKG

story-weaver-ai

Version:

A narrative development system for AI-driven storytelling with Jungian psychology

837 lines (705 loc) 27.9 kB
/** * task-manager.js * Task management functions for the Task Master CLI */ import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import boxen from 'boxen'; import Table from 'cli-table3'; import readline from 'readline'; import { Anthropic } from '@anthropic-ai/sdk'; import { CONFIG, log, readJSON, writeJSON, sanitizePrompt, findTaskById, readComplexityReport, findTaskInComplexityReport, truncate } from './utils.js'; import { displayBanner, getStatusWithColor, formatDependenciesWithStatus, getComplexityWithColor, startLoadingIndicator, stopLoadingIndicator, createProgressBar } from './ui.js'; import { callClaude, generateSubtasks, generateSubtasksWithPerplexity, generateComplexityAnalysisPrompt } from './ai-services.js'; import { validateTaskDependencies, validateAndFixDependencies } from './dependency-manager.js'; // Initialize Anthropic client const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); // Import perplexity if available let perplexity; try { if (process.env.PERPLEXITY_API_KEY) { // Using the existing approach from ai-services.js const OpenAI = (await import('openai')).default; perplexity = new OpenAI({ apiKey: process.env.PERPLEXITY_API_KEY, baseURL: 'https://api.perplexity.ai', }); log('info', `Initialized Perplexity client with OpenAI compatibility layer`); } } catch (error) { log('warn', `Failed to initialize Perplexity client: ${error.message}`); log('warn', 'Research-backed features will not be available'); } /** * Parse a PRD file and generate tasks * @param {string} prdPath - Path to the PRD file * @param {string} tasksPath - Path to the tasks.json file * @param {number} numTasks - Number of tasks to generate (optional, will be determined automatically if not provided) */ async function parsePRD(prdPath, tasksPath, numTasks = null) { try { log('info', `Parsing PRD file: ${prdPath}`); // Read the PRD content const prdContent = fs.readFileSync(prdPath, 'utf8'); // Call Claude to generate tasks with intelligent task count determination const tasksData = await callClaudeForTasks(prdContent, prdPath, numTasks); // Create the directory if it doesn't exist const tasksDir = path.dirname(tasksPath); if (!fs.existsSync(tasksDir)) { fs.mkdirSync(tasksDir, { recursive: true }); } // Write the tasks to the file writeJSON(tasksPath, tasksData); log('success', `Successfully generated ${tasksData.tasks.length} tasks from PRD`); log('info', `Tasks saved to: ${tasksPath}`); // Generate individual task files await generateTaskFiles(tasksPath, tasksDir); console.log(boxen( chalk.green(`Successfully generated ${tasksData.tasks.length} tasks from PRD`), { padding: 1, borderColor: 'green', borderStyle: 'round' } )); console.log(boxen( chalk.white.bold('Next Steps:') + '\n\n' + `${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` + `${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks`, { padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } } )); } catch (error) { log('error', `Error parsing PRD: ${error.message}`); console.error(chalk.red(`Error: ${error.message}`)); if (CONFIG.debug) { console.error(error); } process.exit(1); } } /** * Update tasks based on new context * @param {string} tasksPath - Path to the tasks.json file * @param {number} fromId - Task ID to start updating from * @param {string} prompt - Prompt with new context * @param {boolean} useResearch - Whether to use Perplexity AI for research */ async function updateTasks(tasksPath, fromId, prompt, useResearch = false) { try { log('info', `Updating tasks from ID ${fromId} with prompt: "${prompt}"`); // Validate research flag if (useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY)) { log('warn', 'Perplexity AI is not available. Falling back to Claude AI.'); console.log(chalk.yellow('Perplexity AI is not available (API key may be missing). Falling back to Claude AI.')); useResearch = false; } // Read the tasks file const data = readJSON(tasksPath); if (!data || !data.tasks) { throw new Error(`No valid tasks found in ${tasksPath}`); } // Find tasks to update (ID >= fromId and not 'done') const tasksToUpdate = data.tasks.filter(task => task.id >= fromId && task.status !== 'done'); if (tasksToUpdate.length === 0) { log('info', `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`); console.log(chalk.yellow(`No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`)); return; } // Show the tasks that will be updated const table = new Table({ head: [ chalk.cyan.bold('ID'), chalk.cyan.bold('Title'), chalk.cyan.bold('Status') ], colWidths: [5, 60, 10] }); tasksToUpdate.forEach(task => { table.push([ task.id, truncate(task.title, 57), getStatusWithColor(task.status) ]); }); console.log(boxen( chalk.white.bold(`Updating ${tasksToUpdate.length} tasks`), { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } } )); console.log(table.toString()); // Build the system prompt const systemPrompt = `You are an AI assistant helping to update software development tasks based on new context. You will be given a set of tasks and a prompt describing changes or new implementation details. Your job is to update the tasks to reflect these changes, while preserving their basic structure. Guidelines: 1. Maintain the same IDs, statuses, and dependencies unless specifically mentioned in the prompt 2. Update titles, descriptions, details, and test strategies to reflect the new information 3. Do not change anything unnecessarily - just adapt what needs to change based on the prompt 4. You should return ALL the tasks in order, not just the modified ones 5. Return a complete valid JSON object with the updated tasks array The changes described in the prompt should be applied to ALL tasks in the list.`; const taskData = JSON.stringify(tasksToUpdate, null, 2); let updatedTasks; const loadingIndicator = startLoadingIndicator(useResearch ? 'Updating tasks with Perplexity AI research...' : 'Updating tasks with Claude AI...'); try { if (useResearch) { log('info', 'Using Perplexity AI for research-backed task updates'); // Call Perplexity AI using format consistent with ai-services.js const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro'; const result = await perplexity.chat.completions.create({ model: perplexityModel, messages: [ { role: "system", content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating these tasks. Use your online search capabilities to gather relevant information.` }, { role: "user", content: `Here are the tasks to update: ${taskData} Please update these tasks based on the following new context: ${prompt} Return only the updated tasks as a valid JSON array.` } ], temperature: parseFloat(process.env.TEMPERATURE || CONFIG.temperature), max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens), }); const responseText = result.choices[0].message.content; // Extract JSON from response const jsonStart = responseText.indexOf('['); const jsonEnd = responseText.lastIndexOf(']'); if (jsonStart === -1 || jsonEnd === -1) { throw new Error("Could not find valid JSON array in Perplexity's response"); } const jsonText = responseText.substring(jsonStart, jsonEnd + 1); updatedTasks = JSON.parse(jsonText); } else { // Call Claude to update the tasks with streaming enabled let responseText = ''; let streamingInterval = null; try { // Update loading indicator to show streaming progress let dotCount = 0; const readline = await import('readline'); streamingInterval = setInterval(() => { readline.cursorTo(process.stdout, 0); process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`); dotCount = (dotCount + 1) % 4; }, 500); // Use streaming API call const stream = await anthropic.messages.create({ model: CONFIG.model, max_tokens: CONFIG.maxTokens, temperature: CONFIG.temperature, system: systemPrompt, messages: [ { role: 'user', content: `Here are the tasks to update: ${taskData} Please update these tasks based on the following new context: ${prompt} Return only the updated tasks as a valid JSON array.` } ], stream: true }); // Process the stream for await (const chunk of stream) { if (chunk.type === 'content_block_delta' && chunk.delta.text) { responseText += chunk.delta.text; } } if (streamingInterval) clearInterval(streamingInterval); log('info', "Completed streaming response from Claude API!"); // Extract JSON from response const jsonStart = responseText.indexOf('['); const jsonEnd = responseText.lastIndexOf(']'); if (jsonStart === -1 || jsonEnd === -1) { throw new Error("Could not find valid JSON array in Claude's response"); } const jsonText = responseText.substring(jsonStart, jsonEnd + 1); updatedTasks = JSON.parse(jsonText); } catch (error) { if (streamingInterval) clearInterval(streamingInterval); throw error; } } // Replace the tasks in the original data updatedTasks.forEach(updatedTask => { const index = data.tasks.findIndex(t => t.id === updatedTask.id); if (index !== -1) { data.tasks[index] = updatedTask; } }); // Write the updated tasks to the file writeJSON(tasksPath, data); log('success', `Successfully updated ${updatedTasks.length} tasks`); // Generate individual task files await generateTaskFiles(tasksPath, path.dirname(tasksPath)); console.log(boxen( chalk.green(`Successfully updated ${updatedTasks.length} tasks`), { padding: 1, borderColor: 'green', borderStyle: 'round' } )); } finally { stopLoadingIndicator(loadingIndicator); } } catch (error) { log('error', `Error updating tasks: ${error.message}`); console.error(chalk.red(`Error: ${error.message}`)); if (CONFIG.debug) { console.error(error); } process.exit(1); } } /** * Configure the output format for task files * @param {string} tasksPath - Path to tasks.json * @param {string} outputDir - Output directory * @param {Object} options - Additional options * @param {string} options.format - Output format (markdown, json, yaml) * @returns {Promise<Object>} Result object */ async function generateTaskFiles(tasksPath, outputDir, options = {}) { try { const { format = 'markdown' } = options; log('info', `Generating task files from ${tasksPath} in ${format} format`); // Ensure output directory exists await fs.mkdir(outputDir, { recursive: true }); // Load tasks const tasksData = await readJSON(tasksPath); if (!tasksData.tasks || !Array.isArray(tasksData.tasks)) { throw new Error('Invalid tasks data: missing tasks array'); } // Generate a file for each task const results = await Promise.all(tasksData.tasks.map(task => { return generateTaskFile(task, outputDir, { format }); })); log('success', `Generated ${results.length} task files in ${outputDir}`); console.log(chalk.green(`Generated ${results.length} task files in ${outputDir}`)); return { totalTasks: results.length, outputDir, format }; } catch (error) { log('error', `Failed to generate task files: ${error.message}`); console.error(chalk.red(`Failed to generate task files: ${error.message}`)); if (CONFIG.debug) { console.error(error); } throw error; } } /** * Generate a single task file * @param {Object} task - Task object * @param {string} outputDir - Output directory * @param {Object} options - Additional options * @param {string} options.format - Output format (markdown, json, yaml) * @returns {Promise<Object>} Result object */ async function generateTaskFile(task, outputDir, options = {}) { const { format = 'markdown' } = options; try { // Create a sanitized filename from the task title const filename = sanitizeFilename(task.title) .toLowerCase() .replace(/\s+/g, '-'); let extension; let content; // Generate content based on format switch (format.toLowerCase()) { case 'json': extension = '.json'; content = JSON.stringify(task, null, 2); break; case 'yaml': case 'yml': const yaml = (await import('js-yaml')).default; extension = '.yaml'; content = yaml.dump(task); break; case 'markdown': case 'md': default: extension = '.md'; content = generateTaskMarkdown(task); break; } // Build the filepath const filepath = path.join(outputDir, `task_${String(task.id).padStart(3, '0')}-${filename}${extension}`); // Write the file await fs.writeFile(filepath, content, 'utf8'); log('info', `Created task file: ${filepath}`); return { task, filepath, format }; } catch (error) { log('error', `Failed to generate file for task ${task.id}: ${error.message}`); throw error; } } /** * Generate markdown content for a task * @param {Object} task - Task object * @returns {string} Markdown content */ function generateTaskMarkdown(task) { let content = `# Task: ${task.title}\n\n`; // Add task ID and status content += `**ID:** ${task.id}\n`; content += `**Status:** ${task.status || 'pending'}\n`; // Add dependencies if any if (task.dependencies && task.dependencies.length > 0) { content += `**Dependencies:** ${task.dependencies.join(', ')}\n`; } // Add priority if available if (task.priority) { content += `**Priority:** ${task.priority}\n`; } // Add estimated time if available if (task.estimatedTime) { content += `**Estimated Time:** ${task.estimatedTime}\n`; } // Add description and details content += '\n## Description\n\n'; content += `${task.description}\n\n`; if (task.details) { content += '## Implementation Details\n\n'; content += `${task.details}\n\n`; } // Add acceptance criteria if available if (task.acceptanceCriteria && task.acceptanceCriteria.length > 0) { content += '## Acceptance Criteria\n\n'; task.acceptanceCriteria.forEach((criterion, index) => { content += `${index + 1}. ${criterion}\n`; }); content += '\n'; } // Add subtasks if available if (task.subtasks && task.subtasks.length > 0) { content += '## Subtasks\n\n'; task.subtasks.forEach(subtask => { content += `### ${subtask.title}\n\n`; content += `**Status:** ${subtask.status || 'pending'}\n`; if (subtask.description) { content += `\n${subtask.description}\n\n`; } if (subtask.details) { content += `**Details:** ${subtask.details}\n\n`; } }); } // Add notes if available if (task.notes) { content += '## Notes\n\n'; content += `${task.notes}\n\n`; } // Add metadata in hidden comment for programmatic access content += `\n<!-- Task Metadata\n\`\`\`json\n${JSON.stringify(task, null, 2)}\n\`\`\`\n-->\n`; return content; } /** * Set the status of a task * @param {string} tasksPath - Path to the tasks.json file * @param {string} taskIdInput - Task ID(s) to update * Sanitize a string for use as a filename * @param {string} name - Input string * @returns {string} Sanitized string */ function sanitizeFilename(name) { return name .replace(/[\/\\?%*:|"<>]/g, '-') // Replace invalid chars with dash .replace(/\s+/g, ' ') // Collapse multiple spaces .trim(); // Remove leading/trailing spaces } /** * Analyze story elements for plot holes and inconsistencies * @param {string} elementsPath - Path to the elements JSON file * @param {Array} elementIds - Optional array of element IDs to analyze * @returns {Object} Analysis result */ async function analyzeForPlotHoles(elementsPath, elementIds = []) { try { const storyData = await loadElementsFile(elementsPath); if (!storyData || !storyData.elements || storyData.elements.length === 0) { throw new Error(`No elements found in ${elementsPath}`); } // Call the AI service to analyze for plot holes const analysisResult = await aiServices.analyzePlotHoles(storyData, elementIds); // Save the analysis to a file const outputPath = path.join(path.dirname(elementsPath), 'plot-analysis.json'); await saveObjectToJsonFile(analysisResult, outputPath); // Generate a markdown report await generatePlotAnalysisReport(analysisResult, storyData, path.join(path.dirname(elementsPath), 'plot-analysis.md')); return { status: 'success', message: `Plot hole analysis completed. Found ${analysisResult.plotHoles.length} potential issues.`, outputPath, analysisResult }; } catch (error) { log('error', `Failed to analyze for plot holes: ${error.message}`); throw error; } } /** * Generate a markdown report from the plot hole analysis * @param {Object} analysis - Plot hole analysis result * @param {Object} storyData - Original story data * @param {string} outputPath - Path to save the report */ async function generatePlotAnalysisReport(analysis, storyData, outputPath) { try { // Create a map of elements by ID for easy reference const elementsById = new Map(storyData.elements.map(el => [el.id, el])); // Build the report content let reportContent = `# Story Plot Analysis Report\n\n`; reportContent += `*Generated on ${new Date().toLocaleString()}*\n\n`; // Add character consistency section reportContent += `## Character Consistency\n\n`; reportContent += `${analysis.characterConsistency || 'No character consistency analysis available.'}\n\n`; // Add plot holes section reportContent += `## Plot Holes and Inconsistencies\n\n`; if (analysis.plotHoles && analysis.plotHoles.length > 0) { analysis.plotHoles.forEach((issue, index) => { reportContent += `### Issue ${index + 1}: ${issue.description}\n\n`; // Add affected elements reportContent += `**Affected Elements:**\n\n`; if (issue.affectedElements && issue.affectedElements.length > 0) { issue.affectedElements.forEach(elementId => { const element = elementsById.get(elementId); if (element) { reportContent += `- **Element ${elementId}:** ${element.title}\n`; } else { reportContent += `- **Element ${elementId}:** (Not found)\n`; } }); } else { reportContent += `- No specific elements identified\n`; } reportContent += `\n**Suggested Fix:**\n\n${issue.suggestedFix}\n\n`; reportContent += `---\n\n`; }); } else { reportContent += `*No significant plot holes or inconsistencies found.*\n\n`; } // Add general suggestions section reportContent += `## General Suggestions\n\n`; if (analysis.suggestions && analysis.suggestions.length > 0) { analysis.suggestions.forEach((suggestion, index) => { reportContent += `${index + 1}. ${suggestion}\n`; }); } else { reportContent += `*No general suggestions provided.*\n`; } // Write the report to file await fs.writeFile(outputPath, reportContent, 'utf8'); log('info', `Plot analysis report saved to ${outputPath}`); } catch (error) { log('error', `Failed to generate plot analysis report: ${error.message}`); // Don't throw, as this is a non-critical function } } /** * Refine a story through structured development phases * @param {string} elementsPath - Path to elements JSON file * @param {string} conceptPath - Optional path to concept file * @param {string} phase - Refinement phase (world, characters, plot, themes, all) * @param {Object} options - Additional options for refinement * @returns {Object} Refinement result */ async function refineStory(elementsPath, conceptPath, phase = 'all', options = {}) { try { // Prepare story data from available sources const storyData = { elements: null, concept: null }; // Load elements if available if (elementsPath) { try { const loadedElements = await loadElementsFile(elementsPath); if (loadedElements && loadedElements.elements) { storyData.elements = loadedElements.elements; log('info', `Loaded ${storyData.elements.length} elements from ${elementsPath}`); } } catch (elemError) { log('warning', `Could not load elements file: ${elemError.message}`); // Continue even if elements aren't available } } // Load concept if available if (conceptPath) { try { storyData.concept = await fs.readFile(conceptPath, 'utf8'); log('info', `Loaded concept from ${conceptPath}`); } catch (conceptError) { log('warning', `Could not load concept file: ${conceptError.message}`); // Continue even if concept isn't available } } // Ensure we have something to work with if (!storyData.elements && !storyData.concept) { throw new Error('No elements or concept file available for refinement'); } // Check if we're refining all phases if (phase === 'all') { // Process all phases in sequence const phases = ['world', 'characters', 'plot', 'themes']; let results = {}; for (const currentPhase of phases) { log('info', `Refining phase: ${currentPhase}`); // Refine the current phase const phaseResult = await aiServices.refineStoryPhase(storyData, currentPhase, options); // Update story data with refined results for next phase if (phaseResult.refinedConcept) { storyData.concept = phaseResult.refinedConcept; } if (phaseResult.updatedElements) { storyData.elements = phaseResult.updatedElements; } // Store results results[currentPhase] = phaseResult; } // Save final results if (storyData.elements) { const saveResult = await saveElements(storyData.elements, elementsPath); log('info', saveResult.message); } if (storyData.concept && conceptPath) { const refinedConceptPath = path.join( path.dirname(conceptPath), `${path.basename(conceptPath, path.extname(conceptPath))}_refined${path.extname(conceptPath)}` ); await fs.writeFile(refinedConceptPath, storyData.concept, 'utf8'); log('info', `Refined concept saved to ${refinedConceptPath}`); } // Generate refinement report await generateRefinementReport(results, path.join(path.dirname(elementsPath || conceptPath), 'refinement-report.md')); return { status: 'success', message: 'Story refinement completed for all phases', phases: Object.keys(results) }; } else { // Process single phase const result = await aiServices.refineStoryPhase(storyData, phase, options); // Save results if (result.updatedElements && elementsPath) { const saveResult = await saveElements(result.updatedElements, elementsPath); log('info', saveResult.message); } if (result.refinedConcept && conceptPath) { const refinedConceptPath = path.join( path.dirname(conceptPath), `${path.basename(conceptPath, path.extname(conceptPath))}_refined${path.extname(conceptPath)}` ); await fs.writeFile(refinedConceptPath, result.refinedConcept, 'utf8'); log('info', `Refined concept saved to ${refinedConceptPath}`); } // Generate single phase report const singlePhaseResults = { [phase]: result }; await generateRefinementReport(singlePhaseResults, path.join(path.dirname(elementsPath || conceptPath), `refinement-${phase}.md`)); return { status: 'success', message: `Story refinement completed for phase: ${phase}`, phase, result }; } } catch (error) { log('error', `Failed to refine story: ${error.message}`); throw error; } } /** * Generate a markdown report from the refinement results * @param {Object} results - Object containing refinement results for each phase * @param {string} outputPath - Path to save the report */ async function generateRefinementReport(results, outputPath) { try { // Build the report content let reportContent = `# Story Refinement Report\n\n`; reportContent += `*Generated on ${new Date().toLocaleString()}*\n\n`; // Process each phase for (const [phase, result] of Object.entries(results)) { reportContent += `## ${phase.charAt(0).toUpperCase() + phase.slice(1)} Phase Refinement\n\n`; if (result.summary) { reportContent += `### Summary\n\n${result.summary}\n\n`; } if (result.suggestions && result.suggestions.length > 0) { reportContent += `### Suggestions\n\n`; result.suggestions.forEach((suggestion, index) => { reportContent += `${index + 1}. ${suggestion}\n`; }); reportContent += `\n`; } if (result.updatedElements) { reportContent += `### Updated Elements\n\n`; reportContent += `${result.updatedElements.length} elements were updated or created.\n\n`; } reportContent += `---\n\n`; } // Write the report to file await fs.writeFile(outputPath, reportContent, 'utf8'); log('info', `Refinement report saved to ${outputPath}`); } catch (error) { log('error', `Failed to generate refinement report: ${error.message}`); // Don't throw, as this is a non-critical function } } // Export task manager functions export { parsePRD, updateTasks, generateTaskFiles, setTaskStatus, updateSingleTaskStatus, listTasks, expandTask, expandAllTasks, clearSubtasks, addTask, addSubtask, removeSubtask, findNextTask, analyzeTaskComplexity, parseConcept, generateElementFiles, generateElementFile, analyzeForPlotHoles, refineStory };