@endlessblink/like-i-said-v2
Version:
Task Management & Memory for Claude - Track tasks, remember context, and maintain continuity across sessions with 27 powerful tools. Works with Claude Desktop and Claude Code.
576 lines (478 loc) • 18.6 kB
JavaScript
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { TaskFormat } from './task-format.js';
import { TaskMemoryLinker } from './task-memory-linker.js';
import { TaskNameGenerator } from './task-name-generator.js';
export class TaskStorage {
constructor(baseDir = null, memoryStorage = null) {
this.baseDir = baseDir || process.env.TASK_DIR || 'tasks';
this.memoryStorage = memoryStorage;
this.taskIndex = new Map(); // In-memory index for quick lookups
this.taskMemoryLinker = memoryStorage ? new TaskMemoryLinker(memoryStorage, this) : null;
this.ensureDirectories();
this.loadTaskIndex();
}
ensureDirectories() {
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
/**
* Ensure project directory exists and return project file path
*/
getProjectFilePath(projectName) {
const projectDir = path.join(this.baseDir, projectName || 'default');
if (!fs.existsSync(projectDir)) {
fs.mkdirSync(projectDir, { recursive: true });
}
return path.join(projectDir, 'tasks.md');
}
/**
* Add or update task in project file
*/
addTaskToProject(projectName, task) {
const filePath = this.getProjectFilePath(projectName);
if (!fs.existsSync(filePath)) {
// Create new file with project header
const projectHeader = `---\nproject: ${projectName}\ntags: []\nupdated: '${new Date().toISOString()}'\nmanual_memories: []\nmemory_connections: []\n---\n# ${projectName} Tasks\n\n`;
fs.writeFileSync(filePath, projectHeader);
}
// Read existing content
const content = fs.readFileSync(filePath, 'utf8');
const existingTasks = TaskFormat.parseMultiple(content);
// Check if task already exists
const existingIndex = existingTasks.findIndex(t => t.id === task.id);
if (existingIndex >= 0) {
// Update existing task
existingTasks[existingIndex] = task;
} else {
// Add new task
existingTasks.push(task);
}
// Rebuild file content
const projectHeader = content.match(/^---\n[\s\S]*?\n---\n.*$/m)?.[0] ||
`---\nproject: ${projectName}\ntags: []\nupdated: '${new Date().toISOString()}'\nmanual_memories: []\nmemory_connections: []\n---\n# ${projectName} Tasks\n\n`;
let newContent = projectHeader;
for (const t of existingTasks) {
newContent += '\n\n' + TaskFormat.toMarkdown(t);
}
// Write the updated content
fs.writeFileSync(filePath, newContent);
return task;
}
/**
* Load all tasks from all project directories on disk
*/
loadAllTasksFromDisk() {
const tasks = [];
if (process.env.DEBUG_MCP) console.error(`DEBUG: loadAllTasksFromDisk called, baseDir: ${this.baseDir}`);
if (!fs.existsSync(this.baseDir)) {
if (process.env.DEBUG_MCP) console.error(`DEBUG: baseDir does not exist: ${this.baseDir}`);
return tasks;
}
const projects = fs.readdirSync(this.baseDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
if (process.env.DEBUG_MCP) console.error(`DEBUG: Found ${projects.length} project directories:`, projects);
for (const project of projects) {
const projectPath = path.join(this.baseDir, project, 'tasks.md');
if (process.env.DEBUG_MCP) console.error(`DEBUG: Checking project path: ${projectPath}`);
if (fs.existsSync(projectPath)) {
if (process.env.DEBUG_MCP) console.error(`DEBUG: Found tasks.md in project: ${project}`);
try {
const content = fs.readFileSync(projectPath, 'utf8');
if (process.env.DEBUG_MCP) console.error(`DEBUG: File content length: ${content.length}`);
const projectTasks = TaskFormat.parseMultiple(content);
if (process.env.DEBUG_MCP) console.error(`DEBUG: Parsed ${projectTasks.length} tasks from ${project}`);
tasks.push(...projectTasks);
} catch (error) {
console.error(`Error parsing tasks from ${projectPath}:`, error);
}
} else {
if (process.env.DEBUG_MCP) console.error(`DEBUG: No tasks.md found in project: ${project}`);
}
}
if (process.env.DEBUG_MCP) console.error(`DEBUG: Total tasks loaded: ${tasks.length}`);
return tasks;
}
/**
* Update task in project file
*/
updateTaskInProject(projectName, taskId, updates) {
const filePath = this.getProjectFilePath(projectName);
if (!fs.existsSync(filePath)) {
throw new Error(`Project file not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const tasks = TaskFormat.parseMultiple(content);
const taskIndex = tasks.findIndex(t => t.id === taskId);
if (taskIndex === -1) {
throw new Error(`Task ${taskId} not found in project ${projectName}`);
}
// Update the task with proper timestamp
tasks[taskIndex] = {
...tasks[taskIndex],
...updates,
updated: new Date().toISOString()
};
// Preserve project header and rebuild file
const projectHeader = content.match(/^---\n[\s\S]*?\n---\n.*$/m)?.[0] ||
`---\nproject: ${projectName}\ntags: []\nupdated: '${new Date().toISOString()}'\nmanual_memories: []\nmemory_connections: []\n---\n# ${projectName} Tasks\n\n`;
let newContent = projectHeader;
for (const task of tasks) {
newContent += '\n\n' + TaskFormat.toMarkdown(task);
}
fs.writeFileSync(filePath, newContent);
return tasks[taskIndex];
}
/**
* Delete task from project file
*/
deleteTaskFromProject(projectName, taskId) {
const filePath = this.getProjectFilePath(projectName);
if (!fs.existsSync(filePath)) {
throw new Error(`Project file not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const tasks = TaskFormat.parseMultiple(content);
const filteredTasks = tasks.filter(t => t.id !== taskId);
if (filteredTasks.length === tasks.length) {
throw new Error(`Task ${taskId} not found in project ${projectName}`);
}
// Preserve project header and rebuild file
const projectHeader = content.match(/^---\n[\s\S]*?\n---\n.*$/m)?.[0] ||
`---\nproject: ${projectName}\ntags: []\nupdated: '${new Date().toISOString()}'\nmanual_memories: []\nmemory_connections: []\n---\n# ${projectName} Tasks\n\n`;
let newContent = projectHeader;
for (const task of filteredTasks) {
newContent += '\n\n' + TaskFormat.toMarkdown(task);
}
fs.writeFileSync(filePath, newContent);
}
generateTaskId() {
const date = new Date();
const dateStr = date.toISOString().split('T')[0];
const random = crypto.randomBytes(4).toString('hex');
return `task-${dateStr}-${random}`;
}
generateSerial(project, category) {
// Use enhanced serial generation from TaskNameGenerator
const count = this.getProjectTaskCount(project);
return TaskNameGenerator.generateSerial(project, count, category);
}
getProjectTaskCount(project) {
let count = 0;
for (const [_, task] of this.taskIndex) {
if (task.project === project) count++;
}
return count;
}
/**
* Load task index from project-based markdown files
*/
loadTaskIndex() {
if (process.env.DEBUG_MCP) console.error('📋 Loading tasks from project-based markdown files...');
this.taskIndex.clear();
try {
const allTasks = this.loadAllTasksFromDisk();
for (const task of allTasks) {
this.taskIndex.set(task.id, task);
}
if (process.env.DEBUG_MCP) console.error(`✅ Loaded ${allTasks.length} tasks from project-based structure`);
} catch (error) {
console.error('❌ Error loading tasks:', error);
// Fallback to old loading method
this.loadTaskIndexLegacy();
}
}
/**
* Legacy loading method for backward compatibility
*/
loadTaskIndexLegacy() {
if (process.env.DEBUG_MCP) console.error('📋 Falling back to legacy task loading...');
this.taskIndex.clear();
try {
const tasks = TaskFormat.loadAllTasks();
for (const task of tasks) {
this.taskIndex.set(task.id, task);
}
if (process.env.DEBUG_MCP) console.error(`✅ Loaded ${tasks.length} tasks from markdown files`);
} catch (error) {
console.error('❌ Error loading task index:', error);
}
}
/**
* Save task to markdown file and update index
*/
async saveTask(task) {
// Safeguard: Validate against mock data patterns
if (!task.title || typeof task.title !== 'string' || task.title.trim().length === 0) {
throw new Error('Invalid task: Title is required and must be a non-empty string');
}
// Safeguard: Reject mock data indicators
const mockDataPatterns = [
/mock-\d+/i,
/test.*task/i,
/sample.*task/i,
/lorem ipsum/i,
/fake.*task/i,
/placeholder/i,
/todo.*test/i
];
const containsMockPattern = mockDataPatterns.some(pattern =>
pattern.test(task.title) ||
(typeof task.description === 'string' && pattern.test(task.description)) ||
(typeof task.project === 'string' && pattern.test(task.project)) ||
(Array.isArray(task.tags) && task.tags.some(tag => pattern.test(tag)))
);
if (containsMockPattern) {
throw new Error('Invalid task: Mock data patterns detected. Only real tasks are allowed.');
}
// Safeguard: Validate real task requirements
if (task.title.trim().length < 5) {
throw new Error('Invalid task: Title must be at least 5 characters long for real tasks');
}
// Ensure required fields
task.id = task.id || this.generateTaskId();
task.created = task.created || new Date().toISOString();
task.updated = new Date().toISOString();
task.status = task.status || 'todo';
task.priority = task.priority || 'medium';
task.project = task.project || 'default';
task.tags = task.tags || [];
task.manual_memories = task.manual_memories || [];
task.memory_connections = task.memory_connections || [];
// Generate intelligent task name only if title needs enhancement
if (!task.title || task.title.length < 10 || TaskNameGenerator.isGenericTitle(task.title)) {
task.title = TaskNameGenerator.generateTaskName(task);
} else {
// Just enhance the existing title
task.title = TaskNameGenerator.enhanceTitle(task.title, task.category, task.priority);
}
// Generate enhanced serial after name generation (category might be detected)
task.serial = task.serial || this.generateSerial(task.project, task.category);
// Auto-link memories using advanced TaskMemoryLinker if available
if (this.taskMemoryLinker && (!task.memory_connections || task.memory_connections.length === 0)) {
try {
task.memory_connections = await this.taskMemoryLinker.autoLinkMemories(task);
if (task.memory_connections.length > 0) {
if (process.env.DEBUG_MCP) console.error(`🧠 Linked ${task.memory_connections.length} memories to "${task.title.substring(0, 40)}..."`);
}
} catch (error) {
console.error(`❌ Error linking memories for task ${task.id}:`, error.message);
// Fallback to simple search
task.memory_connections = await this.findMemoryConnections(task);
}
}
// Save to project-based structure
const savedTask = this.addTaskToProject(task.project, task);
// Update in-memory index
this.taskIndex.set(savedTask.id, savedTask);
if (process.env.DEBUG_MCP) console.error(`✅ Saved task: ${savedTask.id} → ${savedTask.project} project`);
return savedTask;
}
/**
* Get task by ID
*/
getTask(id) {
return this.taskIndex.get(id) || null;
}
/**
* Get all tasks
*/
getAllTasks() {
if (process.env.DEBUG_MCP) console.error(`DEBUG: getAllTasks (index) called, taskIndex size: ${this.taskIndex.size}`);
const tasks = Array.from(this.taskIndex.values());
if (process.env.DEBUG_MCP) console.error(`DEBUG: Returning ${tasks.length} tasks from index`);
return tasks;
}
/**
* Get tasks by project
*/
getTasksByProject(project) {
return this.getAllTasks().filter(task => task.project === project);
}
/**
* Get tasks by status
*/
getTasksByStatus(status) {
return this.getAllTasks().filter(task => task.status === status);
}
/**
* List tasks with filters
*/
async listTasks(filters = {}) {
let tasks = this.getAllTasks();
// Apply filters
if (filters.project) {
tasks = tasks.filter(task => task.project === filters.project);
}
if (filters.status) {
tasks = tasks.filter(task => task.status === filters.status);
}
if (filters.category) {
tasks = tasks.filter(task => task.category === filters.category);
}
if (filters.has_memory) {
tasks = tasks.filter(task =>
task.memory_connections && task.memory_connections.some(conn =>
conn.memory_id === filters.has_memory
)
);
}
// Apply limit
if (filters.limit) {
tasks = tasks.slice(0, filters.limit);
}
return tasks;
}
/**
* Update task
*/
async updateTask(id, updates) {
const task = this.getTask(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
// Use project manager to update task
const updatedTask = this.updateTaskInProject(task.project, id, updates);
// Update in-memory index
this.taskIndex.set(id, updatedTask);
if (process.env.DEBUG_MCP) console.error(`✅ Updated task: ${id} in project ${updatedTask.project}`);
return updatedTask;
}
/**
* Delete task
*/
deleteTask(id) {
const task = this.getTask(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
// Use project manager to delete task
this.deleteTaskFromProject(task.project, id);
// Remove from in-memory index
this.taskIndex.delete(id);
if (process.env.DEBUG_MCP) console.error(`✅ Deleted task: ${id} from project ${task.project}`);
return true;
}
/**
* Find memory connections for a task (simple fallback method)
*/
async findMemoryConnections(task) {
if (!this.memoryStorage) return [];
const connections = [];
const searchTerms = [
task.title,
task.description,
task.project,
...task.tags
].filter(Boolean);
try {
// Search for relevant memories
const memories = await this.memoryStorage.searchMemories(searchTerms.join(' '));
for (const memory of memories.slice(0, 5)) { // Limit to top 5 connections
connections.push({
memory_id: memory.id,
memory_serial: memory.serial || `MEM-${memory.id.substring(0, 6)}`,
connection_type: 'research',
relevance: 0.8, // Default relevance
matched_terms: searchTerms,
created: new Date().toISOString()
});
}
} catch (error) {
console.error('Error finding memory connections:', error);
}
return connections;
}
/**
* Add memory connection to task
*/
async addMemoryConnection(taskId, memoryId, connectionType = 'research') {
const task = this.getTask(taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
// Check if connection already exists
const existingConnection = task.memory_connections.find(conn => conn.memory_id === memoryId);
if (existingConnection) {
return task;
}
// Add new connection
const connection = {
memory_id: memoryId,
memory_serial: `MEM-${memoryId.substring(0, 6)}`,
connection_type: connectionType,
relevance: 0.9,
matched_terms: [task.title, task.project],
created: new Date().toISOString()
};
task.memory_connections.push(connection);
// Update task
return await this.updateTask(taskId, { memory_connections: task.memory_connections });
}
/**
* Remove memory connection from task
*/
async removeMemoryConnection(taskId, memoryId) {
const task = this.getTask(taskId);
if (!task) {
throw new Error(`Task not found: ${taskId}`);
}
// Remove connection
task.memory_connections = task.memory_connections.filter(conn => conn.memory_id !== memoryId);
// Update task
return await this.updateTask(taskId, { memory_connections: task.memory_connections });
}
/**
* Get task statistics
*/
getTaskStats() {
const allTasks = this.getAllTasks();
const stats = {
total: allTasks.length,
by_status: {},
by_project: {},
by_priority: {}
};
for (const task of allTasks) {
// Count by status
stats.by_status[task.status] = (stats.by_status[task.status] || 0) + 1;
// Count by project
stats.by_project[task.project] = (stats.by_project[task.project] || 0) + 1;
// Count by priority
stats.by_priority[task.priority] = (stats.by_priority[task.priority] || 0) + 1;
}
return stats;
}
/**
* Search tasks
*/
searchTasks(query) {
const results = [];
const searchTerms = query.toLowerCase().split(' ');
for (const task of this.getAllTasks()) {
const searchableText = [
task.title,
task.description,
task.project,
...(Array.isArray(task.tags) ? task.tags : [])
].join(' ').toLowerCase();
const matches = searchTerms.filter(term => searchableText.includes(term));
if (matches.length > 0) {
results.push({
...task,
relevance: matches.length / searchTerms.length
});
}
}
return results.sort((a, b) => b.relevance - a.relevance);
}
/**
* Reload tasks from filesystem
*/
reload() {
this.loadTaskIndex();
}
}