feat(research): Add comprehensive AI-powered research command with interactive follow-ups, save functionality, intelligent context gathering, fuzzy task discovery, multi-source context support, enhanced display with syntax highlighting, clean inquirer menus, comprehensive help, and MCP integration with saveTo parameter
This commit is contained in:
@@ -380,21 +380,6 @@ async function addTask(
|
||||
displayContextAnalysis(analysisData, prompt, gatheredContext.length);
|
||||
}
|
||||
|
||||
if (outputFormat === 'text') {
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.white.bold('AI Task Generation') +
|
||||
`\n\n${chalk.gray('Analyzing context and generating task details using AI...')}`,
|
||||
{
|
||||
padding: { top: 0, bottom: 1, left: 1, right: 1 },
|
||||
margin: { top: 1, bottom: 0 },
|
||||
borderColor: 'white',
|
||||
borderStyle: 'round'
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// System Prompt - Enhanced for dependency awareness
|
||||
const systemPrompt =
|
||||
"You are a helpful assistant that creates well-structured tasks for a software development project. Generate a single new task based on the user's description, adhering strictly to the provided JSON schema. Pay special attention to dependencies between tasks, ensuring the new task correctly references any tasks it depends on.\n\n" +
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Core research functionality for AI-powered queries with project context
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import boxen from 'boxen';
|
||||
@@ -587,7 +588,7 @@ function displayResearchResults(result, query, detailLevel, tokenBreakdown) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle follow-up questions in interactive mode
|
||||
* Handle follow-up questions and save functionality in interactive mode
|
||||
* @param {Object} originalOptions - Original research options
|
||||
* @param {Object} context - Execution context
|
||||
* @param {string} outputFormat - Output format
|
||||
@@ -606,82 +607,106 @@ async function handleFollowUpQuestions(
|
||||
initialResult
|
||||
) {
|
||||
try {
|
||||
// Import required modules for saving
|
||||
const { readJSON } = await import('../utils.js');
|
||||
const updateTaskById = (await import('./update-task-by-id.js')).default;
|
||||
const { updateSubtaskById } = await import('./update-subtask-by-id.js');
|
||||
|
||||
// Initialize conversation history with the initial Q&A
|
||||
const conversationHistory = [
|
||||
{
|
||||
question: initialQuery,
|
||||
answer: initialResult,
|
||||
type: 'initial'
|
||||
type: 'initial',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
while (true) {
|
||||
// Ask if user wants to ask a follow-up question
|
||||
const { wantFollowUp } = await inquirer.prompt([
|
||||
// Get user choice
|
||||
const { action } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'wantFollowUp',
|
||||
message: 'Would you like to ask a follow-up question?',
|
||||
default: false // Default to 'n' as requested
|
||||
type: 'list',
|
||||
name: 'action',
|
||||
message: 'What would you like to do next?',
|
||||
choices: [
|
||||
{ name: 'Ask a follow-up question', value: 'followup' },
|
||||
{ name: 'Save to task/subtask', value: 'save' },
|
||||
{ name: 'Quit', value: 'quit' }
|
||||
],
|
||||
pageSize: 3
|
||||
}
|
||||
]);
|
||||
|
||||
if (!wantFollowUp) {
|
||||
if (action === 'quit') {
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the follow-up question
|
||||
const { followUpQuery } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'followUpQuery',
|
||||
message: 'Enter your follow-up question:',
|
||||
validate: (input) => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return 'Please enter a valid question.';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
if (!followUpQuery || followUpQuery.trim().length === 0) {
|
||||
if (action === 'save') {
|
||||
// Handle save functionality
|
||||
await handleSaveToTask(
|
||||
conversationHistory,
|
||||
projectRoot,
|
||||
context,
|
||||
logFn
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('\n' + chalk.gray('─'.repeat(60)) + '\n');
|
||||
if (action === 'followup') {
|
||||
// Get the follow-up question
|
||||
const { followUpQuery } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'followUpQuery',
|
||||
message: 'Enter your follow-up question:',
|
||||
validate: (input) => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return 'Please enter a valid question.';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Build cumulative conversation context from all previous exchanges
|
||||
const conversationContext = buildConversationContext(conversationHistory);
|
||||
if (!followUpQuery || followUpQuery.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create enhanced options for follow-up with full conversation context
|
||||
// Remove explicit task IDs to allow fresh fuzzy search based on new question
|
||||
const followUpOptions = {
|
||||
...originalOptions,
|
||||
taskIds: [], // Clear task IDs to allow fresh fuzzy search
|
||||
customContext:
|
||||
conversationContext +
|
||||
(originalOptions.customContext
|
||||
? `\n\n--- Original Context ---\n${originalOptions.customContext}`
|
||||
: '')
|
||||
};
|
||||
console.log('\n' + chalk.gray('─'.repeat(60)) + '\n');
|
||||
|
||||
// Perform follow-up research with fresh fuzzy search and conversation context
|
||||
// Disable follow-up prompts for nested calls to prevent infinite recursion
|
||||
const followUpResult = await performResearch(
|
||||
followUpQuery.trim(),
|
||||
followUpOptions,
|
||||
context,
|
||||
outputFormat,
|
||||
false // allowFollowUp = false for nested calls
|
||||
);
|
||||
// Build cumulative conversation context from all previous exchanges
|
||||
const conversationContext =
|
||||
buildConversationContext(conversationHistory);
|
||||
|
||||
// Add this exchange to the conversation history
|
||||
conversationHistory.push({
|
||||
question: followUpQuery.trim(),
|
||||
answer: followUpResult.result,
|
||||
type: 'followup'
|
||||
});
|
||||
// Create enhanced options for follow-up with full conversation context
|
||||
const followUpOptions = {
|
||||
...originalOptions,
|
||||
taskIds: [], // Clear task IDs to allow fresh fuzzy search
|
||||
customContext:
|
||||
conversationContext +
|
||||
(originalOptions.customContext
|
||||
? `\n\n--- Original Context ---\n${originalOptions.customContext}`
|
||||
: '')
|
||||
};
|
||||
|
||||
// Perform follow-up research
|
||||
const followUpResult = await performResearch(
|
||||
followUpQuery.trim(),
|
||||
followUpOptions,
|
||||
context,
|
||||
outputFormat,
|
||||
false // allowFollowUp = false for nested calls
|
||||
);
|
||||
|
||||
// Add this exchange to the conversation history
|
||||
conversationHistory.push({
|
||||
question: followUpQuery.trim(),
|
||||
answer: followUpResult.result,
|
||||
type: 'followup',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If there's an error with inquirer (e.g., non-interactive terminal),
|
||||
@@ -690,6 +715,173 @@ async function handleFollowUpQuestions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle saving conversation to a task or subtask
|
||||
* @param {Array} conversationHistory - Array of conversation exchanges
|
||||
* @param {string} projectRoot - Project root directory
|
||||
* @param {Object} context - Execution context
|
||||
* @param {Object} logFn - Logger function
|
||||
*/
|
||||
async function handleSaveToTask(
|
||||
conversationHistory,
|
||||
projectRoot,
|
||||
context,
|
||||
logFn
|
||||
) {
|
||||
try {
|
||||
// Import required modules
|
||||
const { readJSON } = await import('../utils.js');
|
||||
const updateTaskById = (await import('./update-task-by-id.js')).default;
|
||||
const { updateSubtaskById } = await import('./update-subtask-by-id.js');
|
||||
|
||||
// Get task ID from user
|
||||
const { taskId } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'taskId',
|
||||
message: 'Enter task ID (e.g., "15" for task or "15.2" for subtask):',
|
||||
validate: (input) => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return 'Please enter a task ID.';
|
||||
}
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
// Validate format: number or number.number
|
||||
if (!/^\d+(\.\d+)?$/.test(trimmedInput)) {
|
||||
return 'Invalid format. Use "15" for task or "15.2" for subtask.';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const trimmedTaskId = taskId.trim();
|
||||
|
||||
// Format conversation thread for saving
|
||||
const conversationThread = formatConversationForSaving(conversationHistory);
|
||||
|
||||
// Determine if it's a task or subtask
|
||||
const isSubtask = trimmedTaskId.includes('.');
|
||||
|
||||
// Try to save - first validate the ID exists
|
||||
const tasksPath = path.join(
|
||||
projectRoot,
|
||||
'.taskmaster',
|
||||
'tasks',
|
||||
'tasks.json'
|
||||
);
|
||||
|
||||
if (!fs.existsSync(tasksPath)) {
|
||||
console.log(
|
||||
chalk.red('❌ Tasks file not found. Please run task-master init first.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ID exists
|
||||
const data = readJSON(tasksPath, projectRoot);
|
||||
if (!data || !data.tasks) {
|
||||
console.log(chalk.red('❌ No valid tasks found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubtask) {
|
||||
// Validate subtask exists
|
||||
const [parentId, subtaskId] = trimmedTaskId
|
||||
.split('.')
|
||||
.map((id) => parseInt(id, 10));
|
||||
const parentTask = data.tasks.find((t) => t.id === parentId);
|
||||
|
||||
if (!parentTask) {
|
||||
console.log(chalk.red(`❌ Parent task ${parentId} not found.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!parentTask.subtasks ||
|
||||
!parentTask.subtasks.find((st) => st.id === subtaskId)
|
||||
) {
|
||||
console.log(chalk.red(`❌ Subtask ${trimmedTaskId} not found.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to subtask using updateSubtaskById
|
||||
console.log(chalk.blue('💾 Saving research conversation to subtask...'));
|
||||
|
||||
await updateSubtaskById(
|
||||
tasksPath,
|
||||
trimmedTaskId,
|
||||
conversationThread,
|
||||
false, // useResearch = false for simple append
|
||||
context,
|
||||
'text'
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✅ Research conversation saved to subtask ${trimmedTaskId}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Validate task exists
|
||||
const taskIdNum = parseInt(trimmedTaskId, 10);
|
||||
const task = data.tasks.find((t) => t.id === taskIdNum);
|
||||
|
||||
if (!task) {
|
||||
console.log(chalk.red(`❌ Task ${trimmedTaskId} not found.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to task using updateTaskById with append mode
|
||||
console.log(chalk.blue('💾 Saving research conversation to task...'));
|
||||
|
||||
await updateTaskById(
|
||||
tasksPath,
|
||||
taskIdNum,
|
||||
conversationThread,
|
||||
false, // useResearch = false for simple append
|
||||
context,
|
||||
'text',
|
||||
true // appendMode = true
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.green(`✅ Research conversation saved to task ${trimmedTaskId}`)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(chalk.red(`❌ Error saving conversation: ${error.message}`));
|
||||
logFn.error(`Error saving conversation: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format conversation history for saving to a task/subtask
|
||||
* @param {Array} conversationHistory - Array of conversation exchanges
|
||||
* @returns {string} Formatted conversation thread
|
||||
*/
|
||||
function formatConversationForSaving(conversationHistory) {
|
||||
const timestamp = new Date().toISOString();
|
||||
let formatted = `## Research Session - ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}\n\n`;
|
||||
|
||||
conversationHistory.forEach((exchange, index) => {
|
||||
if (exchange.type === 'initial') {
|
||||
formatted += `**Initial Query:** ${exchange.question}\n\n`;
|
||||
formatted += `**Response:** ${exchange.answer}\n\n`;
|
||||
} else {
|
||||
formatted += `**Follow-up ${index}:** ${exchange.question}\n\n`;
|
||||
formatted += `**Response:** ${exchange.answer}\n\n`;
|
||||
}
|
||||
|
||||
if (index < conversationHistory.length - 1) {
|
||||
formatted += '---\n\n';
|
||||
}
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build conversation context string from conversation history
|
||||
* @param {Array} conversationHistory - Array of conversation exchanges
|
||||
|
||||
@@ -201,16 +201,18 @@ function parseUpdatedTaskFromText(text, expectedTaskId, logFn, isMCP) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single task by ID using the unified AI service.
|
||||
* Update a task by ID with new information using the unified AI service.
|
||||
* @param {string} tasksPath - Path to the tasks.json file
|
||||
* @param {number} taskId - Task ID to update
|
||||
* @param {string} prompt - Prompt with new context
|
||||
* @param {number} taskId - ID of the task to update
|
||||
* @param {string} prompt - Prompt for generating updated task information
|
||||
* @param {boolean} [useResearch=false] - Whether to use the research AI role.
|
||||
* @param {Object} context - Context object containing session and mcpLog.
|
||||
* @param {Object} [context.session] - Session object from MCP server.
|
||||
* @param {Object} [context.mcpLog] - MCP logger object.
|
||||
* @param {string} [context.projectRoot] - Project root path.
|
||||
* @param {string} [outputFormat='text'] - Output format ('text' or 'json').
|
||||
* @returns {Promise<Object|null>} - Updated task data or null if task wasn't updated/found.
|
||||
* @param {boolean} [appendMode=false] - If true, append to details instead of full update.
|
||||
* @returns {Promise<Object|null>} - The updated task or null if update failed.
|
||||
*/
|
||||
async function updateTaskById(
|
||||
tasksPath,
|
||||
@@ -218,7 +220,8 @@ async function updateTaskById(
|
||||
prompt,
|
||||
useResearch = false,
|
||||
context = {},
|
||||
outputFormat = 'text'
|
||||
outputFormat = 'text',
|
||||
appendMode = false
|
||||
) {
|
||||
const { session, mcpLog, projectRoot: providedProjectRoot } = context;
|
||||
const logFn = mcpLog || consoleLog;
|
||||
@@ -388,8 +391,41 @@ async function updateTaskById(
|
||||
);
|
||||
}
|
||||
|
||||
// --- Build Prompts (Keep EXACT original prompts) ---
|
||||
const systemPrompt = `You are an AI assistant helping to update a software development task based on new context.
|
||||
// --- Build Prompts (Different for append vs full update) ---
|
||||
let systemPrompt;
|
||||
let userPrompt;
|
||||
|
||||
if (appendMode) {
|
||||
// Append mode: generate new content to add to task details
|
||||
systemPrompt = `You are an AI assistant helping to append additional information to a software development task. You will be provided with the task's existing details, context, and a user request string.
|
||||
|
||||
Your Goal: Based *only* on the user's request and all the provided context (including existing details if relevant to the request), GENERATE the new text content that should be added to the task's details.
|
||||
Focus *only* on generating the substance of the update.
|
||||
|
||||
Output Requirements:
|
||||
1. Return *only* the newly generated text content as a plain string. Do NOT return a JSON object or any other structured data.
|
||||
2. Your string response should NOT include any of the task's original details, unless the user's request explicitly asks to rephrase, summarize, or directly modify existing text.
|
||||
3. Do NOT include any timestamps, XML-like tags, markdown, or any other special formatting in your string response.
|
||||
4. Ensure the generated text is concise yet complete for the update based on the user request. Avoid conversational fillers or explanations about what you are doing (e.g., do not start with "Okay, here's the update...").`;
|
||||
|
||||
const taskContext = `
|
||||
Task: ${JSON.stringify({
|
||||
id: taskToUpdate.id,
|
||||
title: taskToUpdate.title,
|
||||
description: taskToUpdate.description,
|
||||
status: taskToUpdate.status
|
||||
})}
|
||||
Current Task Details (for context only):\n${taskToUpdate.details || '(No existing details)'}
|
||||
`;
|
||||
|
||||
userPrompt = `Task Context:\n${taskContext}\n\nUser Request: "${prompt}"\n\nBased on the User Request and all the Task Context (including current task details provided above), what is the new information or text that should be appended to this task's details? Return ONLY this new text as a plain string.`;
|
||||
|
||||
if (gatheredContext) {
|
||||
userPrompt += `\n\n# Additional Project Context\n\n${gatheredContext}`;
|
||||
}
|
||||
} else {
|
||||
// Full update mode: use original prompts
|
||||
systemPrompt = `You are an AI assistant helping to update a software development task based on new context.
|
||||
You will be given a task and a prompt describing changes or new implementation details.
|
||||
Your job is to update the task to reflect these changes, while preserving its basic structure.
|
||||
|
||||
@@ -408,14 +444,15 @@ Guidelines:
|
||||
|
||||
The changes described in the prompt should be thoughtfully applied to make the task more accurate and actionable.`;
|
||||
|
||||
const taskDataString = JSON.stringify(taskToUpdate, null, 2); // Use original task data
|
||||
let userPrompt = `Here is the task to update:\n${taskDataString}\n\nPlease update this task based on the following new context:\n${prompt}\n\nIMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items.`;
|
||||
const taskDataString = JSON.stringify(taskToUpdate, null, 2);
|
||||
userPrompt = `Here is the task to update:\n${taskDataString}\n\nPlease update this task based on the following new context:\n${prompt}\n\nIMPORTANT: In the task JSON above, any subtasks with "status": "done" or "status": "completed" should be preserved exactly as is. Build your changes around these completed items.`;
|
||||
|
||||
if (gatheredContext) {
|
||||
userPrompt += `\n\n# Project Context\n\n${gatheredContext}`;
|
||||
if (gatheredContext) {
|
||||
userPrompt += `\n\n# Project Context\n\n${gatheredContext}`;
|
||||
}
|
||||
|
||||
userPrompt += `\n\nReturn only the updated task as a valid JSON object.`;
|
||||
}
|
||||
|
||||
userPrompt += `\n\nReturn only the updated task as a valid JSON object.`;
|
||||
// --- End Build Prompts ---
|
||||
|
||||
let loadingIndicator = null;
|
||||
@@ -442,7 +479,72 @@ The changes described in the prompt should be thoughtfully applied to make the t
|
||||
if (loadingIndicator)
|
||||
stopLoadingIndicator(loadingIndicator, 'AI update complete.');
|
||||
|
||||
// Use mainResult (text) for parsing
|
||||
if (appendMode) {
|
||||
// Append mode: handle as plain text
|
||||
const generatedContentString = aiServiceResponse.mainResult;
|
||||
let newlyAddedSnippet = '';
|
||||
|
||||
if (generatedContentString && generatedContentString.trim()) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedBlock = `<info added on ${timestamp}>\n${generatedContentString.trim()}\n</info added on ${timestamp}>`;
|
||||
newlyAddedSnippet = formattedBlock;
|
||||
|
||||
// Append to task details
|
||||
taskToUpdate.details =
|
||||
(taskToUpdate.details ? taskToUpdate.details + '\n' : '') +
|
||||
formattedBlock;
|
||||
} else {
|
||||
report(
|
||||
'warn',
|
||||
'AI response was empty or whitespace after trimming. Original details remain unchanged.'
|
||||
);
|
||||
newlyAddedSnippet = 'No new details were added by the AI.';
|
||||
}
|
||||
|
||||
// Update description with timestamp if prompt is short
|
||||
if (prompt.length < 100) {
|
||||
if (taskToUpdate.description) {
|
||||
taskToUpdate.description += ` [Updated: ${new Date().toLocaleDateString()}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the updated task back to file
|
||||
data.tasks[taskIndex] = taskToUpdate;
|
||||
writeJSON(tasksPath, data);
|
||||
report('success', `Successfully appended to task ${taskId}`);
|
||||
|
||||
// Display success message for CLI
|
||||
if (outputFormat === 'text') {
|
||||
console.log(
|
||||
boxen(
|
||||
chalk.green(`Successfully appended to task #${taskId}`) +
|
||||
'\n\n' +
|
||||
chalk.white.bold('Title:') +
|
||||
' ' +
|
||||
taskToUpdate.title +
|
||||
'\n\n' +
|
||||
chalk.white.bold('Newly Added Content:') +
|
||||
'\n' +
|
||||
chalk.white(newlyAddedSnippet),
|
||||
{ padding: 1, borderColor: 'green', borderStyle: 'round' }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display AI usage telemetry for CLI users
|
||||
if (outputFormat === 'text' && aiServiceResponse.telemetryData) {
|
||||
displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
|
||||
}
|
||||
|
||||
// Return the updated task
|
||||
return {
|
||||
updatedTask: taskToUpdate,
|
||||
telemetryData: aiServiceResponse.telemetryData,
|
||||
tagInfo: aiServiceResponse.tagInfo
|
||||
};
|
||||
}
|
||||
|
||||
// Full update mode: Use mainResult (text) for parsing
|
||||
const updatedTask = parseUpdatedTaskFromText(
|
||||
aiServiceResponse.mainResult,
|
||||
taskId,
|
||||
|
||||
Reference in New Issue
Block a user