import os from 'node:os'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { sessionsDb } from '@/modules/database/index.js'; import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfter, normalizeSessionName, readFileTimestamps, } from '@/shared/utils.js'; import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; type ParsedSession = { sessionId: string; projectPath: string; sessionName?: string; }; /** * Session indexer for Claude transcript artifacts. */ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { private readonly provider = 'claude' as const; private readonly claudeHome = path.join(os.homedir(), '.claude'); /** * Scans ~/.claude/projects and upserts discovered sessions into DB. */ async synchronize(since?: Date): Promise { const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); const files = await findFilesRecursivelyCreatedAfter( path.join(this.claudeHome, 'projects'), '.jsonl', since ?? null ); let processed = 0; for (const filePath of files) { const parsed = await this.processSessionFile(filePath, nameMap); if (!parsed) { continue; } const timestamps = await readFileTimestamps(filePath); sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, filePath ); processed += 1; } return processed; } /** * Parses and upserts one Claude session JSONL file. */ async synchronizeFile(filePath: string): Promise { if (!filePath.endsWith('.jsonl')) { return null; } const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); const parsed = await this.processSessionFile(filePath, nameMap); if (!parsed) { return null; } const timestamps = await readFileTimestamps(filePath); return sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, parsed.sessionName, timestamps.createdAt, timestamps.updatedAt, filePath ); } /** * Extracts session metadata from one Claude JSONL session file. */ private async processSessionFile( filePath: string, nameMap: Map ): Promise { const parsed = await extractFirstValidJsonlData(filePath, (rawData) => { const data = rawData as Record; const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined; if (!sessionId || !projectPath) { return null; } return { sessionId, projectPath, }; }); if (!parsed) { return null; } const existingSession = sessionsDb.getSessionById(parsed.sessionId); const existingSessionName = existingSession?.custom_name; if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { return { ...parsed, sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'), }; } let sessionName = nameMap.get(parsed.sessionId); if (!sessionName) { sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId); } return { ...parsed, sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'), }; } private async extractSessionAiTitleFromEnd( filePath: string, sessionId: string ): Promise { try { const content = await readFile(filePath, 'utf8'); const lines = content.split(/\r?\n/); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]?.trim(); if (!line) { continue; } let parsed: unknown; try { parsed = JSON.parse(line); } catch { continue; } const data = parsed as Record; const eventType = typeof data.type === 'string' ? data.type : undefined; const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined; const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined; if ( (eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) || (eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim()) ) { return aiTitle || lastPrompt || claudeRenamedTitle; } } } catch { // Ignore missing/unreadable files so sync can continue. } return undefined; } }