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:
Eyal Toledano
2025-05-25 03:51:51 -04:00
parent 67ac212973
commit 275cd55da7
16 changed files with 6506 additions and 54 deletions

View File

@@ -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')

View File

@@ -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
};

View 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 };

View File

@@ -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'
}
]
},

View 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;

View 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;