@tosin2013/kanbn
Version:
A CLI Kanban board with AI-powered task management features
325 lines (275 loc) • 10.4 kB
JavaScript
const Kanbn = require('../main');
const utility = require('../utility');
const inquirer = require('inquirer');
const fuzzy = require('fuzzy');
const axios = require('axios');
const getGitUsername = require('git-user-name');
const AILogging = require('../lib/ai-logging');
inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'));
/**
* Call OpenRouter API to decompose a task
* @param {Object} kanbnInstance Kanbn instance
* @param {string} description Task description to decompose
* @param {Object} task The task object
* @param {boolean} includeReferences Whether to include references in the context
* @return {Promise<Array>} Array of subtasks
*/
async function callOpenRouterAPI(kanbnInstance, description, task, includeReferences = false) {
try {
// Check if we're in a test environment or CI environment
if (process.env.KANBN_ENV === 'test' || process.env.CI === 'true') {
console.log('Skipping actual API call for testing or CI environment...');
// Return a simple mock decomposition
const mockSubtasks = [
{ text: `Subtask 1 for: ${description.substring(0, 30)}...`, completed: false },
{ text: `Subtask 2 for: ${description.substring(0, 30)}...`, completed: false }
];
// Log the interaction
const aiLogging = new AILogging(kanbnInstance);
await aiLogging.logInteraction(process.cwd(), 'request', {
message: description,
context: `You are a task decomposition assistant. Given a task description, break it down into smaller, actionable subtasks.`
});
await aiLogging.logInteraction(process.cwd(), 'response', {
message: description,
response: JSON.stringify({ subtasks: mockSubtasks })
});
return mockSubtasks;
}
// Get API key from environment or command line arguments
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('OpenRouter API key not found. Please set the OPENROUTER_API_KEY environment variable.');
}
// Use the model specified in the environment or default to a cost-effective option
const model = process.env.OPENROUTER_MODEL || 'google/gemma-3-4b-it:free';
console.log(`Using model: ${model}`);
// Create a system message with context
const systemMessage = {
role: 'system',
content: `You are a task decomposition assistant. Given a task description, break it down into smaller, actionable subtasks.
${includeReferences && task.metadata && task.metadata.references && task.metadata.references.length > 0 ?
`\nHere are references that might be helpful:\n${task.metadata.references.map(ref => `- ${ref}`).join('\n')}` : ''}`
};
// Log the interaction
const aiLogging = new AILogging(kanbnInstance);
await aiLogging.logInteraction(process.cwd(), 'request', {
message: description,
context: systemMessage.content
});
// Send request to OpenRouter API
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: model,
messages: [
systemMessage,
{
role: 'user',
content: `Please decompose the following task into smaller, actionable subtasks:\n\n${description}`
}
],
response_format: { type: 'json_object' }
},
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/decision-crafters/kanbn',
'X-Title': 'Kanbn Task Decomposition'
}
}
);
console.log('OpenRouter API call completed successfully.');
const content = response.data.choices[0].message.content;
const parsedContent = JSON.parse(content);
// Log the response
await aiLogging.logInteraction(process.cwd(), 'response', {
message: description,
response: content
});
return parsedContent.subtasks || [];
} catch (error) {
console.error('Error calling OpenRouter API:', error.message);
return fallbackDecomposition(description);
}
}
/**
* Fallback decomposition when API is unavailable
* @param {string} description Task description
* @return {Array} Array of subtasks
*/
function fallbackDecomposition(description) {
const lines = description.split(/\n+/).filter(line => line.trim().length > 0);
if (lines.length > 1) {
return lines.map(line => ({ text: line.trim(), completed: false }));
}
const sentences = description.split(/\.(?!\d)/).filter(s => s.trim().length > 0);
return sentences.map(s => ({ text: s.trim(), completed: false }));
}
/**
* Decompose a task interactively
* @param {string} taskId Task ID to decompose
* @param {string[]} taskIds All task IDs for autocomplete
* @return {Promise<any>}
*/
async function interactiveDecompose(taskId, taskIds) {
return await inquirer.prompt([
{
type: 'autocomplete',
name: 'taskId',
message: 'Select a task to decompose:',
default: taskId,
source: (answers, input) => {
input = input || '';
const result = fuzzy.filter(input, taskIds);
return new Promise(resolve => {
resolve(result.map(result => result.string));
});
},
when: () => !taskId
},
{
type: 'confirm',
name: 'useAI',
message: 'Use AI to decompose this task?',
default: true
},
{
type: 'input',
name: 'customDescription',
message: 'Enter a custom description for decomposition (leave empty to use task description):',
default: ''
}
]);
}
/**
* Create child tasks from decomposition
* @param {Object} kanbnInstance Kanbn instance
* @param {string} parentTaskId Parent task ID
* @param {Array} subtasks Array of subtask objects
*/
async function createChildTasks(kanbnInstance, parentTaskId, subtasks) {
const parentTask = await kanbnInstance.getTask(parentTaskId);
const parentColumn = await kanbnInstance.findTaskColumn(parentTaskId);
const index = await kanbnInstance.getIndex();
const columnNames = Object.keys(index.columns);
const columnName = parentColumn || columnNames[0];
const childTasks = [];
for (const subtask of subtasks) {
try {
// Create a child task in the same column as the parent
const childTaskId = await kanbnInstance.createTask({
name: subtask.text || subtask,
description: subtask.text || subtask,
metadata: {
tags: parentTask.metadata?.tags || [],
parent: parentTaskId
}
}, columnName);
childTasks.push(childTaskId);
// Add reference to child in parent task
if (!parentTask.metadata) {
parentTask.metadata = {};
}
if (!parentTask.metadata.children) {
parentTask.metadata.children = [];
}
parentTask.metadata.children.push(childTaskId);
} catch (error) {
console.error(`Error creating child task: ${error.message}`);
}
}
await kanbnInstance.updateTask(parentTaskId, parentTask);
return childTasks;
}
module.exports = async args => {
// Create a Kanbn instance
const kanbn = Kanbn();
// Make sure kanbn has been initialised
try {
if (!await kanbn.initialised()) {
utility.warning('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
return;
}
} catch (error) {
utility.warning('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
return;
}
let taskId = args.task ? utility.strArg(args.task) : null;
let customDescription = args.description ? utility.strArg(args.description) : '';
const taskIds = [...await kanbn.findTrackedTasks()];
if (args.interactive) {
try {
const answers = await interactiveDecompose(taskId, taskIds);
taskId = answers.taskId || taskId;
customDescription = answers.customDescription;
if (!answers.useAI) {
utility.error('Manual decomposition not implemented yet. Please use AI decomposition.');
return;
}
} catch (error) {
utility.error(error);
return;
}
}
if (!taskId) {
utility.error('No task specified. Use --task or -t to specify a task ID.');
return;
}
try {
// Normalize the task ID by removing any file extension
if (taskId.endsWith('.md')) {
taskId = taskId.slice(0, -3);
}
// Check if task exists
const allTaskIds = await kanbn.findTrackedTasks();
// Handle both Set and Array return types for backward compatibility
let taskExists = false;
let matchingTaskId = null;
// Convert to array for consistent processing
const taskIdsArray = Array.isArray(allTaskIds) ? allTaskIds :
(allTaskIds instanceof Set) ? [...allTaskIds] :
(allTaskIds && typeof allTaskIds === 'object') ? Object.keys(allTaskIds) : [];
// Check for exact match
taskExists = taskIdsArray.includes(taskId);
if (!taskExists) {
// Try to find a task that matches the given ID (case-insensitive)
matchingTaskId = taskIdsArray.find(id => id.toLowerCase() === taskId.toLowerCase());
if (matchingTaskId) {
// Use the matching task ID with correct case
taskId = matchingTaskId;
} else {
utility.error(`Task "${taskId}" doesn't exist`);
return;
}
}
} catch (error) {
utility.error(error);
return;
}
let task;
try {
task = await kanbn.getTask(taskId);
} catch (error) {
utility.error(error);
return;
}
const description = customDescription || task.description;
console.log(`Decomposing task "${task.name}"...`);
const subtasks = await callOpenRouterAPI(kanbn, description, task, args['with-refs']);
if (subtasks.length === 0) {
utility.error('Failed to decompose task. No subtasks generated.');
return;
}
console.log(`Generated ${subtasks.length} subtasks:`);
subtasks.forEach((subtask, index) => {
console.log(`${index + 1}. ${subtask.text}`);
});
console.log('\nCreating child tasks...');
const childTasks = await createChildTasks(kanbn, taskId, subtasks);
console.log(`\nCreated ${childTasks.length} child tasks for "${task.name}"`);
childTasks.forEach(childTaskId => {
console.log(`- ${childTaskId}`);
});
};