@cabbages/memory-pickle-mcp
Version:
MCP server for AI agent project management - 13 tools for session memory and task tracking
420 lines (419 loc) • 17.4 kB
JavaScript
import { generateId } from '../utils/idGenerator.js';
/**
* High-performance service for task management operations with optimized algorithms and caching
*/
export class TaskService {
// Cache for priority detection to avoid repeated regex operations
static priorityCache = new Map();
static PRIORITY_CACHE_SIZE = 100;
/**
* Creates a new task with intelligent priority detection
*/
createTask(args) {
const { title, description, parent_id, priority, due_date, tags = [], project_id, line_range } = args;
// Validate required fields
if (!title?.trim()) {
throw new Error('Task title is required and cannot be empty');
}
if (!project_id?.trim()) {
throw new Error('Project ID is required and cannot be empty');
}
// Validate priority if provided
if (priority && !['critical', 'high', 'medium', 'low'].includes(priority)) {
throw new Error(`Invalid priority: ${priority}. Must be one of: critical, high, medium, low`);
}
// Validate line_range if provided
this.validateLineRange(line_range);
// Auto-detect priority from title and description
const detectedPriority = priority || this.detectPriorityFromText(title, description);
return {
id: generateId('task'),
project_id,
parent_id,
title: title.trim(),
description: description?.trim(),
completed: false,
progress: 0,
created_date: new Date().toISOString(),
due_date,
priority: detectedPriority,
tags: Array.isArray(tags) ? tags.filter(tag => tag?.trim()) : [],
subtasks: [],
notes: [],
blockers: [],
line_range
};
}
/**
* Validates line range parameters
*/
validateLineRange(line_range) {
if (!line_range)
return;
if (typeof line_range.start_line !== 'number' || typeof line_range.end_line !== 'number') {
throw new Error('Line range start_line and end_line must be numbers');
}
if (line_range.start_line < 1 || line_range.end_line < 1) {
throw new Error('Line numbers must be positive (1-based)');
}
if (line_range.start_line > line_range.end_line) {
throw new Error('start_line must be less than or equal to end_line');
}
if (line_range.file_path && !line_range.file_path.trim()) {
throw new Error('file_path cannot be empty if provided');
}
}
/**
* Updates task progress and related fields efficiently
*/
updateTaskProgress(task, args) {
const { progress, notes, blockers } = args;
if (progress !== undefined) {
if (typeof progress !== 'number' || progress < 0 || progress > 100) {
throw new Error('Progress must be a number between 0 and 100');
}
task.progress = Math.round(progress); // Round to avoid floating point issues
if (task.progress === 100 && !task.completed) {
task.completed = true;
task.completed_date = new Date().toISOString();
}
else if (task.progress < 100 && task.completed) {
// If progress is reduced below 100, un-complete the task
task.completed = false;
task.completed_date = undefined;
}
}
if (notes?.trim()) {
task.notes = task.notes || [];
task.notes.push(`[${new Date().toLocaleString()}] ${notes.trim()}`);
}
if (blockers !== undefined) {
task.blockers = blockers.filter(blocker => typeof blocker === 'string' && blocker.trim());
}
}
/**
* Updates parent task progress based on subtasks with optimized calculation
*/
updateParentProgress(parentTask, allTasks) {
if (!parentTask.subtasks || parentTask.subtasks.length === 0)
return;
// Use Set for O(1) lookup instead of Array.includes
const subtaskIds = new Set(parentTask.subtasks);
const subtasks = allTasks.filter(t => subtaskIds.has(t.id));
if (subtasks.length === 0)
return;
const totalProgress = subtasks.reduce((sum, task) => {
return sum + (task.completed ? 100 : (task.progress || 0));
}, 0);
parentTask.progress = Math.round(totalProgress / subtasks.length);
if (parentTask.progress === 100 && !parentTask.completed) {
parentTask.completed = true;
parentTask.completed_date = new Date().toISOString();
}
}
/**
* Links task to project and parent task efficiently
*/
linkTaskToProject(task, project, parentTask) {
// Add to project's task list if not already present
if (!project.tasks.includes(task.id)) {
project.tasks.push(task.id);
}
// Add to parent's subtasks if applicable
if (parentTask && task.parent_id === parentTask.id) {
parentTask.subtasks = parentTask.subtasks || [];
if (!parentTask.subtasks.includes(task.id)) {
parentTask.subtasks.push(task.id);
}
}
}
/**
* Finds a task by ID with error context
*/
findTaskById(tasks, taskId) {
if (!Array.isArray(tasks)) {
throw new Error('Tasks parameter must be an array');
}
if (!taskId?.trim()) {
throw new Error('Task ID is required and cannot be empty');
}
return tasks.find(t => t.id === taskId);
}
/**
* Updates a task in the tasks array with comprehensive validation
*/
updateTask(tasks, taskId, updates) {
const task = this.findTaskById(tasks, taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}. Available task IDs: ${tasks.slice(0, 5).map(t => t.id).join(', ')}${tasks.length > 5 ? '...' : ''}`);
}
// Validate updates before applying
if (updates.progress !== undefined) {
if (typeof updates.progress !== 'number' || updates.progress < 0 || updates.progress > 100) {
throw new Error('Progress must be a number between 0 and 100');
}
}
if (updates.priority && !['critical', 'high', 'medium', 'low'].includes(updates.priority)) {
throw new Error(`Invalid priority: ${updates.priority}`);
}
if (updates.title !== undefined && !updates.title?.trim()) {
throw new Error('Task title cannot be empty');
}
// Apply updates
Object.assign(task, updates);
// Handle completion date when completed is set to true
if (updates.completed === true && !task.completed_date) {
task.completed_date = new Date().toISOString();
}
else if (updates.completed === false) {
task.completed_date = undefined;
}
// Handle progress-based completion (only if progress reaches 100 and completed wasn't explicitly set)
if (updates.progress !== undefined && updates.progress >= 100 && updates.completed === undefined) {
task.completed = true;
task.completed_date = new Date().toISOString();
}
return task;
}
toggleTask(tasksOrTask, taskId) {
if (Array.isArray(tasksOrTask)) {
// Array version
const task = this.findTaskById(tasksOrTask, taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
this.toggleSingleTask(task);
return task;
}
else {
// Single task version
this.toggleSingleTask(tasksOrTask);
}
}
/**
* Helper method for toggling a single task
*/
toggleSingleTask(task) {
task.completed = !task.completed;
if (task.completed) {
task.completed_date = new Date().toISOString();
task.progress = 100;
}
else {
task.completed_date = undefined;
task.progress = 0;
}
}
/**
* Optimized task filtering with better performance for large datasets
*/
filterTasks(tasks, filters) {
const { status, project_id, parent_id, priority, text_search } = filters;
let filteredTasks = tasks;
// Apply filters in order of selectivity for better performance
if (project_id) {
filteredTasks = filteredTasks.filter(t => t.project_id === project_id);
}
if (priority) {
filteredTasks = filteredTasks.filter(t => t.priority === priority);
}
if (parent_id !== undefined) {
filteredTasks = filteredTasks.filter(t => t.parent_id === parent_id);
}
if (status) {
switch (status) {
case 'completed':
filteredTasks = filteredTasks.filter(t => t.completed === true);
break;
case 'pending':
filteredTasks = filteredTasks.filter(t => !t.completed && (!t.progress || t.progress === 0));
break;
case 'in_progress':
filteredTasks = filteredTasks.filter(t => !t.completed && t.progress && t.progress > 0 && t.progress < 100);
break;
}
}
if (text_search?.trim()) {
const searchTerm = text_search.trim().toLowerCase();
filteredTasks = filteredTasks.filter(t => t.title.toLowerCase().includes(searchTerm) ||
(t.description && t.description.toLowerCase().includes(searchTerm)) ||
t.tags.some(tag => tag.toLowerCase().includes(searchTerm)));
}
return filteredTasks;
}
/**
* Optimized task sorting with stable sort algorithm
*/
sortTasks(tasks, sortBy = 'priority') {
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
return [...tasks].sort((a, b) => {
// Always sort completed tasks after incomplete ones
if (a.completed !== b.completed) {
return a.completed ? 1 : -1;
}
switch (sortBy) {
case 'priority':
return priorityOrder[a.priority] - priorityOrder[b.priority];
case 'created_date':
return new Date(a.created_date).getTime() - new Date(b.created_date).getTime();
case 'progress':
return (b.progress || 0) - (a.progress || 0);
case 'title':
return a.title.localeCompare(b.title);
default:
return priorityOrder[a.priority] - priorityOrder[b.priority];
}
});
}
/**
* Optimized task tree formatting with better performance
*/
formatTaskTree(task, allTasks, indent = 0) {
const indentStr = ' '.repeat(indent);
const checkbox = task.completed ? '[DONE]' : '[ ]';
const progress = task.progress ? ` (${task.progress}%)` : '';
let result = `${indentStr}${checkbox} ${task.title}${progress} [${task.priority}]\n`;
if (task.blockers && task.blockers.length > 0) {
result += `${indentStr} [BLOCKED] ${task.blockers.join(', ')}\n`;
}
if (task.subtasks && task.subtasks.length > 0) {
// Use Set for O(1) lookup performance
const taskMap = new Map(allTasks.map(t => [t.id, t]));
const subtasks = task.subtasks.map(id => taskMap.get(id)).filter(Boolean);
subtasks.forEach(subtask => {
result += this.formatTaskTree(subtask, allTasks, indent + 1);
});
}
return result;
}
/**
* Enhanced task list formatting with summary statistics
*/
formatTaskList(tasks, includeStats = true) {
let result = `# Task List\n\n`;
if (includeStats) {
const completed = tasks.filter(t => t.completed).length;
const inProgress = tasks.filter(t => !t.completed && t.progress && t.progress > 0).length;
const blocked = tasks.filter(t => t.blockers && t.blockers.length > 0).length;
result += `**Summary:** ${tasks.length} total • ${completed} completed • ${inProgress} in progress • ${blocked} blocked\n\n`;
}
else {
result += `**Found:** ${tasks.length} tasks\n\n`;
}
tasks.forEach(task => {
const status = task.completed ? '[DONE]' : '[ ]';
result += `${status} **${task.title}** (${task.id})\n`;
result += ` Priority: ${task.priority} | Progress: ${task.progress || 0}%`;
if (task.due_date) {
result += ` | Due: ${new Date(task.due_date).toLocaleDateString()}`;
}
result += '\n';
if (task.description) {
result += ` ${task.description}\n`;
}
if (task.blockers && task.blockers.length > 0) {
result += ` [BLOCKED] ${task.blockers.join(', ')}\n`;
}
result += '\n';
});
return result;
}
/**
* Optimized markdown export with better performance
*/
exportTaskTree(task, allTasks, indent = 0) {
const indentStr = ' '.repeat(indent);
const checkbox = task.completed ? '- [x]' : '- [ ]';
let result = `${indentStr}${checkbox} ${task.title}`;
if (task.priority !== 'medium')
result += ` (${task.priority})`;
if (task.progress && !task.completed)
result += ` - ${task.progress}%`;
result += '\n';
if (task.description) {
result += `${indentStr} > ${task.description}\n`;
}
if (task.blockers && task.blockers.length > 0) {
result += `${indentStr} > [BLOCKED] ${task.blockers.join(', ')}\n`;
}
if (task.subtasks && task.subtasks.length > 0) {
// Use Map for O(1) lookup
const taskMap = new Map(allTasks.map(t => [t.id, t]));
const subtasks = task.subtasks.map(id => taskMap.get(id)).filter(Boolean);
subtasks.forEach(subtask => {
result += this.exportTaskTree(subtask, allTasks, indent + 1);
});
}
return result;
}
/**
* Cached priority detection from task title and description text
*/
detectPriorityFromText(title, description) {
const cacheKey = `${title}|${description || ''}`;
// Check cache first
if (TaskService.priorityCache.has(cacheKey)) {
return TaskService.priorityCache.get(cacheKey);
}
const text = `${title} ${description || ''}`.toLowerCase();
let detectedPriority = 'medium';
// Critical priority keywords
if (/\b(urgent|critical|emergency|blocking|security|asap|immediately)\b/.test(text)) {
detectedPriority = 'critical';
}
// High priority keywords
else if (/\b(important|deadline|core feature|must have|high priority)\b/.test(text)) {
detectedPriority = 'high';
}
// Low priority keywords
else if (/\b(nice to have|maybe|consider|polish|optional|low priority|when time permits)\b/.test(text)) {
detectedPriority = 'low';
}
// Cache the result (with size limit)
if (TaskService.priorityCache.size >= TaskService.PRIORITY_CACHE_SIZE) {
// Clear oldest entries (simple FIFO)
const keys = Array.from(TaskService.priorityCache.keys());
for (let i = 0; i < Math.floor(TaskService.PRIORITY_CACHE_SIZE * 0.2); i++) {
TaskService.priorityCache.delete(keys[i]);
}
}
TaskService.priorityCache.set(cacheKey, detectedPriority);
return detectedPriority;
}
/**
* Builds optimized quick-lookup indexes for tasks with better performance
*/
static buildIndexes(tasks) {
const tasksById = new Map();
const tasksByProject = new Map();
const tasksByStatus = new Map();
const tasksByPriority = new Map();
tasks.forEach(task => {
// By ID index
tasksById.set(task.id, task);
// By project index
if (!tasksByProject.has(task.project_id)) {
tasksByProject.set(task.project_id, []);
}
tasksByProject.get(task.project_id).push(task);
// By status index
const status = task.completed ? 'completed' : 'active';
if (!tasksByStatus.has(status)) {
tasksByStatus.set(status, []);
}
tasksByStatus.get(status).push(task);
// By priority index
if (!tasksByPriority.has(task.priority)) {
tasksByPriority.set(task.priority, []);
}
tasksByPriority.get(task.priority).push(task);
});
return { tasksById, tasksByProject, tasksByStatus, tasksByPriority };
}
/**
* Clears the priority detection cache (useful for testing)
*/
static clearPriorityCache() {
TaskService.priorityCache.clear();
}
}