UNPKG

@tosin2013/kanbn

Version:

A CLI Kanban board with AI-powered task management features

1,237 lines (1,047 loc) 39.7 kB
const chatParser = require('./chat-parser'); const ChatContext = require('./chat-context'); const utility = require('../utility'); const eventBus = require('./event-bus'); const PromptLoader = require('./prompt-loader'); const MemoryManager = require('./memory-manager'); class ChatHandler { /** * Create a new ChatHandler * @param {Object} kanbn The Kanbn instance * @param {MemoryManager} memoryManager Optional memory manager instance * @param {PromptLoader} promptLoader Optional prompt loader instance */ constructor(kanbn, memoryManager = null, promptLoader = null) { this.kanbn = kanbn; this.context = new ChatContext(); this.memoryManager = memoryManager; this.promptLoader = promptLoader; this.initMode = false; this.initializeContext(); } async initializeContext() { try { // Check if Kanbn is initialized before trying to get the index const initialized = this.kanbn.initialised ? await this.kanbn.initialised() : false; if (initialized) { try { const index = await this.kanbn.getIndex(); if (index && typeof index === 'object') { this.context.setColumns(index); } else { console.log('Invalid index returned, using default empty context'); } } catch (indexError) { console.log('Error getting index, using default empty context:', indexError.message); } } else { // If not initialized, just set up an empty context console.log('Kanbn not initialized yet, using empty context'); } } catch (error) { console.error('Error initializing chat context:', error); // Continue with empty context } } /** * Handle a chat message * @param {string} message The user's message * @return {Promise<string>} The response message * @throws {Error} When no command matches and fallback should be triggered */ async handleMessage(message) { const { intent, params } = chatParser.parseMessage(message); // Different error handling strategy based on intent type if (intent === 'chat') { // Let fallback errors bubble up to trigger AI service throw new Error('No command matched, falling back to AI chat'); } try { switch (intent) { // Existing commands case 'createTask': return await this.handleCreateTask(params); case 'addSubtask': return await this.handleAddSubtask(params); case 'moveTask': return await this.handleMoveTask(params); case 'comment': return await this.handleComment(params); case 'complete': return await this.handleComplete(params); case 'listTasksInColumn': return await this.handleListTasksInColumn(params); case 'status': return await this.handleStatus(); // New commands case 'deleteTask': return await this.handleDeleteTask(params); case 'searchTasks': return await this.handleSearchTasks(params); case 'listTasksByTag': return await this.handleListTasksByTag(params); case 'listTasksByAssignee': return await this.handleListTasksByAssignee(params); case 'showTaskDetails': return await this.handleShowTaskDetails(params); case 'showTaskStats': return await this.handleShowTaskStats(params); case 'addTaskTag': return await this.handleAddTaskTag(params); case 'removeTaskTag': return await this.handleRemoveTaskTag(params); case 'assignTask': return await this.handleAssignTask(params); case 'unassignTask': return await this.handleUnassignTask(params); case 'updateTaskDescription': return await this.handleUpdateTaskDescription(params); default: throw new Error('No command matched, falling back to AI chat'); } } catch (error) { // Only catch errors for recognized commands, not for fallback if (intent !== 'chat') { return `Error: ${error.message}`; } throw error; } } /** * Create a new task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleCreateTask(params) { const taskName = params[3]; const taskData = { name: taskName, description: '', metadata: { created: new Date(), tags: [] } }; const taskId = await this.kanbn.createTask(taskData, 'Backlog'); this.context.setLastTask(taskId, taskName); eventBus.emit('taskCreated', { taskId, column: 'Backlog', taskData, source: 'chat' }); return `Created task "${taskName}" in Backlog`; } /** * Add a subtask to an existing task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleAddSubtask(params) { const [subtaskText, taskRef] = params; const taskId = this.context.resolveTaskReference(taskRef); if (!taskId) { throw new Error(`Could not find task "${taskRef}"`); } const task = await this.kanbn.getTask(taskId); if (!task.subTasks) { task.subTasks = []; } task.subTasks.push({ text: subtaskText, completed: false }); await this.kanbn.updateTask(taskId, task); return `Added subtask "${subtaskText}" to "${task.name}"`; } /** * Move a task to a different column * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleMoveTask(params) { const [taskRef, columnName] = params; const taskId = this.context.resolveTaskReference(taskRef); if (!taskId) { throw new Error(`Could not find task "${taskRef}"`); } if (!this.context.isValidColumn(columnName)) { throw new Error(`Invalid column "${columnName}"`); } const task = await this.kanbn.getTask(taskId); const currentColumn = await this.kanbn.findTaskColumn(taskId); if (currentColumn === columnName) { return `Task "${task.name}" is already in ${columnName}`; } await this.kanbn.moveTask(taskId, columnName); return `Moved task "${task.name}" to ${columnName}`; } /** * Add a comment to a task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleComment(params) { const commentText = params[1]; const taskRef = params[3]; const taskId = this.context.resolveTaskReference(taskRef); if (!taskId) { throw new Error(`Could not find task "${taskRef}"`); } const task = await this.kanbn.getTask(taskId); if (!task.comments) { task.comments = []; } task.comments.push({ date: new Date(), text: commentText }); await this.kanbn.updateTask(taskId, task); return `Added comment to "${task.name}"`; } /** * Mark a task as complete * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleComplete(params) { const [taskRef] = params; const taskId = this.context.resolveTaskReference(taskRef); if (!taskId) { throw new Error(`Could not find task "${taskRef}"`); } const task = await this.kanbn.getTask(taskId); task.metadata.completed = new Date(); await this.kanbn.updateTask(taskId, task); return `Marked "${task.name}" as complete`; } /** * Show project status * @return {Promise<string>} Response message */ async handleStatus() { // Get project statistics const stats = await this.kanbn.status(true); eventBus.emit('contextQueried', { context: stats }); return `Project has ${stats.tasks} tasks total: ${Object.entries(stats.columnTasks) .map(([column, count]) => `${count} in ${column}`) .join(', ')}`; } /** * Delete a task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleDeleteTask(params) { try { // Extract task name from params const taskName = params[2]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Delete the task await this.kanbn.deleteTask(taskId, true); eventBus.emit('taskDeleted', { taskId, taskName }); return `Task "${taskName}" has been deleted.`; } catch (error) { console.error('Error deleting task:', error); throw new Error(`Failed to delete task: ${error.message}`); } } /** * Search for tasks containing a keyword * @param {string[]} params Command parameters * @return {Promise<string>} Response message with search results */ async handleSearchTasks(params) { try { // Extract search term from params const searchTerm = params[params.length - 1]; // Search for tasks const searchResults = await this.kanbn.search({ name: searchTerm, description: searchTerm }); if (searchResults.length === 0) { return `No tasks found containing "${searchTerm}".`; } // Format and return the tasks const taskList = searchResults.map(task => `- ${task.name}${task.description ? ': ' + task.description.split('\n')[0] : ''} (in ${task.column})` ); eventBus.emit('tasksSearched', { searchTerm, resultCount: searchResults.length }); return `Found ${searchResults.length} tasks containing "${searchTerm}":\n${taskList.join('\n')}`; } catch (error) { console.error('Error searching tasks:', error); throw new Error(`Failed to search tasks: ${error.message}`); } } /** * Show details for a specific task * @param {string[]} params Command parameters * @return {Promise<string>} Response message with task details */ async handleShowTaskDetails(params) { try { // Extract task name from params const taskName = params[params.length - 1]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get task details const task = await this.kanbn.getTask(taskId); const column = await this.kanbn.findTaskColumn(taskId); // Format task details let details = `# ${task.name}\n`; details += `**Status**: ${column}\n`; if (task.description) { details += `\n**Description**:\n${task.description}\n`; } if (task.metadata) { if (task.metadata.created) { details += `\n**Created**: ${new Date(task.metadata.created).toLocaleString()}\n`; } if (task.metadata.assigned) { details += `**Assigned to**: ${task.metadata.assigned}\n`; } if (task.metadata.tags && task.metadata.tags.length > 0) { details += `**Tags**: ${task.metadata.tags.join(', ')}\n`; } } if (task.subtasks && task.subtasks.length > 0) { details += '\n**Subtasks**:\n'; task.subtasks.forEach(subtask => { details += `- [${subtask.completed ? 'x' : ' '}] ${subtask.text}\n`; }); } if (task.relations && task.relations.length > 0) { details += '\n**Relations**:\n'; task.relations.forEach(relation => { details += `- ${relation.type} ${relation.task}\n`; }); } if (task.comments && task.comments.length > 0) { details += '\n**Comments**:\n'; task.comments.forEach(comment => { details += `- **${comment.author}**: ${comment.text}\n`; }); } eventBus.emit('taskDetailViewed', { taskId, taskName: task.name }); return details; } catch (error) { console.error('Error showing task details:', error); throw new Error(`Failed to show task details: ${error.message}`); } } /** * List tasks in a specific column * @param {string[]} params Command parameters * @return {Promise<string>} Response message with list of tasks */ async handleListTasksInColumn(params) { try { // The column name will be the last parameter captured by the regex const columnName = params[params.length - 1]; // Get the index to check if the column exists const index = await this.kanbn.getIndex(); // Check if the column exists (case-insensitive check) const matchingColumn = Object.keys(index.columns).find( col => col.toLowerCase() === columnName.toLowerCase() ); if (!matchingColumn) { return `Column "${columnName}" doesn't exist. Available columns are: ${Object.keys(index.columns).join(', ')}`; } // Get task IDs in this column const taskIds = index.columns[matchingColumn] || []; if (taskIds.length === 0) { return `There are no tasks in the ${matchingColumn} column.`; } // Track how many tasks we failed to load let failedTaskCount = 0; // Load task details const tasks = []; for (const taskId of taskIds) { try { const task = await this.kanbn.getTask(taskId); tasks.push({ id: taskId, name: task.name, description: task.description }); } catch (error) { console.error(`Error loading task ${taskId}:`, error); failedTaskCount++; } } // If we failed to load all tasks, try filesystem detection if (failedTaskCount === taskIds.length) { console.log(`Failed to load all ${taskIds.length} tasks, trying filesystem detection...`); try { // Look for task files in the tasks directory const fs = require('fs'); const path = require('path'); const taskFolder = path.join(process.cwd(), '.kanbn', 'tasks'); if (fs.existsSync(taskFolder)) { const taskFiles = fs.readdirSync(taskFolder).filter(file => file.endsWith('.md') && !file.includes('ai-request') && !file.includes('ai-response') ); console.log(`Found ${taskFiles.length} non-system task files in filesystem`); if (taskFiles.length > 0) { // Load task details from files for (const taskFile of taskFiles) { try { const taskId = taskFile.replace('.md', ''); const taskPath = path.join(taskFolder, taskFile); const taskContent = fs.readFileSync(taskPath, 'utf8'); // Simple parsing const nameMatch = taskContent.match(/^# (.+)$/m); const descriptionMatch = taskContent.match(/^# .+\n\n([\s\S]+?)(?:\n\n|$)/m); tasks.push({ id: taskId, name: nameMatch ? nameMatch[1] : taskId, description: descriptionMatch ? descriptionMatch[1].trim() : '' }); } catch (taskError) { console.error(`Error loading task file ${taskFile}:`, taskError); } } } } } catch (fsError) { console.error(`Error reading task directory:`, fsError); } } // If we still have no tasks, return a message if (tasks.length === 0) { return `There are tasks in the ${matchingColumn} column, but they could not be loaded. Try rebuilding the index with 'kanbn index --rebuild'.`; } // Format and return the tasks const taskList = tasks.map(task => `- ${task.name}${task.description ? ': ' + task.description.split('\n')[0] : ''}`); eventBus.emit('tasksListed', { column: matchingColumn, taskCount: tasks.length }); return `Tasks in ${matchingColumn} (${tasks.length}):\n${taskList.join('\n')}`; } catch (error) { console.error('Error listing tasks in column:', error); throw new Error(`Failed to list tasks: ${error.message}`); } } /** * List tasks with a specific tag * @param {string[]} params Command parameters * @return {Promise<string>} Response message with list of tasks */ async handleListTasksByTag(params) { try { // Extract tag from params const tag = params[params.length - 1]; // Search for tasks with the tag const searchResults = await this.kanbn.search({ tags: tag }); if (searchResults.length === 0) { return `No tasks found with tag "${tag}".`; } // Format and return the tasks const taskList = searchResults.map(task => `- ${task.name}${task.description ? ': ' + task.description.split('\n')[0] : ''} (in ${task.column})` ); eventBus.emit('tasksListedByTag', { tag, resultCount: searchResults.length }); return `Found ${searchResults.length} tasks with tag "${tag}":\n${taskList.join('\n')}`; } catch (error) { console.error('Error listing tasks by tag:', error); throw new Error(`Failed to list tasks by tag: ${error.message}`); } } /** * List tasks assigned to a specific person * @param {string[]} params Command parameters * @return {Promise<string>} Response message with list of tasks */ async handleListTasksByAssignee(params) { try { // Extract assignee from params const assignee = params[params.length - 1]; // Search for tasks with the assignee const searchResults = await this.kanbn.search({ assigned: assignee }); if (searchResults.length === 0) { return `No tasks assigned to "${assignee}".`; } // Format and return the tasks const taskList = searchResults.map(task => `- ${task.name}${task.description ? ': ' + task.description.split('\n')[0] : ''} (in ${task.column})` ); eventBus.emit('tasksListedByAssignee', { assignee, resultCount: searchResults.length }); return `Found ${searchResults.length} tasks assigned to "${assignee}":\n${taskList.join('\n')}`; } catch (error) { console.error('Error listing tasks by assignee:', error); throw new Error(`Failed to list tasks by assignee: ${error.message}`); } } /** * Add a tag to a task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleAddTaskTag(params) { try { // Extract task name and tag from params const taskName = params[2]; const tag = params[params.length - 1]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get the task const task = await this.kanbn.getTask(taskId); // Initialize tags array if it doesn't exist if (!task.metadata) { task.metadata = {}; } if (!task.metadata.tags) { task.metadata.tags = []; } // Check if tag already exists if (task.metadata.tags.includes(tag)) { return `Task "${taskName}" already has tag "${tag}".`; } // Add the tag task.metadata.tags.push(tag); // Update the task await this.kanbn.updateTask(taskId, task); eventBus.emit('taskTagAdded', { taskId, taskName, tag }); return `Added tag "${tag}" to task "${taskName}".`; } catch (error) { console.error('Error adding tag to task:', error); throw new Error(`Failed to add tag to task: ${error.message}`); } } /** * Remove a tag from a task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleRemoveTaskTag(params) { try { // Extract tag and task name from params const tag = params[1]; const taskName = params[3]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get the task const task = await this.kanbn.getTask(taskId); // Check if the task has tags if (!task.metadata || !task.metadata.tags || !task.metadata.tags.includes(tag)) { return `Task "${taskName}" doesn't have tag "${tag}".`; } // Remove the tag task.metadata.tags = task.metadata.tags.filter(t => t !== tag); // Update the task await this.kanbn.updateTask(taskId, task); eventBus.emit('taskTagRemoved', { taskId, taskName, tag }); return `Removed tag "${tag}" from task "${taskName}".`; } catch (error) { console.error('Error removing tag from task:', error); throw new Error(`Failed to remove tag from task: ${error.message}`); } } /** * Show details for a specific task * @param {string[]} params Command parameters * @return {Promise<string>} Response message with task details */ async handleShowTaskDetails(params) { try { // Extract task name from params const taskName = params[params.length - 1]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get the task const task = await this.kanbn.getTask(taskId); // Format task details let details = `# ${task.name}\n\n`; // Add task ID details += `**ID**: ${taskId}\n`; // Add description if it exists if (task.description && task.description.trim()) { details += `\n**Description**:\n${task.description}\n`; } // Add column const index = await this.kanbn.getIndex(); const column = Object.keys(index.columns).find( col => index.columns[col] && index.columns[col].includes(taskId) ); if (column) { details += `\n**Column**: ${column}\n`; } // Add created date if (task.created) { details += `\n**Created**: ${task.created}\n`; } // Add started date if it exists if (task.started) { details += `**Started**: ${task.started}\n`; } // Add completed date if it exists if (task.completed) { details += `**Completed**: ${task.completed}\n`; } // Add due date if it exists if (task.due) { details += `**Due**: ${task.due}\n`; } // Add assignees if they exist if (task.metadata && task.metadata.assigned && task.metadata.assigned.length > 0) { details += `\n**Assigned to**: ${task.metadata.assigned.join(', ')}\n`; } // Add tags if they exist if (task.metadata && task.metadata.tags && task.metadata.tags.length > 0) { details += `**Tags**: ${task.metadata.tags.join(', ')}\n`; } // Add subtasks if they exist if (task.subtasks && task.subtasks.length > 0) { details += '\n**Subtasks**:\n'; task.subtasks.forEach(subtask => { details += `- [${subtask.completed ? 'x' : ' '}] ${subtask.text}\n`; }); } // Add relations if they exist if (task.relations && task.relations.length > 0) { details += '\n**Relations**:\n'; task.relations.forEach(relation => { details += `- ${relation.type} ${relation.task}\n`; }); } // Add comments if they exist if (task.comments && task.comments.length > 0) { details += '\n**Comments**:\n'; task.comments.forEach(comment => { details += `- **${comment.author}**: ${comment.text}\n`; }); } eventBus.emit('taskDetailViewed', { taskId, taskName: task.name }); return details; } catch (error) { console.error('Error showing task details:', error); throw new Error(`Failed to show task details: ${error.message}`); } } /** * Delete a task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleDeleteTask(params) { try { // Extract task name from params const taskName = params[1]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Delete the task await this.kanbn.removeTask(taskId); eventBus.emit('taskDeleted', { taskId, taskName }); return `Task "${taskName}" has been deleted.`; } catch (error) { console.error('Error deleting task:', error); throw new Error(`Failed to delete task: ${error.message}`); } } /** * Search for tasks containing a keyword * @param {string[]} params Command parameters * @return {Promise<string>} Response message with search results */ async handleSearchTasks(params) { try { // Extract search term from params const searchTerm = params[params.length - 1]; // Search for tasks const searchResults = await this.kanbn.search({ name: searchTerm, description: searchTerm, operator: 'OR' }); if (searchResults.length === 0) { return `No tasks found containing "${searchTerm}".`; } // Format and return the tasks const taskList = searchResults.map(task => `- ${task.name}${task.description ? ': ' + task.description.split('\n')[0] : ''} (in ${task.column})` ); eventBus.emit('tasksSearched', { searchTerm, resultCount: searchResults.length }); return `Found ${searchResults.length} tasks containing "${searchTerm}":\n${taskList.join('\n')}`; } catch (error) { console.error('Error searching tasks:', error); throw new Error(`Failed to search tasks: ${error.message}`); } } /** * Show statistics for tasks in a column * @param {string[]} params Command parameters * @return {Promise<string>} Response message with task statistics */ async handleShowTaskStats(params) { try { // Extract column name from params const columnName = params[params.length - 1]; // Get the index to check if the column exists const index = await this.kanbn.getIndex(); // Check if the column exists (case-insensitive check) const matchingColumn = Object.keys(index.columns).find( col => col.toLowerCase() === columnName.toLowerCase() ); if (!matchingColumn) { return `Column "${columnName}" doesn't exist. Available columns are: ${Object.keys(index.columns).join(', ')}`; } // Get task IDs in this column const taskIds = index.columns[matchingColumn] || []; if (taskIds.length === 0) { return `There are no tasks in the ${matchingColumn} column.`; } // Count tasks with tags, assignments, and comments let tasksWithTags = 0; let tasksWithAssignees = 0; let tasksWithComments = 0; let totalComments = 0; let tasksWithSubtasks = 0; let totalSubtasks = 0; for (const taskId of taskIds) { try { const task = await this.kanbn.getTask(taskId); if (task.metadata && task.metadata.tags && task.metadata.tags.length > 0) { tasksWithTags++; } if (task.metadata && task.metadata.assigned && task.metadata.assigned.length > 0) { tasksWithAssignees++; } if (task.comments && task.comments.length > 0) { tasksWithComments++; totalComments += task.comments.length; } if (task.subtasks && task.subtasks.length > 0) { tasksWithSubtasks++; totalSubtasks += task.subtasks.length; } } catch (error) { console.error(`Error loading task ${taskId}:`, error); } } // Create statistics report const stats = [ `Total tasks: ${taskIds.length}`, `Tasks with tags: ${tasksWithTags} (${Math.round(tasksWithTags/taskIds.length*100)}%)`, `Tasks with assignees: ${tasksWithAssignees} (${Math.round(tasksWithAssignees/taskIds.length*100)}%)`, `Tasks with comments: ${tasksWithComments} (${Math.round(tasksWithComments/taskIds.length*100)}%)`, `Total comments: ${totalComments}`, `Average comments per task: ${(totalComments/taskIds.length).toFixed(1)}`, `Tasks with subtasks: ${tasksWithSubtasks} (${Math.round(tasksWithSubtasks/taskIds.length*100)}%)`, `Total subtasks: ${totalSubtasks}`, `Average subtasks per task: ${(totalSubtasks/taskIds.length).toFixed(1)}` ]; eventBus.emit('taskStatsViewed', { column: matchingColumn, taskCount: taskIds.length }); return `Statistics for ${matchingColumn} column:\n${stats.join('\n')}`; } catch (error) { console.error('Error showing task statistics:', error); throw new Error(`Failed to show task statistics: ${error.message}`); } } /** * Assign a task to a user * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleAssignTask(params) { try { // Extract task name and assignee from params const taskName = params[1]; const assignee = params[params.length - 1]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get the task const task = await this.kanbn.getTask(taskId); // Initialize metadata and assigned array if they don't exist if (!task.metadata) { task.metadata = {}; } if (!task.metadata.assigned) { task.metadata.assigned = []; } // Check if already assigned if (task.metadata.assigned.includes(assignee)) { return `Task "${taskName}" is already assigned to ${assignee}.`; } // Add assignee task.metadata.assigned.push(assignee); // Update the task await this.kanbn.updateTask(taskId, task); eventBus.emit('taskAssigned', { taskId, taskName, assignee }); return `Assigned task "${taskName}" to ${assignee}.`; } catch (error) { console.error('Error assigning task:', error); throw new Error(`Failed to assign task: ${error.message}`); } } /** * Unassign a user from a task * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleUnassignTask(params) { try { // Extract task name and assignee from params const taskName = params[1]; const assignee = params[params.length - 1]; // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get the task const task = await this.kanbn.getTask(taskId); // Check if the task has assignees if (!task.metadata || !task.metadata.assigned || !task.metadata.assigned.includes(assignee)) { return `Task "${taskName}" is not assigned to ${assignee}.`; } // Remove assignee task.metadata.assigned = task.metadata.assigned.filter(a => a !== assignee); // Update the task await this.kanbn.updateTask(taskId, task); eventBus.emit('taskUnassigned', { taskId, taskName, assignee }); return `Unassigned ${assignee} from task "${taskName}".`; } catch (error) { console.error('Error unassigning task:', error); throw new Error(`Failed to unassign task: ${error.message}`); } } /** * Update task description * @param {string[]} params Command parameters * @return {Promise<string>} Response message */ async handleUpdateTaskDescription(params) { try { // Extract task name and new description from params const taskName = params[1]; const description = params.slice(3).join(' '); // Find the task by name const taskId = await this.findTaskByName(taskName); if (!taskId) { return `Task "${taskName}" not found.`; } // Get the task const task = await this.kanbn.getTask(taskId); // Update the description task.description = description; // Update the task await this.kanbn.updateTask(taskId, task); eventBus.emit('taskDescriptionUpdated', { taskId, taskName }); return `Updated description for task "${taskName}".`; } catch (error) { console.error('Error updating task description:', error); throw new Error(`Failed to update task description: ${error.message}`); } } /** * Handle general chat (fallback) * @param {string} message Original message * @return {Promise<string>} Response message */ async handleChat(message) { // In test mode, just echo the message if (process.env.KANBN_ENV === 'test') { return `Test mode response to: ${message}`; } // If we're in init mode, handle differently if (this.initMode) { return await this.handleInitChat(message); } // In production, throw an error to trigger the fallback to OpenRouter API // This error will be caught by the chat controller, which will then call the OpenRouter API throw new Error('No command matched, falling back to AI chat'); } /** * Handle a specific command directly * @param {string} command Command name * @param {string} args Command arguments * @return {Promise<string>} Response message */ async handleCommand(command, args) { // Check if we have a handler for this command const handlerName = `handle${command.charAt(0).toUpperCase() + command.slice(1)}`; if (typeof this[handlerName] === 'function') { return await this[handlerName](args); } else { throw new Error(`Unknown command: ${command}`); } } /** * Find a task ID by task name (performs a case-insensitive search) * @param {string} taskName The name of the task to find * @return {Promise<string|null>} The task ID if found, or null */ async findTaskByName(taskName) { try { // First try to find the task as if the name was the task ID try { await this.kanbn.getTask(taskName); return taskName; // If this succeeds, the taskName is a valid taskId } catch (error) { // Not a direct ID match, continue with search } // Search by name const searchResults = await this.kanbn.search({ name: taskName }); if (searchResults.length === 0) { return null; } // Look for exact or case-insensitive matches for (const task of searchResults) { if (task.name === taskName) { return task.id; // Exact match } } for (const task of searchResults) { if (task.name.toLowerCase() === taskName.toLowerCase()) { return task.id; // Case-insensitive match } } // If no exact match found, return the first result as a best guess return searchResults[0].id; } catch (error) { console.error('Error finding task by name:', error); return null; } } /** * Initialize for AI-powered init * @param {string} kanbnFolder The path to the .kanbn folder * @return {Promise<void>} */ async initializeForInit(kanbnFolder) { this.initMode = true; // Create memory manager and prompt loader if not provided if (!this.memoryManager) { this.memoryManager = new MemoryManager(kanbnFolder); await this.memoryManager.loadMemory(); await this.memoryManager.startNewConversation('init'); } if (!this.promptLoader) { this.promptLoader = new PromptLoader(kanbnFolder); } } /** * Handle chat messages during initialization * @param {string} message The user's message * @return {Promise<string>} The response message */ async handleInitChat(message) { // In test mode, just echo the message if (process.env.KANBN_ENV === 'test') { return `Init mode test response to: ${message}`; } // In production, throw an error to trigger the fallback to OpenRouter API throw new Error('No init command matched, falling back to AI chat'); } /** * Detect project context from user input * @param {string} projectName The project name * @param {string} projectDescription The project description * @return {Promise<Object>} The detected project context */ async detectProjectContext(projectName, projectDescription) { // In test mode, return a mock context if (process.env.KANBN_ENV === 'test') { return { projectType: 'Software Development', recommendedColumns: ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'], explanation: 'This is a mock project context for testing.' }; } // In production, throw an error to trigger the fallback to OpenRouter API throw new Error('Detecting project context requires AI, falling back to OpenRouter API'); } /** * Calculate Cost of Delay for a task * @param {string} taskName The task name * @param {string} taskDescription The task description * @param {string} classOfService The class of service * @return {Promise<Object>} The calculated Cost of Delay */ async calculateCostOfDelay(taskName, taskDescription, classOfService) { // In test mode, return a mock calculation if (process.env.KANBN_ENV === 'test') { return { value: 5, explanation: 'This is a mock Cost of Delay calculation for testing.' }; } // In production, throw an error to trigger the fallback to OpenRouter API throw new Error('Calculating Cost of Delay requires AI, falling back to OpenRouter API'); } /** * Calculate WSJF (Weighted Shortest Job First) for a task * @param {string} taskName The task name * @param {string} taskDescription The task description * @param {number} costOfDelay The cost of delay * @param {number} jobSize The job size * @return {Promise<Object>} The calculated WSJF */ async calculateWSJF(taskName, taskDescription, costOfDelay, jobSize) { // In test mode, return a mock calculation if (process.env.KANBN_ENV === 'test') { return { value: costOfDelay / jobSize, explanation: 'This is a mock WSJF calculation for testing.' }; } // In production, throw an error to trigger the fallback to OpenRouter API throw new Error('Calculating WSJF requires AI, falling back to OpenRouter API'); } /** * Suggest initial tasks for a project * @param {string} projectName The project name * @param {string} projectDescription The project description * @param {string[]} columns The project columns * @return {Promise<Array>} The suggested initial tasks */ async suggestInitialTasks(projectName, projectDescription, columns) { // In test mode, return mock tasks if (process.env.KANBN_ENV === 'test') { return [ { name: 'Set up project structure', description: 'Create initial project structure and documentation', column: columns[0] || 'Backlog', tags: ['setup', 'documentation'] }, { name: 'Define project scope', description: 'Define the scope and boundaries of the project', column: columns[0] || 'Backlog', tags: ['planning'] } ]; } // In production, throw an error to trigger the fallback to OpenRouter API throw new Error('Suggesting initial tasks requires AI, falling back to OpenRouter API'); } } module.exports = ChatHandler;