UNPKG

claude-sesh

Version:

Session explorer for Claude Code - find, search, and resume all your past sessions

600 lines 22 kB
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 { SessionParser } from '../services/parser.js'; import { enricher } from '../services/enricher.js'; const parser = new SessionParser(); // Create MCP server const server = new Server({ name: 'claude-sesh', version: '1.0.0', }, { capabilities: { tools: {}, }, }); // Define available tools const tools = [ { name: 'search_sessions', description: 'Search across all Claude Code sessions by query text. Searches summaries, file names, project names, and enriched data.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query text', }, project: { type: 'string', description: 'Filter by project name (optional)', }, limit: { type: 'number', description: 'Max results to return (default: 10)', }, }, required: ['query'], }, }, { name: 'get_session', description: 'Get detailed information about a specific session by ID', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'The session ID to retrieve', }, }, required: ['sessionId'], }, }, { name: 'get_project_context', description: 'Get rich context for a project including recent sessions, decisions, todos, and accumulated knowledge', inputSchema: { type: 'object', properties: { projectPath: { type: 'string', description: 'Project path or name to get context for', }, limit: { type: 'number', description: 'Number of recent sessions to include (default: 10)', }, }, required: ['projectPath'], }, }, { name: 'get_pending_todos', description: 'Get all pending/incomplete todos across sessions, optionally filtered by project', inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Filter by project name (optional)', }, limit: { type: 'number', description: 'Max sessions to check (default: 50)', }, }, }, }, { name: 'search_decisions', description: 'Search through past decisions made in sessions. Useful for "why did we choose X?" questions.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for decisions', }, project: { type: 'string', description: 'Filter by project name (optional)', }, limit: { type: 'number', description: 'Max results (default: 20)', }, }, required: ['query'], }, }, { name: 'search_knowledge', description: 'Search through accumulated knowledge from past sessions. Useful for finding solutions to similar problems.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for knowledge', }, project: { type: 'string', description: 'Filter by project name (optional)', }, limit: { type: 'number', description: 'Max results (default: 20)', }, }, required: ['query'], }, }, { name: 'get_file_history', description: 'Get the history of changes to a specific file across sessions', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Full or partial file path to search for', }, limit: { type: 'number', description: 'Max sessions to return (default: 20)', }, }, required: ['filePath'], }, }, { name: 'get_recent_activity', description: 'Get a summary of recent coding activity across all projects', inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'Number of days to look back (default: 7)', }, }, }, }, ]; // List tools handler server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools }; }); // Tool execution handler server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'search_sessions': { const query = args?.query || ''; const project = args?.project; const limit = args?.limit || 10; const results = searchSessions(query, project, limit); return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], }; } case 'get_session': { const sessionId = args?.sessionId; if (!sessionId) { return { content: [{ type: 'text', text: 'Error: sessionId is required' }], isError: true, }; } const session = parser.getSession(sessionId); if (!session) { return { content: [{ type: 'text', text: `Session not found: ${sessionId}` }], isError: true, }; } const enriched = enricher.getEnrichedData(sessionId); const todos = parser.getSessionTodos(sessionId); return { content: [{ type: 'text', text: JSON.stringify({ ...session, startedAt: session.startedAt.toISOString(), endedAt: session.endedAt?.toISOString(), enriched, todos, }, null, 2), }], }; } case 'get_project_context': { const projectPath = args?.projectPath; const limit = args?.limit || 10; if (!projectPath) { return { content: [{ type: 'text', text: 'Error: projectPath is required' }], isError: true, }; } const context = getProjectContext(projectPath, limit); return { content: [{ type: 'text', text: JSON.stringify(context, null, 2) }], }; } case 'get_pending_todos': { const project = args?.project; const limit = args?.limit || 50; const todos = getPendingTodos(project, limit); return { content: [{ type: 'text', text: JSON.stringify(todos, null, 2) }], }; } case 'search_decisions': { const query = args?.query || ''; const project = args?.project; const limit = args?.limit || 20; const decisions = searchDecisions(query, project, limit); return { content: [{ type: 'text', text: JSON.stringify(decisions, null, 2) }], }; } case 'search_knowledge': { const query = args?.query || ''; const project = args?.project; const limit = args?.limit || 20; const knowledge = searchKnowledge(query, project, limit); return { content: [{ type: 'text', text: JSON.stringify(knowledge, null, 2) }], }; } case 'get_file_history': { const filePath = args?.filePath; const limit = args?.limit || 20; if (!filePath) { return { content: [{ type: 'text', text: 'Error: filePath is required' }], isError: true, }; } const history = getFileHistory(filePath, limit); return { content: [{ type: 'text', text: JSON.stringify(history, null, 2) }], }; } case 'get_recent_activity': { const days = args?.days || 7; const activity = getRecentActivity(days); return { content: [{ type: 'text', text: JSON.stringify(activity, null, 2) }], }; } default: return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true, }; } } catch (error) { return { content: [{ type: 'text', text: `Error executing ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; } }); // ==================== Tool Implementation Functions ==================== function searchSessions(query, project, limit = 10) { const sessions = parser.getAllSessions(500); const queryLower = query.toLowerCase(); const results = sessions .filter(s => { // Project filter if (project && !s.projectName.toLowerCase().includes(project.toLowerCase())) { return false; } // Text search across multiple fields (metadata) const searchableText = [ s.projectName, s.projectPath, ...s.summaries, ...s.filesRead, ...s.filesWritten, ...s.filesEdited, s.gitBranch || '', ].join(' ').toLowerCase(); if (searchableText.includes(queryLower)) { return true; } // Also search enriched data const enriched = enricher.getEnrichedData(s.id); if (enriched) { const enrichedText = [ enriched.summary || '', ...(enriched.decisions || []), ...(enriched.problems || []), ...(enriched.knowledge || []), ...(enriched.tags || []), ].join(' ').toLowerCase(); if (enrichedText.includes(queryLower)) { return true; } } // Search raw message content (user messages only for speed) const messages = parser.getRawMessages(s.id); for (const msg of messages) { if (msg.type === 'user' && msg.message?.content) { const content = typeof msg.message.content === 'string' ? msg.message.content : JSON.stringify(msg.message.content); if (content.toLowerCase().includes(queryLower)) { return true; } } } return false; }) .slice(0, limit) .map(s => { const enriched = enricher.getEnrichedData(s.id); return { id: s.id, project: s.projectName, date: s.startedAt.toISOString(), summary: enriched?.summary || s.summaries[0] || 'No summary', tokens: s.totalTokens, duration: formatDuration(s.durationSeconds), model: s.model, }; }); return { query, resultCount: results.length, results, }; } function getProjectContext(projectPath, limit = 10) { // Normalize project path for matching const normalizedPath = projectPath.toLowerCase().replace(/\//g, '-').replace(/^-/, ''); const sessions = parser.getAllSessions(200) .filter(s => { const sessionPath = s.projectPath.toLowerCase().replace(/\//g, '-'); return sessionPath.includes(normalizedPath) || s.projectName.toLowerCase().includes(projectPath.toLowerCase()); }) .slice(0, limit); if (sessions.length === 0) { return { found: false, message: `No sessions found for project: ${projectPath}`, }; } // Aggregate data from sessions const allDecisions = []; const allKnowledge = []; const allProblems = []; const pendingTodos = []; for (const session of sessions) { const enriched = enricher.getEnrichedData(session.id); if (enriched) { allDecisions.push(...(enriched.decisions || [])); allKnowledge.push(...(enriched.knowledge || [])); // Map problem objects to readable strings const problemStrings = (enriched.problems || []).map(p => `${p.issue}: ${p.solution}`); allProblems.push(...problemStrings); } const todos = parser.getSessionTodos(session.id); const pending = todos.filter(t => t.status !== 'completed'); for (const todo of pending) { pendingTodos.push({ content: todo.content, sessionId: session.id, date: session.startedAt.toISOString(), }); } } const totalTokens = sessions.reduce((sum, s) => sum + s.totalTokens, 0); const totalDuration = sessions.reduce((sum, s) => sum + s.durationSeconds, 0); return { found: true, project: sessions[0].projectPath, sessionCount: sessions.length, totalTokens, totalDuration: formatDuration(totalDuration), recentSessions: sessions.slice(0, 5).map(s => ({ id: s.id, date: s.startedAt.toISOString(), summary: enricher.getEnrichedData(s.id)?.summary || s.summaries[0] || 'No summary', tokens: s.totalTokens, })), keyDecisions: [...new Set(allDecisions)].slice(0, 10), accumulatedKnowledge: [...new Set(allKnowledge)].slice(0, 10), problemsSolved: [...new Set(allProblems)].slice(0, 10), pendingTodos: pendingTodos.slice(0, 10), }; } function getPendingTodos(project, limit = 50) { const sessions = parser.getAllSessions(limit); const pendingTodos = []; for (const session of sessions) { // Project filter if (project && !session.projectName.toLowerCase().includes(project.toLowerCase())) { continue; } const todos = parser.getSessionTodos(session.id); const pending = todos.filter(t => t.status !== 'completed'); for (const todo of pending) { pendingTodos.push({ content: todo.content, sessionId: session.id, project: session.projectName, date: session.startedAt.toISOString(), }); } } return { count: pendingTodos.length, todos: pendingTodos, }; } function searchDecisions(query, project, limit = 20) { const sessions = parser.getAllSessions(200); const queryLower = query.toLowerCase(); const matchingDecisions = []; for (const session of sessions) { // Project filter if (project && !session.projectName.toLowerCase().includes(project.toLowerCase())) { continue; } const enriched = enricher.getEnrichedData(session.id); if (!enriched?.decisions) continue; for (const decision of enriched.decisions) { if (decision.toLowerCase().includes(queryLower)) { matchingDecisions.push({ decision, sessionId: session.id, project: session.projectName, date: session.startedAt.toISOString(), context: enriched.summary, }); } } if (matchingDecisions.length >= limit) break; } return { query, count: matchingDecisions.length, decisions: matchingDecisions.slice(0, limit), }; } function searchKnowledge(query, project, limit = 20) { const sessions = parser.getAllSessions(200); const queryLower = query.toLowerCase(); const matchingKnowledge = []; for (const session of sessions) { // Project filter if (project && !session.projectName.toLowerCase().includes(project.toLowerCase())) { continue; } const enriched = enricher.getEnrichedData(session.id); if (!enriched?.knowledge) continue; for (const item of enriched.knowledge) { if (item.toLowerCase().includes(queryLower)) { matchingKnowledge.push({ knowledge: item, sessionId: session.id, project: session.projectName, date: session.startedAt.toISOString(), relatedProblems: (enriched.problems || []).map(p => `${p.issue}: ${p.solution}`), }); } } if (matchingKnowledge.length >= limit) break; } return { query, count: matchingKnowledge.length, knowledge: matchingKnowledge.slice(0, limit), }; } function getFileHistory(filePath, limit = 20) { const sessions = parser.getAllSessions(500); const filePathLower = filePath.toLowerCase(); const history = []; for (const session of sessions) { const actions = [ ...session.filesRead.map(f => ({ file: f, action: 'read' })), ...session.filesWritten.map(f => ({ file: f, action: 'written' })), ...session.filesEdited.map(f => ({ file: f, action: 'edited' })), ]; for (const { file, action } of actions) { if (file.toLowerCase().includes(filePathLower)) { history.push({ sessionId: session.id, project: session.projectName, date: session.startedAt.toISOString(), action, summary: enricher.getEnrichedData(session.id)?.summary || session.summaries[0], }); } } if (history.length >= limit) break; } // Sort by date, most recent first history.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return { file: filePath, touchCount: history.length, history: history.slice(0, limit), }; } function getRecentActivity(days = 7) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const sessions = parser.getAllSessions(500) .filter(s => s.startedAt >= cutoff); // Aggregate by project const projectStats = new Map(); for (const session of sessions) { const stats = projectStats.get(session.projectName) || { sessions: 0, tokens: 0, duration: 0, lastActive: new Date(0), }; stats.sessions++; stats.tokens += session.totalTokens; stats.duration += session.durationSeconds; if (session.startedAt > stats.lastActive) { stats.lastActive = session.startedAt; } projectStats.set(session.projectName, stats); } // Sort projects by last active const projects = Array.from(projectStats.entries()) .sort((a, b) => b[1].lastActive.getTime() - a[1].lastActive.getTime()) .map(([name, stats]) => ({ name, sessions: stats.sessions, tokens: stats.tokens, duration: formatDuration(stats.duration), lastActive: stats.lastActive.toISOString(), })); const totalTokens = sessions.reduce((sum, s) => sum + s.totalTokens, 0); const totalDuration = sessions.reduce((sum, s) => sum + s.durationSeconds, 0); return { period: `Last ${days} days`, totalSessions: sessions.length, totalTokens, totalDuration: formatDuration(totalDuration), activeProjects: projects.length, projects: projects.slice(0, 10), }; } function formatDuration(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); return `${hours}h ${mins}m`; } // Start the server export async function startMCPServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('claude-sesh MCP server running'); } // Run if executed directly startMCPServer().catch(console.error); //# sourceMappingURL=server.js.map