/** * 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} [options.tasks] - Task/subtask IDs to include * @param {Array} [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} 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} taskIds - Array of task ID strings * @returns {Array} 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} taskIds - Task/subtask IDs * @param {string} format - Output format * @param {boolean} includeTokenCounts - Whether to include token breakdown * @returns {Promise} 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} filePaths - File paths to read * @param {string} format - Output format * @param {boolean} includeTokenCounts - Whether to include token breakdown * @returns {Promise} 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} 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} 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} 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} 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;