UNPKG

claude-code-conversation-search-mcp

Version:

Never lose your Claude Code conversations again. Search across all projects, find old chats, and resume where you left off.

250 lines (212 loc) 8.2 kB
import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; import { ConversationMessage, IndexedMessage } from '../types/index.js'; export class ConversationParser { private projectsPath: string; constructor(projectsPath: string = path.join(process.env.HOME!, '.claude', 'projects')) { this.projectsPath = projectsPath; } async *getAllConversationFiles(): AsyncGenerator<{ filePath: string; projectName: string }> { const projects = await fs.promises.readdir(this.projectsPath); for (const project of projects) { const projectPath = path.join(this.projectsPath, project); const stats = await fs.promises.stat(projectPath); if (stats.isDirectory()) { const files = await fs.promises.readdir(projectPath); const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); for (const file of jsonlFiles) { yield { filePath: path.join(projectPath, file), projectName: this.decodeProjectName(project) }; } } } } private decodeProjectName(encodedName: string): string { // Convert encoded project name back to readable path // Use the same intelligent decoding logic as result-formatter const decodedPath = this.intelligentDecode(encodedName); return decodedPath.replace(/^\//, ''); // Remove leading slash if present } private intelligentDecode(encodedPath: string): string { // Handle the leading -Users- pattern first let decodedPath = encodedPath.replace(/^-Users-/, '/Users/'); // Apply specific pattern replacements in careful order (most specific first) const replacements: Array<[RegExp, string]> = [ // Handle full compound paths first [/-claude-mcp-servers-conversation-search$/gi, '/claude-mcp-servers/conversation-search'], // Specific domain patterns [/-ai-value-to-/gi, '/ai.value.to/'], // Common folder names with proper capitalization [/-dropbox-/gi, '/Dropbox/'], // Handle numbered folders: convert to path first, then handle spaces [/-(\d{2})-([a-z]+)-/gi, '/$1-$2/'], // Convert to path segment first // Convert remaining dashes to slashes [/-/g, '/'], // Now handle spaces in numbered folders after path conversion [/\/(\d{2})-([a-z]+)\//gi, '/$1 $2/'] // "04-clients" -> "04 clients" ]; // Apply replacements in order for (const [pattern, replacement] of replacements) { decodedPath = decodedPath.replace(pattern, replacement); } // Handle selective capitalization separately decodedPath = decodedPath.replace(/\/([a-z]+)\//gi, (match: string, word: string) => { // Only capitalize specific known folder names, not everything const shouldCapitalize = ['clients', 'dropbox'].includes(word.toLowerCase()); return shouldCapitalize ? `/${word.charAt(0).toUpperCase() + word.slice(1)}/` : match; }); // Clean up double slashes decodedPath = decodedPath.replace(/\/+/g, '/'); return decodedPath; } async *parseConversationFile(filePath: string): AsyncGenerator<ConversationMessage> { const fileStream = fs.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const message = JSON.parse(line) as ConversationMessage; yield message; } catch (err) { // Log but continue parsing other lines if (process.env.DEBUG === 'true') { // eslint-disable-next-line no-console console.error(`[PARSER] Failed to parse line in ${filePath}:`, err); } } } } } async getSessionIdFromFile(filePath: string): Promise<string | null> { const fileStream = fs.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const message = JSON.parse(line) as ConversationMessage; if (message.sessionId) { rl.close(); return message.sessionId; } } catch (err) { // Log but continue to next line if (process.env.DEBUG === 'true') { // eslint-disable-next-line no-console console.error(`[PARSER] Failed to parse session ID from ${filePath}:`, err); } } } } return null; } extractSearchableContent(message: ConversationMessage): string { const parts: string[] = []; // Extract message content if (message.message) { if (typeof message.message.content === 'string') { parts.push(message.message.content); } else if (Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'text' && item.text) { parts.push(item.text); } else if (item.type === 'tool_use' && item.input) { parts.push(JSON.stringify(item.input)); } } } } // Extract tool use results if (message.toolUseResult) { if (typeof message.toolUseResult === 'object' && message.toolUseResult !== null) { const result = message.toolUseResult as { stdout?: string; stderr?: string }; if (result.stdout) { parts.push(result.stdout); } if (result.stderr) { parts.push(result.stderr); } } if (typeof message.toolUseResult === 'string') { parts.push(message.toolUseResult); } } return parts.join(' ').toLowerCase(); } extractToolOperations(message: ConversationMessage): IndexedMessage['toolOperations'] { const operations: IndexedMessage['toolOperations'] = []; if (message.message?.content && Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'tool_use') { const op: { type: string; description?: string; filePaths?: string[]; commands?: string[] } = { type: item.name, description: item.input?.description }; // Extract file paths if (item.input?.file_path) { op.filePaths = [item.input.file_path]; } else if (item.input?.path) { op.filePaths = [item.input.path]; } // Extract commands if (item.input?.command) { op.commands = [item.input.command]; } operations.push(op); } } } return operations.length > 0 ? operations : undefined; } convertToIndexedMessage( message: ConversationMessage, conversationId: string, projectPath: string, projectName: string ): IndexedMessage | null { // Skip meta messages if (message.isMeta) { return null; } const searchableText = this.extractSearchableContent(message); const toolOperations = this.extractToolOperations(message); // Determine message type let messageType: IndexedMessage['type'] = message.type as IndexedMessage['type']; if (message.toolUseResult) { messageType = 'tool_result'; } else if (toolOperations && toolOperations.length > 0) { messageType = 'tool_use'; } // Validate required fields if (!message.uuid) { return null; // Skip messages without UUID } // Validate timestamp const timestamp = message.timestamp ? new Date(message.timestamp) : new Date(); if (isNaN(timestamp.getTime())) { return null; // Skip messages with invalid timestamps } return { id: `${conversationId}_${message.uuid}`, conversationId, projectPath, projectName, timestamp, type: messageType, content: searchableText.substring(0, 1000), // Truncate for display rawContent: message.message || message.toolUseResult, toolOperations, searchableText, messageUuid: message.uuid, parentUuid: message.parentUuid }; } }