import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import readline from 'node:readline'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; import { sessionsDb } from '@/modules/database/index.js'; const PROVIDER = 'claude'; type ClaudeToolResult = { content: unknown; isError: boolean; subagentTools?: unknown; toolUseResult?: unknown; }; type ClaudeHistoryResult = | AnyRecord[] | { messages?: AnyRecord[]; total?: number; hasMore?: boolean; }; type ClaudeHistoryMessagesResult = | AnyRecord[] | { messages: AnyRecord[]; total: number; hasMore: boolean; offset?: number; limit?: number | null; }; async function parseAgentTools(filePath: string): Promise { const tools: AnyRecord[] = []; try { const fileStream = fs.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); for await (const line of rl) { if (!line.trim()) { continue; } try { const entry = JSON.parse(line) as AnyRecord; if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) { for (const part of entry.message.content as AnyRecord[]) { if (part.type === 'tool_use') { tools.push({ toolId: part.id, toolName: part.name, toolInput: part.input, timestamp: entry.timestamp, }); } } } if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) { for (const part of entry.message.content as AnyRecord[]) { if (part.type !== 'tool_result') { continue; } const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id); if (!tool) { continue; } tool.toolResult = { content: typeof part.content === 'string' ? part.content : Array.isArray(part.content) ? part.content .map((contentPart: AnyRecord) => contentPart?.text || '') .join('\n') : JSON.stringify(part.content), isError: Boolean(part.is_error), }; } } } catch { // Skip malformed lines that can happen during concurrent writes. } } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`Error parsing agent file ${filePath}:`, message); } return tools; } async function getSessionMessages( sessionId: string, limit: number | null, offset: number, ): Promise { try { const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; if (!jsonLPath) { return { messages: [], total: 0, hasMore: false }; } const projectDir = path.dirname(jsonLPath); const files = await fsp.readdir(projectDir); const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-')); const messages: AnyRecord[] = []; const agentToolsCache = new Map(); const fileStream = fs.createReadStream(jsonLPath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); for await (const line of rl) { if (!line.trim()) { continue; } try { const entry = JSON.parse(line) as AnyRecord; if (entry.sessionId === sessionId) { messages.push(entry); } } catch { // Skip malformed JSONL lines that can happen during concurrent writes. } } const agentIds = new Set(); for (const message of messages) { const agentId = message.toolUseResult?.agentId; if (agentId) { agentIds.add(String(agentId)); } } for (const agentId of agentIds) { const agentFileName = `agent-${agentId}.jsonl`; if (!agentFiles.includes(agentFileName)) { continue; } const agentFilePath = path.join(projectDir, agentFileName); const tools = await parseAgentTools(agentFilePath); agentToolsCache.set(agentId, tools); } for (const message of messages) { const agentId = message.toolUseResult?.agentId; if (!agentId) { continue; } const agentTools = agentToolsCache.get(String(agentId)); if (agentTools && agentTools.length > 0) { message.subagentTools = agentTools; } } const sortedMessages = messages.sort( (a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(), ); const total = sortedMessages.length; if (limit === null) { return sortedMessages; } const startIndex = Math.max(0, total - offset - limit); const endIndex = total - offset; const paginatedMessages = sortedMessages.slice(startIndex, endIndex); const hasMore = startIndex > 0; return { messages: paginatedMessages, total, hasMore, offset, limit, }; } catch (error) { console.error(`Error reading messages for session ${sessionId}:`, error); return limit === null ? [] : { messages: [], total: 0, hasMore: false }; } } /** * Claude writes internal command and system reminder entries into history. * Those are useful for the CLI but should not appear in the user-facing chat. */ const INTERNAL_CONTENT_PREFIXES = [ '', '', '', '', '', 'Caveat:', 'This session is being continued from a previous', '[Request interrupted', ] as const; function isInternalContent(content: string): boolean { return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix)); } export class ClaudeSessionsProvider implements IProviderSessions { /** * Normalizes one Claude JSONL entry or live SDK stream event into the shared * message shape consumed by REST and WebSocket clients. */ normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { const raw = readObjectRecord(rawMessage); if (!raw) { return []; } if (raw.type === 'content_block_delta' && raw.delta?.text) { return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })]; } if (raw.type === 'content_block_stop') { return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })]; } const messages: NormalizedMessage[] = []; const ts = raw.timestamp || new Date().toISOString(); const baseId = raw.uuid || generateMessageId('claude'); if (raw.message?.role === 'user' && raw.message?.content) { if (Array.isArray(raw.message.content)) { for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) { const part = raw.message.content[partIndex]; if (part.type === 'tool_result') { messages.push(createNormalizedMessage({ id: `${baseId}_tr_${part.tool_use_id}`, sessionId, timestamp: ts, provider: PROVIDER, kind: 'tool_result', toolId: part.tool_use_id, content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content), isError: Boolean(part.is_error), subagentTools: raw.subagentTools, toolUseResult: raw.toolUseResult, })); } else if (part.type === 'text') { const text = part.text || ''; if (text && !isInternalContent(text)) { messages.push(createNormalizedMessage({ id: `${baseId}_text_${partIndex}`, sessionId, timestamp: ts, provider: PROVIDER, kind: 'text', role: 'user', content: text, })); } } } if (messages.length === 0) { const textParts = raw.message.content .filter((part: AnyRecord) => part.type === 'text') .map((part: AnyRecord) => part.text) .filter(Boolean) .join('\n'); if (textParts && !isInternalContent(textParts)) { messages.push(createNormalizedMessage({ id: `${baseId}_text`, sessionId, timestamp: ts, provider: PROVIDER, kind: 'text', role: 'user', content: textParts, })); } } } else if (typeof raw.message.content === 'string') { const text = raw.message.content; if (text && !isInternalContent(text)) { messages.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'text', role: 'user', content: text, })); } } return messages; } if (raw.type === 'thinking' && raw.message?.content) { messages.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'thinking', content: raw.message.content, })); return messages; } if (raw.type === 'tool_use' && raw.toolName) { messages.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'tool_use', toolName: raw.toolName, toolInput: raw.toolInput, toolId: raw.toolCallId || baseId, })); return messages; } if (raw.type === 'tool_result') { messages.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'tool_result', toolId: raw.toolCallId || '', content: raw.output || '', isError: false, })); return messages; } if (raw.message?.role === 'assistant' && raw.message?.content) { if (Array.isArray(raw.message.content)) { let partIndex = 0; for (const part of raw.message.content) { if (part.type === 'text' && part.text) { messages.push(createNormalizedMessage({ id: `${baseId}_${partIndex}`, sessionId, timestamp: ts, provider: PROVIDER, kind: 'text', role: 'assistant', content: part.text, })); } else if (part.type === 'tool_use') { messages.push(createNormalizedMessage({ id: `${baseId}_${partIndex}`, sessionId, timestamp: ts, provider: PROVIDER, kind: 'tool_use', toolName: part.name, toolInput: part.input, toolId: part.id, })); } else if (part.type === 'thinking' && part.thinking) { messages.push(createNormalizedMessage({ id: `${baseId}_${partIndex}`, sessionId, timestamp: ts, provider: PROVIDER, kind: 'thinking', content: part.thinking, })); } partIndex++; } } else if (typeof raw.message.content === 'string') { messages.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'text', role: 'assistant', content: raw.message.content, })); } return messages; } return messages; } /** * Loads Claude JSONL history for a project/session and returns normalized * messages, preserving the existing pagination behavior from projects.js. */ async fetchHistory( sessionId: string, options: FetchHistoryOptions = {}, ): Promise { const { limit = null, offset = 0 } = options; let result: ClaudeHistoryResult; try { result = await getSessionMessages(sessionId, limit, offset); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; } const rawMessages = Array.isArray(result) ? result : (result.messages || []); const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); const toolResultMap = new Map(); for (const raw of rawMessages) { if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) { for (const part of raw.message.content) { if (part.type === 'tool_result' && part.tool_use_id) { toolResultMap.set(part.tool_use_id, { content: part.content, isError: Boolean(part.is_error), subagentTools: raw.subagentTools, toolUseResult: raw.toolUseResult, }); } } } } const normalized: NormalizedMessage[] = []; for (const raw of rawMessages) { normalized.push(...this.normalizeMessage(raw, sessionId)); } for (const msg of normalized) { if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { const toolResult = toolResultMap.get(msg.toolId); if (!toolResult) { continue; } msg.toolResult = { content: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content), isError: toolResult.isError, toolUseResult: toolResult.toolUseResult, }; msg.subagentTools = toolResult.subagentTools; } } return { messages: normalized, total, hasMore, offset, limit, }; } }