feat: implement research command with enhanced context gathering - Add comprehensive research command with AI-powered queries - Implement ContextGatherer utility for reusable context extraction - Support multiple context types: tasks, files, custom text, project tree - Add fuzzy search integration for automatic task discovery - Implement detailed token breakdown display with syntax highlighting - Add enhanced UI with boxed output and code block formatting - Support different detail levels (low, medium, high) for responses - Include project-specific context for more relevant AI responses - Add token counting with gpt-tokens library integration - Create reusable patterns for future context-aware commands - Task 94.4 completed
This commit is contained in:
@@ -1374,6 +1374,244 @@ function registerCommands(programInstance) {
|
||||
await analyzeTaskComplexity(options);
|
||||
});
|
||||
|
||||
// research command
|
||||
programInstance
|
||||
.command('research')
|
||||
.description('Perform AI-powered research queries with project context')
|
||||
.argument('<prompt>', 'Research prompt to investigate')
|
||||
.option('--file <file>', 'Path to the tasks file', 'tasks/tasks.json')
|
||||
.option(
|
||||
'-i, --id <ids>',
|
||||
'Comma-separated task/subtask IDs to include as context (e.g., "15,16.2")'
|
||||
)
|
||||
.option(
|
||||
'-f, --files <paths>',
|
||||
'Comma-separated file paths to include as context'
|
||||
)
|
||||
.option(
|
||||
'-c, --context <text>',
|
||||
'Additional custom context to include in the research prompt'
|
||||
)
|
||||
.option(
|
||||
'--project-tree',
|
||||
'Include project file tree structure in the research context'
|
||||
)
|
||||
.option(
|
||||
'-s, --save <file>',
|
||||
'Save research results to the specified task/subtask(s)'
|
||||
)
|
||||
.option(
|
||||
'-d, --detail <level>',
|
||||
'Output detail level: low, medium, high',
|
||||
'medium'
|
||||
)
|
||||
.action(async (prompt, options) => {
|
||||
// Parameter validation
|
||||
if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
|
||||
console.error(
|
||||
chalk.red('Error: Research prompt is required and cannot be empty')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate detail level
|
||||
const validDetailLevels = ['low', 'medium', 'high'];
|
||||
if (
|
||||
options.detail &&
|
||||
!validDetailLevels.includes(options.detail.toLowerCase())
|
||||
) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Error: Detail level must be one of: ${validDetailLevels.join(', ')}`
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate and parse task IDs if provided
|
||||
let taskIds = [];
|
||||
if (options.id) {
|
||||
try {
|
||||
taskIds = options.id.split(',').map((id) => {
|
||||
const trimmedId = id.trim();
|
||||
// Support both task IDs (e.g., "15") and subtask IDs (e.g., "15.2")
|
||||
if (!/^\d+(\.\d+)?$/.test(trimmedId)) {
|
||||
throw new Error(
|
||||
`Invalid task ID format: "${trimmedId}". Expected format: "15" or "15.2"`
|
||||
);
|
||||
}
|
||||
return trimmedId;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error parsing task IDs: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and parse file paths if provided
|
||||
let filePaths = [];
|
||||
if (options.files) {
|
||||
try {
|
||||
filePaths = options.files.split(',').map((filePath) => {
|
||||
const trimmedPath = filePath.trim();
|
||||
if (trimmedPath.length === 0) {
|
||||
throw new Error('Empty file path provided');
|
||||
}
|
||||
return trimmedPath;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Error parsing file paths: ${error.message}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate save option if provided
|
||||
if (options.save) {
|
||||
const saveTarget = options.save.trim();
|
||||
if (saveTarget.length === 0) {
|
||||
console.error(chalk.red('Error: Save target cannot be empty'));
|
||||
process.exit(1);
|
||||
}
|
||||
// Check if it's a valid file path (basic validation)
|
||||
if (saveTarget.includes('..') || saveTarget.startsWith('/')) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Error: Save path must be relative and cannot contain ".."'
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine project root and tasks file path
|
||||
const projectRoot = findProjectRoot() || '.';
|
||||
const tasksPath =
|
||||
options.file || path.join(projectRoot, 'tasks', 'tasks.json');
|
||||
|
||||
// Validate tasks file exists if task IDs are specified
|
||||
if (taskIds.length > 0) {
|
||||
try {
|
||||
const tasksData = readJSON(tasksPath);
|
||||
if (!tasksData || !tasksData.tasks) {
|
||||
console.error(
|
||||
chalk.red(`Error: No valid tasks found in ${tasksPath}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Error reading tasks file: ${error.message}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file paths exist if specified
|
||||
if (filePaths.length > 0) {
|
||||
for (const filePath of filePaths) {
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(projectRoot, filePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.error(chalk.red(`Error: File not found: ${filePath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create validated parameters object
|
||||
const validatedParams = {
|
||||
prompt: prompt.trim(),
|
||||
taskIds: taskIds,
|
||||
filePaths: filePaths,
|
||||
customContext: options.context ? options.context.trim() : null,
|
||||
includeProjectTree: !!options.projectTree,
|
||||
saveTarget: options.save ? options.save.trim() : null,
|
||||
detailLevel: options.detail ? options.detail.toLowerCase() : 'medium',
|
||||
tasksPath: tasksPath,
|
||||
projectRoot: projectRoot
|
||||
};
|
||||
|
||||
// Display what we're about to do
|
||||
console.log(chalk.blue(`Researching: "${validatedParams.prompt}"`));
|
||||
|
||||
if (validatedParams.taskIds.length > 0) {
|
||||
console.log(
|
||||
chalk.gray(`Task context: ${validatedParams.taskIds.join(', ')}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (validatedParams.filePaths.length > 0) {
|
||||
console.log(
|
||||
chalk.gray(`File context: ${validatedParams.filePaths.join(', ')}`)
|
||||
);
|
||||
}
|
||||
|
||||
if (validatedParams.customContext) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Custom context: ${validatedParams.customContext.substring(0, 50)}${validatedParams.customContext.length > 50 ? '...' : ''}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (validatedParams.includeProjectTree) {
|
||||
console.log(chalk.gray('Including project file tree'));
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Detail level: ${validatedParams.detailLevel}`));
|
||||
|
||||
try {
|
||||
// Import the research function
|
||||
const { performResearch } = await import('./task-manager/research.js');
|
||||
|
||||
// Prepare research options
|
||||
const researchOptions = {
|
||||
taskIds: validatedParams.taskIds,
|
||||
filePaths: validatedParams.filePaths,
|
||||
customContext: validatedParams.customContext || '',
|
||||
includeProjectTree: validatedParams.includeProjectTree,
|
||||
detailLevel: validatedParams.detailLevel,
|
||||
projectRoot: validatedParams.projectRoot
|
||||
};
|
||||
|
||||
// Execute research
|
||||
const result = await performResearch(
|
||||
validatedParams.prompt,
|
||||
researchOptions,
|
||||
{
|
||||
commandName: 'research',
|
||||
outputType: 'cli'
|
||||
},
|
||||
'text'
|
||||
);
|
||||
|
||||
// Save results if requested
|
||||
if (validatedParams.saveTarget) {
|
||||
const saveContent = `# Research Query: ${validatedParams.prompt}
|
||||
|
||||
**Detail Level:** ${result.detailLevel}
|
||||
**Context Size:** ${result.contextSize} characters
|
||||
**Timestamp:** ${new Date().toISOString()}
|
||||
|
||||
## Results
|
||||
|
||||
${result.result}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(validatedParams.saveTarget, saveContent, 'utf-8');
|
||||
console.log(
|
||||
chalk.green(`\n💾 Results saved to: ${validatedParams.saveTarget}`)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`\n❌ Research failed: ${error.message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// clear-subtasks command
|
||||
programInstance
|
||||
.command('clear-subtasks')
|
||||
|
||||
@@ -24,6 +24,7 @@ import removeTask from './task-manager/remove-task.js';
|
||||
import taskExists from './task-manager/task-exists.js';
|
||||
import isTaskDependentOn from './task-manager/is-task-dependent.js';
|
||||
import moveTask from './task-manager/move-task.js';
|
||||
import { performResearch } from './task-manager/research.js';
|
||||
import { readComplexityReport } from './utils.js';
|
||||
// Export task manager functions
|
||||
export {
|
||||
@@ -48,5 +49,6 @@ export {
|
||||
taskExists,
|
||||
isTaskDependentOn,
|
||||
moveTask,
|
||||
performResearch,
|
||||
readComplexityReport
|
||||
};
|
||||
|
||||
564
scripts/modules/task-manager/research.js
Normal file
564
scripts/modules/task-manager/research.js
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* research.js
|
||||
* Core research functionality for AI-powered queries with project context
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import boxen from 'boxen';
|
||||
import { highlight } from 'cli-highlight';
|
||||
import { ContextGatherer } from '../utils/contextGatherer.js';
|
||||
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
|
||||
import { generateTextService } from '../ai-services-unified.js';
|
||||
import { log as consoleLog, findProjectRoot, readJSON } from '../utils.js';
|
||||
import {
|
||||
displayAiUsageSummary,
|
||||
startLoadingIndicator,
|
||||
stopLoadingIndicator
|
||||
} from '../ui.js';
|
||||
|
||||
/**
|
||||
* Perform AI-powered research with project context
|
||||
* @param {string} query - Research query/prompt
|
||||
* @param {Object} options - Research options
|
||||
* @param {Array<string>} [options.taskIds] - Task/subtask IDs for context
|
||||
* @param {Array<string>} [options.filePaths] - File paths for context
|
||||
* @param {string} [options.customContext] - Additional custom context
|
||||
* @param {boolean} [options.includeProjectTree] - Include project file tree
|
||||
* @param {string} [options.detailLevel] - Detail level: 'low', 'medium', 'high'
|
||||
* @param {string} [options.projectRoot] - Project root directory
|
||||
* @param {Object} [context] - Execution context
|
||||
* @param {Object} [context.session] - MCP session object
|
||||
* @param {Object} [context.mcpLog] - MCP logger object
|
||||
* @param {string} [context.commandName] - Command name for telemetry
|
||||
* @param {string} [context.outputType] - Output type ('cli' or 'mcp')
|
||||
* @param {string} [outputFormat] - Output format ('text' or 'json')
|
||||
* @returns {Promise<Object>} Research results with telemetry data
|
||||
*/
|
||||
async function performResearch(
|
||||
query,
|
||||
options = {},
|
||||
context = {},
|
||||
outputFormat = 'text'
|
||||
) {
|
||||
const {
|
||||
taskIds = [],
|
||||
filePaths = [],
|
||||
customContext = '',
|
||||
includeProjectTree = false,
|
||||
detailLevel = 'medium',
|
||||
projectRoot: providedProjectRoot
|
||||
} = options;
|
||||
|
||||
const {
|
||||
session,
|
||||
mcpLog,
|
||||
commandName = 'research',
|
||||
outputType = 'cli'
|
||||
} = context;
|
||||
const isMCP = !!mcpLog;
|
||||
|
||||
// Determine project root
|
||||
const projectRoot = providedProjectRoot || findProjectRoot();
|
||||
if (!projectRoot) {
|
||||
throw new Error('Could not determine project root directory');
|
||||
}
|
||||
|
||||
// Create consistent logger
|
||||
const logFn = isMCP
|
||||
? mcpLog
|
||||
: {
|
||||
info: (...args) => consoleLog('info', ...args),
|
||||
warn: (...args) => consoleLog('warn', ...args),
|
||||
error: (...args) => consoleLog('error', ...args),
|
||||
debug: (...args) => consoleLog('debug', ...args),
|
||||
success: (...args) => consoleLog('success', ...args)
|
||||
};
|
||||
|
||||
// Show UI banner for CLI mode
|
||||
if (outputFormat === 'text') {
|
||||
console.log(
|
||||
boxen(chalk.cyan.bold(`🔍 AI Research Query`), {
|
||||
padding: 1,
|
||||
borderColor: 'cyan',
|
||||
borderStyle: 'round',
|
||||
margin: { top: 1, bottom: 1 }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize context gatherer
|
||||
const contextGatherer = new ContextGatherer(projectRoot);
|
||||
|
||||
// Auto-discover relevant tasks using fuzzy search to supplement provided tasks
|
||||
let finalTaskIds = [...taskIds]; // Start with explicitly provided tasks
|
||||
let autoDiscoveredIds = [];
|
||||
|
||||
try {
|
||||
const tasksPath = path.join(projectRoot, 'tasks', 'tasks.json');
|
||||
const tasksData = await readJSON(tasksPath);
|
||||
|
||||
if (tasksData && tasksData.tasks && tasksData.tasks.length > 0) {
|
||||
const fuzzySearch = new FuzzyTaskSearch(tasksData.tasks, 'research');
|
||||
const searchResults = fuzzySearch.findRelevantTasks(query, {
|
||||
maxResults: 8,
|
||||
includeRecent: true,
|
||||
includeCategoryMatches: true
|
||||
});
|
||||
|
||||
autoDiscoveredIds = fuzzySearch.getTaskIds(searchResults);
|
||||
|
||||
// Remove any auto-discovered tasks that were already explicitly provided
|
||||
const uniqueAutoDiscovered = autoDiscoveredIds.filter(
|
||||
(id) => !finalTaskIds.includes(id)
|
||||
);
|
||||
|
||||
// Add unique auto-discovered tasks to the final list
|
||||
finalTaskIds = [...finalTaskIds, ...uniqueAutoDiscovered];
|
||||
|
||||
if (outputFormat === 'text' && finalTaskIds.length > 0) {
|
||||
// Sort task IDs numerically for better display
|
||||
const sortedTaskIds = finalTaskIds
|
||||
.map((id) => parseInt(id))
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => id.toString());
|
||||
|
||||
// Show different messages based on whether tasks were explicitly provided
|
||||
if (taskIds.length > 0) {
|
||||
const sortedProvidedIds = taskIds
|
||||
.map((id) => parseInt(id))
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => id.toString());
|
||||
|
||||
console.log(
|
||||
chalk.gray('Provided tasks: ') +
|
||||
chalk.cyan(sortedProvidedIds.join(', '))
|
||||
);
|
||||
|
||||
if (uniqueAutoDiscovered.length > 0) {
|
||||
const sortedAutoIds = uniqueAutoDiscovered
|
||||
.map((id) => parseInt(id))
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => id.toString());
|
||||
|
||||
console.log(
|
||||
chalk.gray('+ Auto-discovered related tasks: ') +
|
||||
chalk.cyan(sortedAutoIds.join(', '))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
chalk.gray('Auto-discovered relevant tasks: ') +
|
||||
chalk.cyan(sortedTaskIds.join(', '))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently continue without auto-discovered tasks if there's an error
|
||||
logFn.debug(`Could not auto-discover tasks: ${error.message}`);
|
||||
}
|
||||
|
||||
const contextResult = await contextGatherer.gather({
|
||||
tasks: finalTaskIds,
|
||||
files: filePaths,
|
||||
customContext,
|
||||
includeProjectTree,
|
||||
format: 'research', // Use research format for AI consumption
|
||||
includeTokenCounts: true
|
||||
});
|
||||
|
||||
const gatheredContext = contextResult.context;
|
||||
const tokenBreakdown = contextResult.tokenBreakdown;
|
||||
|
||||
// Build system prompt based on detail level
|
||||
const systemPrompt = buildResearchSystemPrompt(detailLevel, projectRoot);
|
||||
|
||||
// Build user prompt with context
|
||||
const userPrompt = buildResearchUserPrompt(
|
||||
query,
|
||||
gatheredContext,
|
||||
detailLevel
|
||||
);
|
||||
|
||||
// Count tokens for system and user prompts
|
||||
const systemPromptTokens = contextGatherer.countTokens(systemPrompt);
|
||||
const userPromptTokens = contextGatherer.countTokens(userPrompt);
|
||||
const totalInputTokens = systemPromptTokens + userPromptTokens;
|
||||
|
||||
if (outputFormat === 'text') {
|
||||
// Display detailed token breakdown in a clean box
|
||||
displayDetailedTokenBreakdown(
|
||||
tokenBreakdown,
|
||||
systemPromptTokens,
|
||||
userPromptTokens
|
||||
);
|
||||
}
|
||||
|
||||
// Only log detailed info in debug mode or MCP
|
||||
if (outputFormat !== 'text') {
|
||||
logFn.info(
|
||||
`Calling AI service with research role, context size: ${tokenBreakdown.total} tokens (${gatheredContext.length} characters)`
|
||||
);
|
||||
}
|
||||
|
||||
// Start loading indicator for CLI mode
|
||||
let loadingIndicator = null;
|
||||
if (outputFormat === 'text') {
|
||||
loadingIndicator = startLoadingIndicator('Researching with AI...\n');
|
||||
}
|
||||
|
||||
let aiResult;
|
||||
try {
|
||||
// Call AI service with research role
|
||||
aiResult = await generateTextService({
|
||||
role: 'research', // Always use research role for research command
|
||||
session,
|
||||
projectRoot,
|
||||
systemPrompt,
|
||||
prompt: userPrompt,
|
||||
commandName,
|
||||
outputType
|
||||
});
|
||||
} catch (error) {
|
||||
if (loadingIndicator) {
|
||||
stopLoadingIndicator(loadingIndicator);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (loadingIndicator) {
|
||||
stopLoadingIndicator(loadingIndicator);
|
||||
}
|
||||
}
|
||||
|
||||
const researchResult = aiResult.mainResult;
|
||||
const telemetryData = aiResult.telemetryData;
|
||||
|
||||
// Format and display results
|
||||
if (outputFormat === 'text') {
|
||||
displayResearchResults(
|
||||
researchResult,
|
||||
query,
|
||||
detailLevel,
|
||||
tokenBreakdown
|
||||
);
|
||||
|
||||
// Display AI usage telemetry for CLI users
|
||||
if (telemetryData) {
|
||||
displayAiUsageSummary(telemetryData, 'cli');
|
||||
}
|
||||
}
|
||||
|
||||
logFn.success('Research query completed successfully');
|
||||
|
||||
return {
|
||||
query,
|
||||
result: researchResult,
|
||||
contextSize: gatheredContext.length,
|
||||
contextTokens: tokenBreakdown.total,
|
||||
tokenBreakdown,
|
||||
systemPromptTokens,
|
||||
userPromptTokens,
|
||||
totalInputTokens,
|
||||
detailLevel,
|
||||
telemetryData
|
||||
};
|
||||
} catch (error) {
|
||||
logFn.error(`Research query failed: ${error.message}`);
|
||||
|
||||
if (outputFormat === 'text') {
|
||||
console.error(chalk.red(`\n❌ Research failed: ${error.message}`));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt for research based on detail level
|
||||
* @param {string} detailLevel - Detail level: 'low', 'medium', 'high'
|
||||
* @param {string} projectRoot - Project root for context
|
||||
* @returns {string} System prompt
|
||||
*/
|
||||
function buildResearchSystemPrompt(detailLevel, projectRoot) {
|
||||
const basePrompt = `You are an expert AI research assistant helping with a software development project. You have access to project context including tasks, files, and project structure.
|
||||
|
||||
Your role is to provide comprehensive, accurate, and actionable research responses based on the user's query and the provided project context.`;
|
||||
|
||||
const detailInstructions = {
|
||||
low: `
|
||||
**Response Style: Concise & Direct**
|
||||
- Provide brief, focused answers (2-4 paragraphs maximum)
|
||||
- Focus on the most essential information
|
||||
- Use bullet points for key takeaways
|
||||
- Avoid lengthy explanations unless critical
|
||||
- Skip pleasantries, introductions, and conclusions
|
||||
- No phrases like "Based on your project context" or "I'll provide guidance"
|
||||
- No summary outros or alignment statements
|
||||
- Get straight to the actionable information
|
||||
- Use simple, direct language - users want info, not explanation`,
|
||||
|
||||
medium: `
|
||||
**Response Style: Balanced & Comprehensive**
|
||||
- Provide thorough but well-structured responses (4-8 paragraphs)
|
||||
- Include relevant examples and explanations
|
||||
- Balance depth with readability
|
||||
- Use headings and bullet points for organization`,
|
||||
|
||||
high: `
|
||||
**Response Style: Detailed & Exhaustive**
|
||||
- Provide comprehensive, in-depth analysis (8+ paragraphs)
|
||||
- Include multiple perspectives and approaches
|
||||
- Provide detailed examples, code snippets, and step-by-step guidance
|
||||
- Cover edge cases and potential pitfalls
|
||||
- Use clear structure with headings, subheadings, and lists`
|
||||
};
|
||||
|
||||
return `${basePrompt}
|
||||
|
||||
${detailInstructions[detailLevel]}
|
||||
|
||||
**Guidelines:**
|
||||
- Always consider the project context when formulating responses
|
||||
- Reference specific tasks, files, or project elements when relevant
|
||||
- Provide actionable insights that can be applied to the project
|
||||
- If the query relates to existing project tasks, suggest how the research applies to those tasks
|
||||
- Use markdown formatting for better readability
|
||||
- Be precise and avoid speculation unless clearly marked as such
|
||||
|
||||
**For LOW detail level specifically:**
|
||||
- Start immediately with the core information
|
||||
- No introductory phrases or context acknowledgments
|
||||
- No concluding summaries or project alignment statements
|
||||
- Focus purely on facts, steps, and actionable items`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build user prompt with query and context
|
||||
* @param {string} query - User's research query
|
||||
* @param {string} gatheredContext - Gathered project context
|
||||
* @param {string} detailLevel - Detail level for response guidance
|
||||
* @returns {string} Complete user prompt
|
||||
*/
|
||||
function buildResearchUserPrompt(query, gatheredContext, detailLevel) {
|
||||
let prompt = `# Research Query
|
||||
|
||||
${query}`;
|
||||
|
||||
if (gatheredContext && gatheredContext.trim()) {
|
||||
prompt += `
|
||||
|
||||
# Project Context
|
||||
|
||||
${gatheredContext}`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
|
||||
# Instructions
|
||||
|
||||
Please research and provide a ${detailLevel}-detail response to the query above. Consider the project context provided and make your response as relevant and actionable as possible for this specific project.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display detailed token breakdown for context and prompts
|
||||
* @param {Object} tokenBreakdown - Token breakdown from context gatherer
|
||||
* @param {number} systemPromptTokens - System prompt token count
|
||||
* @param {number} userPromptTokens - User prompt token count
|
||||
*/
|
||||
function displayDetailedTokenBreakdown(
|
||||
tokenBreakdown,
|
||||
systemPromptTokens,
|
||||
userPromptTokens
|
||||
) {
|
||||
const parts = [];
|
||||
|
||||
// Custom context
|
||||
if (tokenBreakdown.customContext) {
|
||||
parts.push(
|
||||
chalk.cyan('Custom: ') +
|
||||
chalk.yellow(tokenBreakdown.customContext.tokens.toLocaleString())
|
||||
);
|
||||
}
|
||||
|
||||
// Tasks breakdown
|
||||
if (tokenBreakdown.tasks && tokenBreakdown.tasks.length > 0) {
|
||||
const totalTaskTokens = tokenBreakdown.tasks.reduce(
|
||||
(sum, task) => sum + task.tokens,
|
||||
0
|
||||
);
|
||||
const taskDetails = tokenBreakdown.tasks
|
||||
.map((task) => {
|
||||
const titleDisplay =
|
||||
task.title.length > 30
|
||||
? task.title.substring(0, 30) + '...'
|
||||
: task.title;
|
||||
return ` ${chalk.gray(task.id)} ${chalk.white(titleDisplay)} ${chalk.yellow(task.tokens.toLocaleString())} tokens`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
parts.push(
|
||||
chalk.cyan('Tasks: ') +
|
||||
chalk.yellow(totalTaskTokens.toLocaleString()) +
|
||||
chalk.gray(` (${tokenBreakdown.tasks.length} items)`) +
|
||||
'\n' +
|
||||
taskDetails
|
||||
);
|
||||
}
|
||||
|
||||
// Files breakdown
|
||||
if (tokenBreakdown.files && tokenBreakdown.files.length > 0) {
|
||||
const totalFileTokens = tokenBreakdown.files.reduce(
|
||||
(sum, file) => sum + file.tokens,
|
||||
0
|
||||
);
|
||||
const fileDetails = tokenBreakdown.files
|
||||
.map((file) => {
|
||||
const pathDisplay =
|
||||
file.path.length > 40
|
||||
? '...' + file.path.substring(file.path.length - 37)
|
||||
: file.path;
|
||||
return ` ${chalk.gray(pathDisplay)} ${chalk.yellow(file.tokens.toLocaleString())} tokens ${chalk.gray(`(${file.sizeKB}KB)`)}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
parts.push(
|
||||
chalk.cyan('Files: ') +
|
||||
chalk.yellow(totalFileTokens.toLocaleString()) +
|
||||
chalk.gray(` (${tokenBreakdown.files.length} files)`) +
|
||||
'\n' +
|
||||
fileDetails
|
||||
);
|
||||
}
|
||||
|
||||
// Project tree
|
||||
if (tokenBreakdown.projectTree) {
|
||||
parts.push(
|
||||
chalk.cyan('Project Tree: ') +
|
||||
chalk.yellow(tokenBreakdown.projectTree.tokens.toLocaleString()) +
|
||||
chalk.gray(
|
||||
` (${tokenBreakdown.projectTree.fileCount} files, ${tokenBreakdown.projectTree.dirCount} dirs)`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Prompts breakdown
|
||||
const totalPromptTokens = systemPromptTokens + userPromptTokens;
|
||||
const promptDetails = [
|
||||
` ${chalk.gray('System:')} ${chalk.yellow(systemPromptTokens.toLocaleString())} tokens`,
|
||||
` ${chalk.gray('User:')} ${chalk.yellow(userPromptTokens.toLocaleString())} tokens`
|
||||
].join('\n');
|
||||
|
||||
parts.push(
|
||||
chalk.cyan('Prompts: ') +
|
||||
chalk.yellow(totalPromptTokens.toLocaleString()) +
|
||||
chalk.gray(' (generated)') +
|
||||
'\n' +
|
||||
promptDetails
|
||||
);
|
||||
|
||||
// Display the breakdown in a clean box
|
||||
if (parts.length > 0) {
|
||||
const content = parts.join('\n\n');
|
||||
const tokenBox = boxen(content, {
|
||||
title: chalk.blue.bold('Context Analysis'),
|
||||
titleAlignment: 'left',
|
||||
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
||||
margin: { top: 0, bottom: 1 },
|
||||
borderStyle: 'single',
|
||||
borderColor: 'blue'
|
||||
});
|
||||
console.log(tokenBox);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process research result text to highlight code blocks
|
||||
* @param {string} text - Raw research result text
|
||||
* @returns {string} Processed text with highlighted code blocks
|
||||
*/
|
||||
function processCodeBlocks(text) {
|
||||
// Regex to match code blocks with optional language specification
|
||||
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
|
||||
|
||||
return text.replace(codeBlockRegex, (match, language, code) => {
|
||||
try {
|
||||
// Default to javascript if no language specified
|
||||
const lang = language || 'javascript';
|
||||
|
||||
// Highlight the code using cli-highlight
|
||||
const highlightedCode = highlight(code.trim(), {
|
||||
language: lang,
|
||||
ignoreIllegals: true // Don't fail on unrecognized syntax
|
||||
});
|
||||
|
||||
// Add a subtle border around code blocks
|
||||
const codeBox = boxen(highlightedCode, {
|
||||
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
||||
margin: { top: 0, bottom: 0 },
|
||||
borderStyle: 'single',
|
||||
borderColor: 'dim'
|
||||
});
|
||||
|
||||
return '\n' + codeBox + '\n';
|
||||
} catch (error) {
|
||||
// If highlighting fails, return the original code block with basic formatting
|
||||
return (
|
||||
'\n' +
|
||||
chalk.gray('```' + (language || '')) +
|
||||
'\n' +
|
||||
chalk.white(code.trim()) +
|
||||
'\n' +
|
||||
chalk.gray('```') +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display research results in formatted output
|
||||
* @param {string} result - AI research result
|
||||
* @param {string} query - Original query
|
||||
* @param {string} detailLevel - Detail level used
|
||||
* @param {Object} tokenBreakdown - Detailed token usage
|
||||
*/
|
||||
function displayResearchResults(result, query, detailLevel, tokenBreakdown) {
|
||||
// Header with query info
|
||||
const header = boxen(
|
||||
chalk.green.bold('Research Results') +
|
||||
'\n\n' +
|
||||
chalk.gray('Query: ') +
|
||||
chalk.white(query) +
|
||||
'\n' +
|
||||
chalk.gray('Detail Level: ') +
|
||||
chalk.cyan(detailLevel),
|
||||
{
|
||||
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
||||
margin: { top: 1, bottom: 0 },
|
||||
borderStyle: 'round',
|
||||
borderColor: 'green'
|
||||
}
|
||||
);
|
||||
console.log(header);
|
||||
|
||||
// Process the result to highlight code blocks
|
||||
const processedResult = processCodeBlocks(result);
|
||||
|
||||
// Main research content in a clean box
|
||||
const contentBox = boxen(processedResult, {
|
||||
padding: { top: 1, bottom: 1, left: 2, right: 2 },
|
||||
margin: { top: 0, bottom: 1 },
|
||||
borderStyle: 'single',
|
||||
borderColor: 'gray'
|
||||
});
|
||||
console.log(contentBox);
|
||||
|
||||
// Success footer
|
||||
console.log(chalk.green('✅ Research completed'));
|
||||
}
|
||||
|
||||
export { performResearch };
|
||||
@@ -542,6 +542,11 @@ function displayHelp() {
|
||||
name: 'expand --all',
|
||||
args: '[--force] [--research]',
|
||||
desc: 'Expand all pending tasks with subtasks'
|
||||
},
|
||||
{
|
||||
name: 'research',
|
||||
args: '"<prompt>" [-i=<task_ids>] [-f=<file_paths>] [-c="<context>"] [--project-tree] [-s=<save_file>] [-d=<detail_level>]',
|
||||
desc: 'Perform AI-powered research queries with project context'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
659
scripts/modules/utils/contextGatherer.js
Normal file
659
scripts/modules/utils/contextGatherer.js
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* contextGatherer.js
|
||||
* Comprehensive context gathering utility for Task Master AI operations
|
||||
* Supports task context, file context, project tree, and custom context
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pkg from 'gpt-tokens';
|
||||
import { readJSON, findTaskById, truncate } from '../utils.js';
|
||||
|
||||
const { encode } = pkg;
|
||||
|
||||
/**
|
||||
* Context Gatherer class for collecting and formatting context from various sources
|
||||
*/
|
||||
export class ContextGatherer {
|
||||
constructor(projectRoot) {
|
||||
this.projectRoot = projectRoot;
|
||||
this.tasksPath = path.join(projectRoot, 'tasks', 'tasks.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tokens in a text string using gpt-tokens
|
||||
* @param {string} text - Text to count tokens for
|
||||
* @returns {number} Token count
|
||||
*/
|
||||
countTokens(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return encode(text).length;
|
||||
} catch (error) {
|
||||
// Fallback to rough character-based estimation if tokenizer fails
|
||||
// Rough estimate: ~4 characters per token for English text
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to gather context from multiple sources
|
||||
* @param {Object} options - Context gathering options
|
||||
* @param {Array<string>} [options.tasks] - Task/subtask IDs to include
|
||||
* @param {Array<string>} [options.files] - File paths to include
|
||||
* @param {string} [options.customContext] - Additional custom context
|
||||
* @param {boolean} [options.includeProjectTree] - Include project file tree
|
||||
* @param {string} [options.format] - Output format: 'research', 'chat', 'system-prompt'
|
||||
* @returns {Promise<string>} Formatted context string
|
||||
*/
|
||||
async gather(options = {}) {
|
||||
const {
|
||||
tasks = [],
|
||||
files = [],
|
||||
customContext = '',
|
||||
includeProjectTree = false,
|
||||
format = 'research',
|
||||
includeTokenCounts = false
|
||||
} = options;
|
||||
|
||||
const contextSections = [];
|
||||
const tokenBreakdown = {
|
||||
customContext: null,
|
||||
tasks: [],
|
||||
files: [],
|
||||
projectTree: null,
|
||||
total: 0
|
||||
};
|
||||
|
||||
// Add custom context first if provided
|
||||
if (customContext && customContext.trim()) {
|
||||
const formattedCustom = this._formatCustomContext(customContext, format);
|
||||
contextSections.push(formattedCustom);
|
||||
if (includeTokenCounts) {
|
||||
tokenBreakdown.customContext = {
|
||||
tokens: this.countTokens(formattedCustom),
|
||||
characters: formattedCustom.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add task context
|
||||
if (tasks.length > 0) {
|
||||
const taskContextResult = await this._gatherTaskContext(
|
||||
tasks,
|
||||
format,
|
||||
includeTokenCounts
|
||||
);
|
||||
if (taskContextResult.context) {
|
||||
contextSections.push(taskContextResult.context);
|
||||
if (includeTokenCounts) {
|
||||
tokenBreakdown.tasks = taskContextResult.breakdown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add file context
|
||||
if (files.length > 0) {
|
||||
const fileContextResult = await this._gatherFileContext(
|
||||
files,
|
||||
format,
|
||||
includeTokenCounts
|
||||
);
|
||||
if (fileContextResult.context) {
|
||||
contextSections.push(fileContextResult.context);
|
||||
if (includeTokenCounts) {
|
||||
tokenBreakdown.files = fileContextResult.breakdown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add project tree context
|
||||
if (includeProjectTree) {
|
||||
const treeContextResult = await this._gatherProjectTreeContext(
|
||||
format,
|
||||
includeTokenCounts
|
||||
);
|
||||
if (treeContextResult.context) {
|
||||
contextSections.push(treeContextResult.context);
|
||||
if (includeTokenCounts) {
|
||||
tokenBreakdown.projectTree = treeContextResult.breakdown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join all sections based on format
|
||||
const finalContext = this._joinContextSections(contextSections, format);
|
||||
|
||||
if (includeTokenCounts) {
|
||||
tokenBreakdown.total = this.countTokens(finalContext);
|
||||
return {
|
||||
context: finalContext,
|
||||
tokenBreakdown: tokenBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
return finalContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse task ID strings into structured format
|
||||
* Supports formats: "15", "15.2", "16,17.1"
|
||||
* @param {Array<string>} taskIds - Array of task ID strings
|
||||
* @returns {Array<Object>} Parsed task identifiers
|
||||
*/
|
||||
_parseTaskIds(taskIds) {
|
||||
const parsed = [];
|
||||
|
||||
for (const idStr of taskIds) {
|
||||
if (idStr.includes('.')) {
|
||||
// Subtask format: "15.2"
|
||||
const [parentId, subtaskId] = idStr.split('.');
|
||||
parsed.push({
|
||||
type: 'subtask',
|
||||
parentId: parseInt(parentId, 10),
|
||||
subtaskId: parseInt(subtaskId, 10),
|
||||
fullId: idStr
|
||||
});
|
||||
} else {
|
||||
// Task format: "15"
|
||||
parsed.push({
|
||||
type: 'task',
|
||||
taskId: parseInt(idStr, 10),
|
||||
fullId: idStr
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather context from tasks and subtasks
|
||||
* @param {Array<string>} taskIds - Task/subtask IDs
|
||||
* @param {string} format - Output format
|
||||
* @param {boolean} includeTokenCounts - Whether to include token breakdown
|
||||
* @returns {Promise<Object>} Task context result with breakdown
|
||||
*/
|
||||
async _gatherTaskContext(taskIds, format, includeTokenCounts = false) {
|
||||
try {
|
||||
const tasksData = readJSON(this.tasksPath);
|
||||
if (!tasksData || !tasksData.tasks) {
|
||||
return { context: null, breakdown: [] };
|
||||
}
|
||||
|
||||
const parsedIds = this._parseTaskIds(taskIds);
|
||||
const contextItems = [];
|
||||
const breakdown = [];
|
||||
|
||||
for (const parsed of parsedIds) {
|
||||
let formattedItem = null;
|
||||
let itemInfo = null;
|
||||
|
||||
if (parsed.type === 'task') {
|
||||
const result = findTaskById(tasksData.tasks, parsed.taskId);
|
||||
if (result.task) {
|
||||
formattedItem = this._formatTaskForContext(result.task, format);
|
||||
itemInfo = {
|
||||
id: parsed.fullId,
|
||||
type: 'task',
|
||||
title: result.task.title,
|
||||
tokens: includeTokenCounts ? this.countTokens(formattedItem) : 0,
|
||||
characters: formattedItem.length
|
||||
};
|
||||
}
|
||||
} else if (parsed.type === 'subtask') {
|
||||
const parentResult = findTaskById(tasksData.tasks, parsed.parentId);
|
||||
if (parentResult.task && parentResult.task.subtasks) {
|
||||
const subtask = parentResult.task.subtasks.find(
|
||||
(st) => st.id === parsed.subtaskId
|
||||
);
|
||||
if (subtask) {
|
||||
formattedItem = this._formatSubtaskForContext(
|
||||
subtask,
|
||||
parentResult.task,
|
||||
format
|
||||
);
|
||||
itemInfo = {
|
||||
id: parsed.fullId,
|
||||
type: 'subtask',
|
||||
title: subtask.title,
|
||||
parentTitle: parentResult.task.title,
|
||||
tokens: includeTokenCounts
|
||||
? this.countTokens(formattedItem)
|
||||
: 0,
|
||||
characters: formattedItem.length
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (formattedItem && itemInfo) {
|
||||
contextItems.push(formattedItem);
|
||||
if (includeTokenCounts) {
|
||||
breakdown.push(itemInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contextItems.length === 0) {
|
||||
return { context: null, breakdown: [] };
|
||||
}
|
||||
|
||||
const finalContext = this._formatTaskContextSection(contextItems, format);
|
||||
return {
|
||||
context: finalContext,
|
||||
breakdown: includeTokenCounts ? breakdown : []
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not gather task context: ${error.message}`);
|
||||
return { context: null, breakdown: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a task for context inclusion
|
||||
* @param {Object} task - Task object
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted task context
|
||||
*/
|
||||
_formatTaskForContext(task, format) {
|
||||
const sections = [];
|
||||
|
||||
sections.push(`**Task ${task.id}: ${task.title}**`);
|
||||
sections.push(`Description: ${task.description}`);
|
||||
sections.push(`Status: ${task.status || 'pending'}`);
|
||||
sections.push(`Priority: ${task.priority || 'medium'}`);
|
||||
|
||||
if (task.dependencies && task.dependencies.length > 0) {
|
||||
sections.push(`Dependencies: ${task.dependencies.join(', ')}`);
|
||||
}
|
||||
|
||||
if (task.details) {
|
||||
const details = truncate(task.details, 500);
|
||||
sections.push(`Implementation Details: ${details}`);
|
||||
}
|
||||
|
||||
if (task.testStrategy) {
|
||||
const testStrategy = truncate(task.testStrategy, 300);
|
||||
sections.push(`Test Strategy: ${testStrategy}`);
|
||||
}
|
||||
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
sections.push(`Subtasks: ${task.subtasks.length} subtasks defined`);
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a subtask for context inclusion
|
||||
* @param {Object} subtask - Subtask object
|
||||
* @param {Object} parentTask - Parent task object
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted subtask context
|
||||
*/
|
||||
_formatSubtaskForContext(subtask, parentTask, format) {
|
||||
const sections = [];
|
||||
|
||||
sections.push(
|
||||
`**Subtask ${parentTask.id}.${subtask.id}: ${subtask.title}**`
|
||||
);
|
||||
sections.push(`Parent Task: ${parentTask.title}`);
|
||||
sections.push(`Description: ${subtask.description}`);
|
||||
sections.push(`Status: ${subtask.status || 'pending'}`);
|
||||
|
||||
if (subtask.dependencies && subtask.dependencies.length > 0) {
|
||||
sections.push(`Dependencies: ${subtask.dependencies.join(', ')}`);
|
||||
}
|
||||
|
||||
if (subtask.details) {
|
||||
const details = truncate(subtask.details, 500);
|
||||
sections.push(`Implementation Details: ${details}`);
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather context from files
|
||||
* @param {Array<string>} filePaths - File paths to read
|
||||
* @param {string} format - Output format
|
||||
* @param {boolean} includeTokenCounts - Whether to include token breakdown
|
||||
* @returns {Promise<Object>} File context result with breakdown
|
||||
*/
|
||||
async _gatherFileContext(filePaths, format, includeTokenCounts = false) {
|
||||
const fileContents = [];
|
||||
const breakdown = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
const fullPath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(this.projectRoot, filePath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.warn(`Warning: File not found: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (!stats.isFile()) {
|
||||
console.warn(`Warning: Path is not a file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file size (limit to 50KB for context)
|
||||
if (stats.size > 50 * 1024) {
|
||||
console.warn(
|
||||
`Warning: File too large, skipping: ${filePath} (${Math.round(stats.size / 1024)}KB)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const relativePath = path.relative(this.projectRoot, fullPath);
|
||||
|
||||
const fileData = {
|
||||
path: relativePath,
|
||||
size: stats.size,
|
||||
content: content,
|
||||
lastModified: stats.mtime
|
||||
};
|
||||
|
||||
fileContents.push(fileData);
|
||||
|
||||
// Calculate tokens for this individual file if requested
|
||||
if (includeTokenCounts) {
|
||||
const formattedFile = this._formatSingleFileForContext(
|
||||
fileData,
|
||||
format
|
||||
);
|
||||
breakdown.push({
|
||||
path: relativePath,
|
||||
sizeKB: Math.round(stats.size / 1024),
|
||||
tokens: this.countTokens(formattedFile),
|
||||
characters: formattedFile.length
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Warning: Could not read file ${filePath}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileContents.length === 0) {
|
||||
return { context: null, breakdown: [] };
|
||||
}
|
||||
|
||||
const finalContext = this._formatFileContextSection(fileContents, format);
|
||||
return {
|
||||
context: finalContext,
|
||||
breakdown: includeTokenCounts ? breakdown : []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate project file tree context
|
||||
* @param {string} format - Output format
|
||||
* @param {boolean} includeTokenCounts - Whether to include token breakdown
|
||||
* @returns {Promise<Object>} Project tree context result with breakdown
|
||||
*/
|
||||
async _gatherProjectTreeContext(format, includeTokenCounts = false) {
|
||||
try {
|
||||
const tree = this._generateFileTree(this.projectRoot, 5); // Max depth 5
|
||||
const finalContext = this._formatProjectTreeSection(tree, format);
|
||||
|
||||
const breakdown = includeTokenCounts
|
||||
? {
|
||||
tokens: this.countTokens(finalContext),
|
||||
characters: finalContext.length,
|
||||
fileCount: tree.fileCount || 0,
|
||||
dirCount: tree.dirCount || 0
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
context: finalContext,
|
||||
breakdown: breakdown
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Warning: Could not generate project tree: ${error.message}`
|
||||
);
|
||||
return { context: null, breakdown: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single file for context (used for token counting)
|
||||
* @param {Object} fileData - File data object
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted file context
|
||||
*/
|
||||
_formatSingleFileForContext(fileData, format) {
|
||||
const header = `**File: ${fileData.path}** (${Math.round(fileData.size / 1024)}KB)`;
|
||||
const content = `\`\`\`\n${fileData.content}\n\`\`\``;
|
||||
return `${header}\n\n${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate file tree structure
|
||||
* @param {string} dirPath - Directory path
|
||||
* @param {number} maxDepth - Maximum depth to traverse
|
||||
* @param {number} currentDepth - Current depth
|
||||
* @returns {Object} File tree structure
|
||||
*/
|
||||
_generateFileTree(dirPath, maxDepth, currentDepth = 0) {
|
||||
const ignoreDirs = [
|
||||
'.git',
|
||||
'node_modules',
|
||||
'.env',
|
||||
'coverage',
|
||||
'dist',
|
||||
'build'
|
||||
];
|
||||
const ignoreFiles = ['.DS_Store', '.env', '.env.local', '.env.production'];
|
||||
|
||||
if (currentDepth >= maxDepth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = fs.readdirSync(dirPath);
|
||||
const tree = {
|
||||
name: path.basename(dirPath),
|
||||
type: 'directory',
|
||||
children: [],
|
||||
fileCount: 0,
|
||||
dirCount: 0
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (ignoreDirs.includes(item) || ignoreFiles.includes(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemPath = path.join(dirPath, item);
|
||||
const stats = fs.statSync(itemPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
tree.dirCount++;
|
||||
if (currentDepth < maxDepth - 1) {
|
||||
const subtree = this._generateFileTree(
|
||||
itemPath,
|
||||
maxDepth,
|
||||
currentDepth + 1
|
||||
);
|
||||
if (subtree) {
|
||||
tree.children.push(subtree);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tree.fileCount++;
|
||||
tree.children.push({
|
||||
name: item,
|
||||
type: 'file',
|
||||
size: stats.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format custom context section
|
||||
* @param {string} customContext - Custom context string
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted custom context
|
||||
*/
|
||||
_formatCustomContext(customContext, format) {
|
||||
switch (format) {
|
||||
case 'research':
|
||||
return `## Additional Context\n\n${customContext}`;
|
||||
case 'chat':
|
||||
return `**Additional Context:**\n${customContext}`;
|
||||
case 'system-prompt':
|
||||
return `Additional context: ${customContext}`;
|
||||
default:
|
||||
return customContext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format task context section
|
||||
* @param {Array<string>} taskItems - Formatted task items
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted task context section
|
||||
*/
|
||||
_formatTaskContextSection(taskItems, format) {
|
||||
switch (format) {
|
||||
case 'research':
|
||||
return `## Task Context\n\n${taskItems.join('\n\n---\n\n')}`;
|
||||
case 'chat':
|
||||
return `**Task Context:**\n\n${taskItems.join('\n\n')}`;
|
||||
case 'system-prompt':
|
||||
return `Task context: ${taskItems.join(' | ')}`;
|
||||
default:
|
||||
return taskItems.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file context section
|
||||
* @param {Array<Object>} fileContents - File content objects
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted file context section
|
||||
*/
|
||||
_formatFileContextSection(fileContents, format) {
|
||||
const fileItems = fileContents.map((file) => {
|
||||
const header = `**File: ${file.path}** (${Math.round(file.size / 1024)}KB)`;
|
||||
const content = `\`\`\`\n${file.content}\n\`\`\``;
|
||||
return `${header}\n\n${content}`;
|
||||
});
|
||||
|
||||
switch (format) {
|
||||
case 'research':
|
||||
return `## File Context\n\n${fileItems.join('\n\n---\n\n')}`;
|
||||
case 'chat':
|
||||
return `**File Context:**\n\n${fileItems.join('\n\n')}`;
|
||||
case 'system-prompt':
|
||||
return `File context: ${fileContents.map((f) => `${f.path} (${f.content.substring(0, 200)}...)`).join(' | ')}`;
|
||||
default:
|
||||
return fileItems.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format project tree section
|
||||
* @param {Object} tree - File tree structure
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Formatted project tree section
|
||||
*/
|
||||
_formatProjectTreeSection(tree, format) {
|
||||
const treeString = this._renderFileTree(tree);
|
||||
|
||||
switch (format) {
|
||||
case 'research':
|
||||
return `## Project Structure\n\n\`\`\`\n${treeString}\n\`\`\``;
|
||||
case 'chat':
|
||||
return `**Project Structure:**\n\`\`\`\n${treeString}\n\`\`\``;
|
||||
case 'system-prompt':
|
||||
return `Project structure: ${treeString.replace(/\n/g, ' | ')}`;
|
||||
default:
|
||||
return treeString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render file tree as string
|
||||
* @param {Object} tree - File tree structure
|
||||
* @param {string} prefix - Current prefix for indentation
|
||||
* @returns {string} Rendered tree string
|
||||
*/
|
||||
_renderFileTree(tree, prefix = '') {
|
||||
let result = `${prefix}${tree.name}/`;
|
||||
|
||||
if (tree.fileCount > 0 || tree.dirCount > 0) {
|
||||
result += ` (${tree.fileCount} files, ${tree.dirCount} dirs)`;
|
||||
}
|
||||
|
||||
result += '\n';
|
||||
|
||||
if (tree.children) {
|
||||
tree.children.forEach((child, index) => {
|
||||
const isLast = index === tree.children.length - 1;
|
||||
const childPrefix = prefix + (isLast ? '└── ' : '├── ');
|
||||
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
|
||||
|
||||
if (child.type === 'directory') {
|
||||
result += this._renderFileTree(child, childPrefix);
|
||||
} else {
|
||||
result += `${childPrefix}${child.name}\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join context sections based on format
|
||||
* @param {Array<string>} sections - Context sections
|
||||
* @param {string} format - Output format
|
||||
* @returns {string} Joined context string
|
||||
*/
|
||||
_joinContextSections(sections, format) {
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'research':
|
||||
return sections.join('\n\n---\n\n');
|
||||
case 'chat':
|
||||
return sections.join('\n\n');
|
||||
case 'system-prompt':
|
||||
return sections.join(' ');
|
||||
default:
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a context gatherer instance
|
||||
* @param {string} projectRoot - Project root directory
|
||||
* @returns {ContextGatherer} Context gatherer instance
|
||||
*/
|
||||
export function createContextGatherer(projectRoot) {
|
||||
return new ContextGatherer(projectRoot);
|
||||
}
|
||||
|
||||
export default ContextGatherer;
|
||||
372
scripts/modules/utils/fuzzyTaskSearch.js
Normal file
372
scripts/modules/utils/fuzzyTaskSearch.js
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* fuzzyTaskSearch.js
|
||||
* Reusable fuzzy search utility for finding relevant tasks based on semantic similarity
|
||||
*/
|
||||
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
/**
|
||||
* Configuration for different search contexts
|
||||
*/
|
||||
const SEARCH_CONFIGS = {
|
||||
research: {
|
||||
threshold: 0.5, // More lenient for research (broader context)
|
||||
limit: 20,
|
||||
keys: [
|
||||
{ name: 'title', weight: 2.0 },
|
||||
{ name: 'description', weight: 1.0 },
|
||||
{ name: 'details', weight: 0.5 },
|
||||
{ name: 'dependencyTitles', weight: 0.5 }
|
||||
]
|
||||
},
|
||||
addTask: {
|
||||
threshold: 0.4, // Stricter for add-task (more precise context)
|
||||
limit: 15,
|
||||
keys: [
|
||||
{ name: 'title', weight: 2.0 },
|
||||
{ name: 'description', weight: 1.5 },
|
||||
{ name: 'details', weight: 0.8 },
|
||||
{ name: 'dependencyTitles', weight: 0.5 }
|
||||
]
|
||||
},
|
||||
default: {
|
||||
threshold: 0.4,
|
||||
limit: 15,
|
||||
keys: [
|
||||
{ name: 'title', weight: 2.0 },
|
||||
{ name: 'description', weight: 1.5 },
|
||||
{ name: 'details', weight: 1.0 },
|
||||
{ name: 'dependencyTitles', weight: 0.5 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Purpose categories for pattern-based task matching
|
||||
*/
|
||||
const PURPOSE_CATEGORIES = [
|
||||
{ pattern: /(command|cli|flag)/i, label: 'CLI commands' },
|
||||
{ pattern: /(task|subtask|add)/i, label: 'Task management' },
|
||||
{ pattern: /(dependency|depend)/i, label: 'Dependency handling' },
|
||||
{ pattern: /(AI|model|prompt|research)/i, label: 'AI integration' },
|
||||
{ pattern: /(UI|display|show|interface)/i, label: 'User interface' },
|
||||
{ pattern: /(schedule|time|cron)/i, label: 'Scheduling' },
|
||||
{ pattern: /(config|setting|option)/i, label: 'Configuration' },
|
||||
{ pattern: /(test|testing|spec)/i, label: 'Testing' },
|
||||
{ pattern: /(auth|login|user)/i, label: 'Authentication' },
|
||||
{ pattern: /(database|db|data)/i, label: 'Data management' },
|
||||
{ pattern: /(api|endpoint|route)/i, label: 'API development' },
|
||||
{ pattern: /(deploy|build|release)/i, label: 'Deployment' },
|
||||
{ pattern: /(security|auth|login|user)/i, label: 'Security' },
|
||||
{ pattern: /.*/, label: 'Other' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Relevance score thresholds
|
||||
*/
|
||||
const RELEVANCE_THRESHOLDS = {
|
||||
high: 0.25,
|
||||
medium: 0.4,
|
||||
low: 0.6
|
||||
};
|
||||
|
||||
/**
|
||||
* Fuzzy search utility class for finding relevant tasks
|
||||
*/
|
||||
export class FuzzyTaskSearch {
|
||||
constructor(tasks, searchType = 'default') {
|
||||
this.tasks = tasks;
|
||||
this.config = SEARCH_CONFIGS[searchType] || SEARCH_CONFIGS.default;
|
||||
this.searchableTasks = this._prepareSearchableTasks(tasks);
|
||||
this.fuse = new Fuse(this.searchableTasks, {
|
||||
includeScore: true,
|
||||
threshold: this.config.threshold,
|
||||
keys: this.config.keys,
|
||||
shouldSort: true,
|
||||
useExtendedSearch: true,
|
||||
limit: this.config.limit
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare tasks for searching by expanding dependency titles
|
||||
* @param {Array} tasks - Array of task objects
|
||||
* @returns {Array} Tasks with expanded dependency information
|
||||
*/
|
||||
_prepareSearchableTasks(tasks) {
|
||||
return tasks.map((task) => {
|
||||
// Get titles of this task's dependencies if they exist
|
||||
const dependencyTitles =
|
||||
task.dependencies?.length > 0
|
||||
? task.dependencies
|
||||
.map((depId) => {
|
||||
const depTask = tasks.find((t) => t.id === depId);
|
||||
return depTask ? depTask.title : '';
|
||||
})
|
||||
.filter((title) => title)
|
||||
.join(' ')
|
||||
: '';
|
||||
|
||||
return {
|
||||
...task,
|
||||
dependencyTitles
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract significant words from a prompt
|
||||
* @param {string} prompt - The search prompt
|
||||
* @returns {Array<string>} Array of significant words
|
||||
*/
|
||||
_extractPromptWords(prompt) {
|
||||
return prompt
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 3); // Words at least 4 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tasks related to a prompt using fuzzy search
|
||||
* @param {string} prompt - The search prompt
|
||||
* @param {Object} options - Search options
|
||||
* @param {number} [options.maxResults=8] - Maximum number of results to return
|
||||
* @param {boolean} [options.includeRecent=true] - Include recent tasks in results
|
||||
* @param {boolean} [options.includeCategoryMatches=true] - Include category-based matches
|
||||
* @returns {Object} Search results with relevance breakdown
|
||||
*/
|
||||
findRelevantTasks(prompt, options = {}) {
|
||||
const {
|
||||
maxResults = 8,
|
||||
includeRecent = true,
|
||||
includeCategoryMatches = true
|
||||
} = options;
|
||||
|
||||
// Extract significant words from prompt
|
||||
const promptWords = this._extractPromptWords(prompt);
|
||||
|
||||
// Perform fuzzy search with full prompt
|
||||
const fuzzyResults = this.fuse.search(prompt);
|
||||
|
||||
// Also search for each significant word to catch different aspects
|
||||
let wordResults = [];
|
||||
for (const word of promptWords) {
|
||||
if (word.length > 5) {
|
||||
// Only use significant words
|
||||
const results = this.fuse.search(word);
|
||||
if (results.length > 0) {
|
||||
wordResults.push(...results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and deduplicate results
|
||||
const mergedResults = [...fuzzyResults];
|
||||
|
||||
// Add word results that aren't already in fuzzyResults
|
||||
for (const wordResult of wordResults) {
|
||||
if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) {
|
||||
mergedResults.push(wordResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Group search results by relevance
|
||||
const highRelevance = mergedResults
|
||||
.filter((result) => result.score < RELEVANCE_THRESHOLDS.high)
|
||||
.map((result) => ({ ...result.item, score: result.score }));
|
||||
|
||||
const mediumRelevance = mergedResults
|
||||
.filter(
|
||||
(result) =>
|
||||
result.score >= RELEVANCE_THRESHOLDS.high &&
|
||||
result.score < RELEVANCE_THRESHOLDS.medium
|
||||
)
|
||||
.map((result) => ({ ...result.item, score: result.score }));
|
||||
|
||||
const lowRelevance = mergedResults
|
||||
.filter(
|
||||
(result) =>
|
||||
result.score >= RELEVANCE_THRESHOLDS.medium &&
|
||||
result.score < RELEVANCE_THRESHOLDS.low
|
||||
)
|
||||
.map((result) => ({ ...result.item, score: result.score }));
|
||||
|
||||
// Get recent tasks (newest first) if requested
|
||||
const recentTasks = includeRecent
|
||||
? [...this.tasks].sort((a, b) => b.id - a.id).slice(0, 5)
|
||||
: [];
|
||||
|
||||
// Find category-based matches if requested
|
||||
let categoryTasks = [];
|
||||
let promptCategory = null;
|
||||
if (includeCategoryMatches) {
|
||||
promptCategory = PURPOSE_CATEGORIES.find((cat) =>
|
||||
cat.pattern.test(prompt)
|
||||
);
|
||||
categoryTasks = promptCategory
|
||||
? this.tasks
|
||||
.filter(
|
||||
(t) =>
|
||||
promptCategory.pattern.test(t.title) ||
|
||||
promptCategory.pattern.test(t.description) ||
|
||||
(t.details && promptCategory.pattern.test(t.details))
|
||||
)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Combine all relevant tasks, prioritizing by relevance
|
||||
const allRelevantTasks = [...highRelevance];
|
||||
|
||||
// Add medium relevance if not already included
|
||||
for (const task of mediumRelevance) {
|
||||
if (!allRelevantTasks.some((t) => t.id === task.id)) {
|
||||
allRelevantTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Add low relevance if not already included
|
||||
for (const task of lowRelevance) {
|
||||
if (!allRelevantTasks.some((t) => t.id === task.id)) {
|
||||
allRelevantTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Add category tasks if not already included
|
||||
for (const task of categoryTasks) {
|
||||
if (!allRelevantTasks.some((t) => t.id === task.id)) {
|
||||
allRelevantTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Add recent tasks if not already included
|
||||
for (const task of recentTasks) {
|
||||
if (!allRelevantTasks.some((t) => t.id === task.id)) {
|
||||
allRelevantTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Get top N results for final output
|
||||
const finalResults = allRelevantTasks.slice(0, maxResults);
|
||||
|
||||
return {
|
||||
results: finalResults,
|
||||
breakdown: {
|
||||
highRelevance,
|
||||
mediumRelevance,
|
||||
lowRelevance,
|
||||
categoryTasks,
|
||||
recentTasks,
|
||||
promptCategory,
|
||||
promptWords
|
||||
},
|
||||
metadata: {
|
||||
totalSearched: this.tasks.length,
|
||||
fuzzyMatches: fuzzyResults.length,
|
||||
wordMatches: wordResults.length,
|
||||
finalCount: finalResults.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task IDs from search results
|
||||
* @param {Object} searchResults - Results from findRelevantTasks
|
||||
* @returns {Array<string>} Array of task ID strings
|
||||
*/
|
||||
getTaskIds(searchResults) {
|
||||
return searchResults.results.map((task) => task.id.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task IDs including subtasks from search results
|
||||
* @param {Object} searchResults - Results from findRelevantTasks
|
||||
* @param {boolean} [includeSubtasks=false] - Whether to include subtask IDs
|
||||
* @returns {Array<string>} Array of task and subtask ID strings
|
||||
*/
|
||||
getTaskIdsWithSubtasks(searchResults, includeSubtasks = false) {
|
||||
const taskIds = [];
|
||||
|
||||
for (const task of searchResults.results) {
|
||||
taskIds.push(task.id.toString());
|
||||
|
||||
if (includeSubtasks && task.subtasks && task.subtasks.length > 0) {
|
||||
for (const subtask of task.subtasks) {
|
||||
taskIds.push(`${task.id}.${subtask.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for display
|
||||
* @param {Object} searchResults - Results from findRelevantTasks
|
||||
* @param {Object} options - Formatting options
|
||||
* @returns {string} Formatted search results summary
|
||||
*/
|
||||
formatSearchSummary(searchResults, options = {}) {
|
||||
const { includeScores = false, includeBreakdown = false } = options;
|
||||
const { results, breakdown, metadata } = searchResults;
|
||||
|
||||
let summary = `Found ${results.length} relevant tasks from ${metadata.totalSearched} total tasks`;
|
||||
|
||||
if (includeBreakdown && breakdown) {
|
||||
const parts = [];
|
||||
if (breakdown.highRelevance.length > 0)
|
||||
parts.push(`${breakdown.highRelevance.length} high relevance`);
|
||||
if (breakdown.mediumRelevance.length > 0)
|
||||
parts.push(`${breakdown.mediumRelevance.length} medium relevance`);
|
||||
if (breakdown.lowRelevance.length > 0)
|
||||
parts.push(`${breakdown.lowRelevance.length} low relevance`);
|
||||
if (breakdown.categoryTasks.length > 0)
|
||||
parts.push(`${breakdown.categoryTasks.length} category matches`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
summary += ` (${parts.join(', ')})`;
|
||||
}
|
||||
|
||||
if (breakdown.promptCategory) {
|
||||
summary += `\nCategory detected: ${breakdown.promptCategory.label}`;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a fuzzy search instance
|
||||
* @param {Array} tasks - Array of task objects
|
||||
* @param {string} [searchType='default'] - Type of search configuration to use
|
||||
* @returns {FuzzyTaskSearch} Fuzzy search instance
|
||||
*/
|
||||
export function createFuzzyTaskSearch(tasks, searchType = 'default') {
|
||||
return new FuzzyTaskSearch(tasks, searchType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick utility function to find relevant task IDs for a prompt
|
||||
* @param {Array} tasks - Array of task objects
|
||||
* @param {string} prompt - Search prompt
|
||||
* @param {Object} options - Search options
|
||||
* @returns {Array<string>} Array of relevant task ID strings
|
||||
*/
|
||||
export function findRelevantTaskIds(tasks, prompt, options = {}) {
|
||||
const {
|
||||
searchType = 'default',
|
||||
maxResults = 8,
|
||||
includeSubtasks = false
|
||||
} = options;
|
||||
|
||||
const fuzzySearch = new FuzzyTaskSearch(tasks, searchType);
|
||||
const results = fuzzySearch.findRelevantTasks(prompt, { maxResults });
|
||||
|
||||
return includeSubtasks
|
||||
? fuzzySearch.getTaskIdsWithSubtasks(results, true)
|
||||
: fuzzySearch.getTaskIds(results);
|
||||
}
|
||||
|
||||
export default FuzzyTaskSearch;
|
||||
Reference in New Issue
Block a user