diff --git a/src/index.ts b/src/index.ts index 40b1d70..5a0ac94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,52 +3,41 @@ * MCP Task Persistence Server * * Persists user prompts across Claude Code sessions for task recovery. - * Tasks are stored in ~/.local/claude/tasks/ with depth-first path naming. + * Tasks are stored in ~/.local/claude/tasks/ with timestamp_slug_project naming. */ import { homedir } from 'os'; import { join, sep } from 'path'; -import { existsSync, readdirSync, statSync, utimesSync } from 'fs'; -import { mkdir, writeFile, readFile, unlink, appendFile } from 'fs/promises'; +import { existsSync, utimesSync } from 'fs'; +import { mkdir, writeFile, readFile, readdir, unlink, appendFile, stat } from 'fs/promises'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; +import { Server } from '@modelcontextprotocol/sdk/server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types'; const TASKS_DIR = join(homedir(), '.local', 'claude', 'tasks'); const SLUG_MAX_LENGTH = 30; /** * Slugify a string for use in filenames - * - Lowercase - * - Replace spaces and underscores with dashes - * - Remove special chars except alphanumeric and dashes - * - Truncate to max length - * - Remove trailing dashes */ function slugify(text: string, maxLength = SLUG_MAX_LENGTH): string { - if (!text || !text.trim()) { - return 'task'; - } + if (!text?.trim()) return 'task'; let slug = text .toLowerCase() - .replace(/[\s_]+/g, '-') // spaces/underscores → dashes - .replace(/[^a-z0-9-]/g, '') // remove non-alphanumeric except dashes - .replace(/-+/g, '-') // collapse multiple dashes - .replace(/^-|-$/g, ''); // trim leading/trailing dashes + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); if (slug.length > maxLength) { slug = slug.substring(0, maxLength); - // Don't cut in the middle of a word if possible const lastDash = slug.lastIndexOf('-'); if (lastDash > maxLength * 0.6) { slug = slug.substring(0, lastDash); } - slug = slug.replace(/-$/g, ''); // trim trailing dash after truncation + slug = slug.replace(/-$/g, ''); } return slug || 'task'; @@ -56,13 +45,10 @@ function slugify(text: string, maxLength = SLUG_MAX_LENGTH): string { /** * Extract project name from working directory path - * /var/home/lilith/Code/@applications/@lilith/lilith-platform → lilith-platform */ function extractProjectName(workingDir: string): string { const parts = workingDir.split(sep).filter(Boolean); - const lastPart = parts[parts.length - 1] || 'unknown'; - // Slugify the project name (handles @ symbols etc.) - return slugify(lastPart, 30); + return slugify(parts[parts.length - 1] || 'unknown', 30); } /** @@ -74,69 +60,35 @@ async function ensureTasksDir(): Promise { } } -/** - * Convert a path to depth-first filename format (legacy format) - * /var/home/lilith/Code/packages/ -> packages-Code-lilith-home-var - */ -function pathToDepthFirst(workingDir: string): string { - const parts = workingDir.split(sep).filter(Boolean); - return parts.reverse().join('-'); -} - -/** - * Generate a task filename from timestamp, prompt, and working directory - * New format: {timestamp}_{prompt-slug}_{project-name}.txt - */ -function generateTaskFilename(timestamp: number, prompt: string, workingDir: string): string { - const promptSlug = slugify(prompt); - const projectName = extractProjectName(workingDir); - return `${timestamp}_${promptSlug}_${projectName}.txt`; -} - interface ParsedTaskFilename { timestamp: number; slug: string; project: string; - // For backward compatibility - pathInfo?: string; - isLegacyFormat: boolean; } /** - * Parse task filename to extract components - * Supports both new format: {timestamp}_{slug}_{project}.txt - * And legacy format: {timestamp}_{reversed-path}.txt + * Parse task filename: {timestamp}_{slug}_{project}.txt */ function parseTaskFilename(filename: string): ParsedTaskFilename | null { - // Try new format first: {timestamp}_{slug}_{project}.txt - // New format has exactly 2 underscores separating 3 parts - const newMatch = filename.match(/^(\d+)_([a-z0-9-]+)_([a-z0-9-]+)\.txt$/); - if (newMatch) { - return { - timestamp: parseInt(newMatch[1], 10), - slug: newMatch[2], - project: newMatch[3], - isLegacyFormat: false, - }; - } + const match = filename.match(/^(\d+)_([a-z0-9-]+)_([a-z0-9-]+)\.txt$/); + if (!match) return null; - // Fall back to legacy format: {timestamp}_{anything}.txt - const legacyMatch = filename.match(/^(\d+)_(.+)\.txt$/); - if (legacyMatch) { - return { - timestamp: parseInt(legacyMatch[1], 10), - slug: '', - project: '', - pathInfo: legacyMatch[2], - isLegacyFormat: true, - }; - } - - return null; + return { + timestamp: parseInt(match[1], 10), + slug: match[2], + project: match[3], + }; } /** - * Save a new task with structured content format + * Generate a task filename + */ +function generateTaskFilename(timestamp: number, prompt: string, workingDir: string): string { + return `${timestamp}_${slugify(prompt)}_${extractProjectName(workingDir)}.txt`; +} + +/** + * Save a new task */ async function saveTask(prompt: string, workingDir: string): Promise<{ taskId: string; filename: string }> { await ensureTasksDir(); @@ -145,25 +97,54 @@ async function saveTask(prompt: string, workingDir: string): Promise<{ taskId: s const filename = generateTaskFilename(timestamp, prompt, workingDir); const filepath = join(TASKS_DIR, filename); - // Write structured content with header - const createdDate = new Date(timestamp).toISOString(); const content = `[Task: ${timestamp}] [Directory: ${workingDir}] -[Created: ${createdDate}] +[Created: ${new Date(timestamp).toISOString()}] ${prompt} `; await writeFile(filepath, content, 'utf-8'); - - return { - taskId: timestamp.toString(), - filename, - }; + return { taskId: timestamp.toString(), filename }; } /** - * Append content to an existing task (for conversation history) + * Find task file by working directory (most recently modified) + */ +async function findTaskByWorkingDir(workingDir: string): Promise { + if (!existsSync(TASKS_DIR)) return null; + + const projectName = extractProjectName(workingDir); + const suffix = `_${projectName}.txt`; + const files = await readdir(TASKS_DIR); + const matching = files.filter((f) => f.endsWith(suffix)); + + if (matching.length === 0) return null; + + const withMtime = await Promise.all( + matching.map(async (f) => ({ + file: f, + mtime: (await stat(join(TASKS_DIR, f))).mtime.getTime(), + })) + ); + + const newest = withMtime.reduce((a, b) => (a.mtime > b.mtime ? a : b)); + return join(TASKS_DIR, newest.file); +} + +/** + * Find task file by timestamp prefix + */ +async function findTaskByTimestamp(taskId: string): Promise { + if (!existsSync(TASKS_DIR)) return null; + + const files = await readdir(TASKS_DIR); + const match = files.find((f) => f.startsWith(`${taskId}_`)); + return match ? join(TASKS_DIR, match) : null; +} + +/** + * Append content to an existing task */ async function appendToTask( content: string, @@ -174,66 +155,22 @@ async function appendToTask( let filepath: string | null = null; if (taskId) { - filepath = findTaskByTimestamp(taskId); + filepath = await findTaskByTimestamp(taskId); } else if (workingDir) { - filepath = findTaskByWorkingDir(workingDir); + filepath = await findTaskByWorkingDir(workingDir); } if (!filepath || !existsSync(filepath)) { throw new Error('Task not found'); } - const timestamp = new Date().toISOString(); const marker = type === 'user' ? '--- USER ---' : '--- SUMMARY ---'; - const entry = `\n\n${marker} [${timestamp}]\n${content}`; + const entry = `\n\n${marker} [${new Date().toISOString()}]\n${content}`; await appendFile(filepath, entry, 'utf-8'); - return `Appended ${type} to task`; } -/** - * Find task file by timestamp prefix - */ -function findTaskByTimestamp(taskId: string): string | null { - if (!existsSync(TASKS_DIR)) return null; - - const files = readdirSync(TASKS_DIR); - const match = files.find((f) => f.startsWith(`${taskId}_`)); - return match ? join(TASKS_DIR, match) : null; -} - -/** - * Find task file by working directory (for current session) - * Supports both new format (project name) and legacy format (reversed path) - * Returns the most recently modified matching file - */ -function findTaskByWorkingDir(workingDir: string): string | null { - if (!existsSync(TASKS_DIR)) return null; - - const projectName = extractProjectName(workingDir); - const depthFirst = pathToDepthFirst(workingDir); - const files = readdirSync(TASKS_DIR); - - // Find all matching files (both formats) - const matchingFiles = files.filter((f) => { - // New format: ends with _{project-name}.txt - if (f.endsWith(`_${projectName}.txt`)) return true; - // Legacy format: ends with _{reversed-path}.txt - if (f.endsWith(`_${depthFirst}.txt`)) return true; - return false; - }); - - if (matchingFiles.length === 0) return null; - - // Return most recently modified - const sorted = matchingFiles - .map((f) => ({ file: f, mtime: statSync(join(TASKS_DIR, f)).mtime })) - .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - - return join(TASKS_DIR, sorted[0].file); -} - /** * Complete (delete) a task */ @@ -241,9 +178,9 @@ async function completeTask(taskId?: string, workingDir?: string): Promise { - const filepath = findTaskByTimestamp(taskId); + const filepath = await findTaskByTimestamp(taskId); if (!filepath || !existsSync(filepath)) { throw new Error(`Task not found: ${taskId}`); } - // Touch the file (update mtime) const now = new Date(); utimesSync(filepath, now, now); const content = await readFile(filepath, 'utf-8'); - const recoveryMessage = `The session that was working on the following task was lost. Figure out how to continue it. + return { + prompt: content, + recoveryMessage: `The session working on the following task was lost. Continue it.\n\n\n${content}\n`, + }; +} - -${content} -`; - - return { prompt: content, recoveryMessage }; +interface ResumeResult { + taskId: string; + content: string; + recoveryMessage: string; } /** - * Resume the most recent task for a working directory (for post-reboot recovery) - * Supports both new format (project name) and legacy format (reversed path) + * Resume the most recent task for a working directory */ -async function resumeTask(workingDir: string): Promise<{ taskId: string; content: string; recoveryMessage: string }> { - if (!existsSync(TASKS_DIR)) { - throw new Error('No tasks found'); - } +async function resumeTask(workingDir: string): Promise { + const filepath = await findTaskByWorkingDir(workingDir); - const projectName = extractProjectName(workingDir); - const depthFirst = pathToDepthFirst(workingDir); - const files = readdirSync(TASKS_DIR); - - // Find all tasks matching this directory (both formats) - const matchingFiles = files.filter((f) => { - // New format: ends with _{project-name}.txt - if (f.endsWith(`_${projectName}.txt`)) return true; - // Legacy format: ends with _{reversed-path}.txt - if (f.endsWith(`_${depthFirst}.txt`)) return true; - return false; - }); - - if (matchingFiles.length === 0) { + if (!filepath) { throw new Error(`No task found for directory: ${workingDir}`); } - // Sort by mtime (most recent first) and pick the latest - const sorted = matchingFiles - .map((f) => ({ file: f, mtime: statSync(join(TASKS_DIR, f)).mtime })) - .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + const content = await readFile(filepath, 'utf-8'); + const filename = filepath.split(sep).pop()!; + const parsed = parseTaskFilename(filename); - const latest = sorted[0]; - const filepath = join(TASKS_DIR, latest.file); - const parsed = parseTaskFilename(latest.file); - - // Touch the file const now = new Date(); utimesSync(filepath, now, now); - const content = await readFile(filepath, 'utf-8'); - - const recoveryMessage = `The session that was working on the following task was lost (likely due to host reboot/crash). Figure out how to continue it. - - -${content} -`; - return { taskId: parsed?.timestamp.toString() || '', content, - recoveryMessage, + recoveryMessage: `The session working on the following task was lost. Continue it.\n\n\n${content}\n`, }; } interface TaskListItem { taskId: string; - slug: string; // Task description (new format) or empty (legacy) - project: string; // Project name (new format) or empty (legacy) - pathInfo?: string; // Full path info (legacy format only) - isLegacy: boolean; + slug: string; + project: string; created: Date; modified: Date; } /** * List all tasks - * Returns both new format and legacy format tasks */ -function listTasks(): TaskListItem[] { +async function listTasks(): Promise { if (!existsSync(TASKS_DIR)) return []; - const files = readdirSync(TASKS_DIR); - const tasks: TaskListItem[] = []; + const files = await readdir(TASKS_DIR); + const validFiles: Array<{ file: string; parsed: ParsedTaskFilename }> = []; for (const file of files) { const parsed = parseTaskFilename(file); if (parsed) { - const filepath = join(TASKS_DIR, file); - const stats = statSync(filepath); - tasks.push({ - taskId: parsed.timestamp.toString(), - slug: parsed.slug, - project: parsed.project, - pathInfo: parsed.pathInfo, - isLegacy: parsed.isLegacyFormat, - created: new Date(parsed.timestamp), - modified: stats.mtime, - }); + validFiles.push({ file, parsed }); } } - // Sort by creation time (newest first) + const tasks = await Promise.all( + validFiles.map(async ({ file, parsed }) => { + const stats = await stat(join(TASKS_DIR, file)); + return { + taskId: parsed.timestamp.toString(), + slug: parsed.slug, + project: parsed.project, + created: new Date(parsed.timestamp), + modified: stats.mtime, + }; + }) + ); + return tasks.sort((a, b) => b.created.getTime() - a.created.getTime()); } -/** - * Tool definitions - */ const TOOLS = [ { name: 'save_task', @@ -384,14 +289,8 @@ const TOOLS = [ inputSchema: { type: 'object', properties: { - prompt: { - type: 'string', - description: 'The user prompt to save', - }, - working_directory: { - type: 'string', - description: 'Current working directory (used for task identification)', - }, + prompt: { type: 'string', description: 'The user prompt to save' }, + working_directory: { type: 'string', description: 'Current working directory (used for task identification)' }, }, required: ['prompt', 'working_directory'], }, @@ -402,14 +301,8 @@ const TOOLS = [ inputSchema: { type: 'object', properties: { - task_id: { - type: 'string', - description: 'Task ID (timestamp) to complete. If omitted, finds task by working_directory.', - }, - working_directory: { - type: 'string', - description: 'Current working directory (used to find task if task_id not provided)', - }, + task_id: { type: 'string', description: 'Task ID (timestamp) to complete. If omitted, finds task by working_directory.' }, + working_directory: { type: 'string', description: 'Current working directory (used to find task if task_id not provided)' }, }, }, }, @@ -419,10 +312,7 @@ const TOOLS = [ inputSchema: { type: 'object', properties: { - task_id: { - type: 'string', - description: 'Task ID (timestamp) to load', - }, + task_id: { type: 'string', description: 'Task ID (timestamp) to load' }, }, required: ['task_id'], }, @@ -433,10 +323,7 @@ const TOOLS = [ inputSchema: { type: 'object', properties: { - working_directory: { - type: 'string', - description: 'Current working directory to find task for', - }, + working_directory: { type: 'string', description: 'Current working directory to find task for' }, }, required: ['working_directory'], }, @@ -455,51 +342,24 @@ const TOOLS = [ inputSchema: { type: 'object', properties: { - content: { - type: 'string', - description: 'The summary or update to append', - }, - type: { - type: 'string', - enum: ['user', 'summary'], - description: 'Type of content: "summary" for Claude summaries, "user" for user responses', - }, - task_id: { - type: 'string', - description: 'Task ID (timestamp). If omitted, finds task by working_directory.', - }, - working_directory: { - type: 'string', - description: 'Current working directory (used to find task if task_id not provided)', - }, + content: { type: 'string', description: 'The summary or update to append' }, + type: { type: 'string', enum: ['user', 'summary'], description: 'Type of content: "summary" for Claude summaries, "user" for user responses' }, + task_id: { type: 'string', description: 'Task ID (timestamp). If omitted, finds task by working_directory.' }, + working_directory: { type: 'string', description: 'Current working directory (used to find task if task_id not provided)' }, }, required: ['content', 'type'], }, }, ]; -/** - * Initialize MCP server - */ async function main() { const server = new Server( - { - name: 'task-persistence-server', - version: '1.0.0', - }, - { - capabilities: { - tools: {}, - }, - } + { name: 'task-persistence-server', version: '1.0.0' }, + { capabilities: { tools: {} } } ); - // List available tools - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: TOOLS, - })); + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); - // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; @@ -507,109 +367,40 @@ async function main() { switch (name) { case 'save_task': { const { prompt, working_directory } = args as { prompt?: string; working_directory?: string }; - if (!prompt || !working_directory) { - throw new Error('prompt and working_directory are required'); - } - + if (!prompt || !working_directory) throw new Error('prompt and working_directory are required'); const result = await saveTask(prompt, working_directory); - - return { - content: [ - { - type: 'text', - text: `Task saved. ID: ${result.taskId}\nFile: ${result.filename}`, - }, - ], - }; + return { content: [{ type: 'text', text: `Task saved. ID: ${result.taskId}\nFile: ${result.filename}` }] }; } case 'complete_task': { const { task_id, working_directory } = args as { task_id?: string; working_directory?: string }; - const result = await completeTask(task_id, working_directory); - - return { - content: [ - { - type: 'text', - text: result, - }, - ], - }; + return { content: [{ type: 'text', text: result }] }; } case 'load_task': { const { task_id } = args as { task_id?: string }; - if (!task_id) { - throw new Error('task_id is required'); - } - + if (!task_id) throw new Error('task_id is required'); const result = await loadTask(task_id); - - return { - content: [ - { - type: 'text', - text: result.recoveryMessage, - }, - ], - }; + return { content: [{ type: 'text', text: result.recoveryMessage }] }; } case 'resume_task': { const { working_directory } = args as { working_directory?: string }; - if (!working_directory) { - throw new Error('working_directory is required'); - } - + if (!working_directory) throw new Error('working_directory is required'); const result = await resumeTask(working_directory); - - return { - content: [ - { - type: 'text', - text: `Task ID: ${result.taskId}\n\n${result.recoveryMessage}`, - }, - ], - }; + return { content: [{ type: 'text', text: `Task ID: ${result.taskId}\n\n${result.recoveryMessage}` }] }; } case 'list_tasks': { - const tasks = listTasks(); - + const tasks = await listTasks(); if (tasks.length === 0) { - return { - content: [ - { - type: 'text', - text: 'No saved tasks found.', - }, - ], - }; + return { content: [{ type: 'text', text: 'No saved tasks found.' }] }; } - const taskList = tasks - .map((t) => { - // New format: show slug and project - if (!t.isLegacy) { - return `- ${t.taskId} | ${t.slug} | ${t.project} | Created: ${t.created.toISOString()}`; - } - // Legacy format: show pathInfo (truncated if too long) - const pathInfo = t.pathInfo && t.pathInfo.length > 50 - ? t.pathInfo.substring(0, 47) + '...' - : t.pathInfo; - return `- ${t.taskId} | (legacy) ${pathInfo} | Created: ${t.created.toISOString()}`; - }) + .map((t) => `- ${t.taskId} | ${t.slug} | ${t.project} | Created: ${t.created.toISOString()}`) .join('\n'); - - return { - content: [ - { - type: 'text', - text: `Saved tasks:\n${taskList}`, - }, - ], - }; + return { content: [{ type: 'text', text: `Saved tasks:\n${taskList}` }] }; } case 'append_to_task': { @@ -619,21 +410,9 @@ async function main() { task_id?: string; working_directory?: string; }; - - if (!content || !type) { - throw new Error('content and type are required'); - } - + if (!content || !type) throw new Error('content and type are required'); const result = await appendToTask(content, type, task_id, working_directory); - - return { - content: [ - { - type: 'text', - text: result, - }, - ], - }; + return { content: [{ type: 'text', text: result }] }; } default: @@ -641,23 +420,12 @@ async function main() { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { - content: [ - { - type: 'text', - text: `Error: ${message}`, - }, - ], - isError: true, - }; + return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }; } }); - // Start server const transport = new StdioServerTransport(); await server.connect(transport); - - // Keep process alive process.stdin.resume(); }