diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 72bbe07e..eacf3f00 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -1,7 +1,13 @@ -import { getSessionMessages } from '@/projects.js'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +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'; @@ -15,17 +21,186 @@ type ClaudeToolResult = { type ClaudeHistoryResult = | AnyRecord[] | { - messages?: AnyRecord[]; - total?: number; - hasMore?: boolean; - }; + messages?: AnyRecord[]; + total?: number; + hasMore?: boolean; + }; -const loadClaudeSessionMessages = getSessionMessages as unknown as ( +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( projectName: string, sessionId: string, limit: number | null, offset: number, -) => Promise; +): Promise { + const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + + try { + const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; + + if (!jsonLPath) { + return { messages: [], total: 0, hasMore: false }; + } + + 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. @@ -245,7 +420,7 @@ export class ClaudeSessionsProvider implements IProviderSessions { let result: ClaudeHistoryResult; try { - result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset); + result = await getSessionMessages(projectName, sessionId, limit, offset); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index 1ea986f7..8540d27a 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -1,4 +1,7 @@ -import { getCodexSessionMessages } from '@/projects.js'; +import fsSync from 'node:fs'; +import readline from 'node:readline'; + +import { sessionsDb } from '@/modules/database/index.js'; 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'; @@ -11,14 +14,250 @@ type CodexHistoryResult = messages?: AnyRecord[]; total?: number; hasMore?: boolean; + offset?: number; + limit?: number | null; tokenUsage?: unknown; }; -const loadCodexSessionMessages = getCodexSessionMessages as unknown as ( +function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean { + if (!payload || payload.type !== 'user_message') { + return false; + } + + if (payload.kind && payload.kind !== 'plain') { + return false; + } + + return typeof payload.message === 'string' && payload.message.trim().length > 0; +} + +function extractCodexTextContent(content: unknown): string { + if (!Array.isArray(content)) { + return typeof content === 'string' ? content : ''; + } + + return content + .map((item) => { + if (!item || typeof item !== 'object') { + return ''; + } + + const record = item as AnyRecord; + if ( + (record.type === 'input_text' || record.type === 'output_text' || record.type === 'text') + && typeof record.text === 'string' + ) { + return record.text; + } + + return ''; + }) + .filter(Boolean) + .join('\n'); +} + +async function getCodexSessionMessages( sessionId: string, - limit: number | null, - offset: number, -) => Promise; + limit: number | null = null, + offset = 0, +): Promise { + try { + const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; + + if (!sessionFilePath) { + console.warn(`Codex session file not found for session ${sessionId}`); + return { messages: [], total: 0, hasMore: false }; + } + + const messages: AnyRecord[] = []; + let tokenUsage: AnyRecord | null = null; + const fileStream = fsSync.createReadStream(sessionFilePath); + 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.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { + const info = entry.payload.info as AnyRecord; + if (info.total_token_usage) { + const usage = info.total_token_usage as AnyRecord; + tokenUsage = { + used: usage.total_tokens || 0, + total: info.model_context_window || 200000, + }; + } + } + + if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) { + messages.push({ + type: 'user', + timestamp: entry.timestamp, + message: { + role: 'user', + content: entry.payload.message, + }, + }); + } + + if ( + entry.type === 'response_item' && + entry.payload?.type === 'message' && + entry.payload.role === 'assistant' + ) { + const textContent = extractCodexTextContent(entry.payload.content); + if (textContent.trim()) { + messages.push({ + type: 'assistant', + timestamp: entry.timestamp, + message: { + role: 'assistant', + content: textContent, + }, + }); + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') { + const summaryText = Array.isArray(entry.payload.summary) + ? entry.payload.summary + .map((item: AnyRecord) => item?.text) + .filter(Boolean) + .join('\n') + : ''; + + if (summaryText.trim()) { + messages.push({ + type: 'thinking', + timestamp: entry.timestamp, + message: { + role: 'assistant', + content: summaryText, + }, + }); + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { + let toolName = entry.payload.name; + let toolInput = entry.payload.arguments; + + if (toolName === 'shell_command') { + toolName = 'Bash'; + try { + const args = JSON.parse(entry.payload.arguments) as AnyRecord; + toolInput = JSON.stringify({ command: args.command }); + } catch { + // Keep original arguments when parsing fails. + } + } + + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolName, + toolInput, + toolCallId: entry.payload.call_id, + }); + } + + if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') { + messages.push({ + type: 'tool_result', + timestamp: entry.timestamp, + toolCallId: entry.payload.call_id, + output: entry.payload.output, + }); + } + + if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') { + const toolName = entry.payload.name || 'custom_tool'; + const input = entry.payload.input || ''; + + if (toolName === 'apply_patch') { + const fileMatch = String(input).match(/\*\*\* Update File: (.+)/); + const filePath = fileMatch ? fileMatch[1].trim() : 'unknown'; + const lines = String(input).split('\n'); + const oldLines: string[] = []; + const newLines: string[] = []; + + for (const lineContent of lines) { + if (lineContent.startsWith('-') && !lineContent.startsWith('---')) { + oldLines.push(lineContent.slice(1)); + } else if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) { + newLines.push(lineContent.slice(1)); + } + } + + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolName: 'Edit', + toolInput: JSON.stringify({ + file_path: filePath, + old_string: oldLines.join('\n'), + new_string: newLines.join('\n'), + }), + toolCallId: entry.payload.call_id, + }); + } else { + messages.push({ + type: 'tool_use', + timestamp: entry.timestamp, + toolName, + toolInput: input, + toolCallId: entry.payload.call_id, + }); + } + } + + if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') { + messages.push({ + type: 'tool_result', + timestamp: entry.timestamp, + toolCallId: entry.payload.call_id, + output: entry.payload.output || '', + }); + } + } catch { + // Skip malformed lines. + } + } + + messages.sort( + (a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(), + ); + const total = messages.length; + + if (limit !== null) { + const startIndex = Math.max(0, total - offset - limit); + const endIndex = total - offset; + const paginatedMessages = messages.slice(startIndex, endIndex); + const hasMore = startIndex > 0; + + return { + messages: paginatedMessages, + total, + hasMore, + offset, + limit, + tokenUsage, + }; + } + + return { messages, tokenUsage }; + } catch (error) { + console.error(`Error reading Codex session messages for ${sessionId}:`, error); + return { messages: [], total: 0, hasMore: false }; + } +} export class CodexSessionsProvider implements IProviderSessions { /** @@ -275,7 +514,7 @@ export class CodexSessionsProvider implements IProviderSessions { let result: CodexHistoryResult; try { - result = await loadCodexSessionMessages(sessionId, limit, offset); + result = await getCodexSessionMessages(sessionId, limit, offset); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message); diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts index 7d5b5f1a..5362c080 100644 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -1,11 +1,51 @@ -import sessionManager from '@/sessionManager.js'; -import { getGeminiCliSessionMessages } from '@/projects.js'; +import fs from 'node:fs/promises'; + +import { sessionsDb } from '@/modules/database/index.js'; 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'; const PROVIDER = 'gemini'; +async function getGeminiCliSessionMessages(sessionId: string): Promise { + const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; + if (!sessionFilePath) { + return []; + } + + try { + const data = await fs.readFile(sessionFilePath, 'utf8'); + const session = JSON.parse(data) as AnyRecord; + const sourceMessages = Array.isArray(session.messages) ? session.messages : []; + + return sourceMessages.map((msg: AnyRecord) => { + const role = msg.type === 'user' + ? 'user' + : (msg.type === 'gemini' || msg.type === 'assistant') + ? 'assistant' + : msg.type; + + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content + .filter((part: AnyRecord) => part?.text) + .map((part: AnyRecord) => part.text) + .join('\n'); + } + + return { + type: 'message', + message: { role, content }, + timestamp: msg.timestamp || null, + }; + }); + } catch { + return []; + } +} + export class GeminiSessionsProvider implements IProviderSessions { /** * Normalizes live Gemini stream-json events into the shared message shape. @@ -108,8 +148,7 @@ export class GeminiSessionsProvider implements IProviderSessions { } /** - * Loads Gemini history from the in-memory session manager first, then falls - * back to Gemini CLI session files on disk. + * Loads Gemini history from Gemini CLI session files on disk. */ async fetchHistory( sessionId: string, @@ -119,11 +158,7 @@ export class GeminiSessionsProvider implements IProviderSessions { let rawMessages: AnyRecord[]; try { - rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[]; - - if (rawMessages.length === 0) { - rawMessages = await getGeminiCliSessionMessages(sessionId) as AnyRecord[]; - } + rawMessages = await getGeminiCliSessionMessages(sessionId); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message); diff --git a/server/projects.js b/server/projects.js index a09dfd43..af95c1da 100755 --- a/server/projects.js +++ b/server/projects.js @@ -32,7 +32,7 @@ import os from 'os'; import { generateDisplayName } from '@/modules/projects'; import sessionManager from './sessionManager.js'; -import { projectsDb, sessionsDb } from './modules/database/index.js'; +import { projectsDb } from './modules/database/index.js'; /** * Resolve the absolute project path for a database `projectId`. @@ -71,11 +71,6 @@ function claudeFolderNameFromPath(projectPath) { // Cache for extracted project directories const projectDirectoryCache = new Map(); -// Clear cache when needed (called when project files change) -function clearProjectDirectoryCache() { - projectDirectoryCache.clear(); -} - // Load project configuration file async function loadProjectConfig() { const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); @@ -507,164 +502,6 @@ async function parseJsonlSessions(filePath) { } } -// Parse an agent JSONL file and extract tool uses -async function parseAgentTools(filePath) { - const tools = []; - - try { - const fileStream = fsSync.createReadStream(filePath); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { - if (line.trim()) { - try { - const entry = JSON.parse(line); - // Look for assistant messages with tool_use - if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) { - for (const part of entry.message.content) { - if (part.type === 'tool_use') { - tools.push({ - toolId: part.id, - toolName: part.name, - toolInput: part.input, - timestamp: entry.timestamp - }); - } - } - } - // Look for tool results - if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) { - for (const part of entry.message.content) { - if (part.type === 'tool_result') { - // Find the matching tool and add result - const tool = tools.find(t => t.toolId === part.tool_use_id); - if (tool) { - tool.toolResult = { - content: typeof part.content === 'string' ? part.content : - Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') : - JSON.stringify(part.content), - isError: Boolean(part.is_error) - }; - } - } - } - } - } catch (parseError) { - // Skip malformed lines - } - } - } - } catch (error) { - console.warn(`Error parsing agent file ${filePath}:`, error.message); - } - - return tools; -} - -// Get messages for a specific session with pagination support -async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - - try { - const files = await fs.readdir(projectDir); - // agent-*.jsonl files contain subagent tool history - we'll process them separately - const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); - const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-')); - - if (jsonlFiles.length === 0) { - return { messages: [], total: 0, hasMore: false }; - } - - const messages = []; - // Map of agentId -> tools for subagent tool grouping - const agentToolsCache = new Map(); - - // Process all JSONL files to find messages for this session - for (const file of jsonlFiles) { - const jsonlFile = path.join(projectDir, file); - const fileStream = fsSync.createReadStream(jsonlFile); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { - if (line.trim()) { - try { - const entry = JSON.parse(line); - if (entry.sessionId === sessionId) { - messages.push(entry); - } - } catch (parseError) { - // Silently skip malformed JSONL lines (common with concurrent writes) - } - } - } - } - - // Collect agentIds from Task tool results - const agentIds = new Set(); - for (const message of messages) { - if (message.toolUseResult?.agentId) { - agentIds.add(message.toolUseResult.agentId); - } - } - - // Load agent tools for each agentId found - for (const agentId of agentIds) { - const agentFileName = `agent-${agentId}.jsonl`; - if (agentFiles.includes(agentFileName)) { - const agentFilePath = path.join(projectDir, agentFileName); - const tools = await parseAgentTools(agentFilePath); - agentToolsCache.set(agentId, tools); - } - } - - // Attach agent tools to their parent Task messages - for (const message of messages) { - if (message.toolUseResult?.agentId) { - const agentId = message.toolUseResult.agentId; - const agentTools = agentToolsCache.get(agentId); - if (agentTools && agentTools.length > 0) { - message.subagentTools = agentTools; - } - } - } - // Sort messages by timestamp - const sortedMessages = messages.sort((a, b) => - new Date(a.timestamp || 0) - new Date(b.timestamp || 0) - ); - - const total = sortedMessages.length; - - // If no limit is specified, return all messages (backward compatibility) - if (limit === null) { - return sortedMessages; - } - - // Apply pagination - for recent messages, we need to slice from the end - // offset 0 should give us the most recent messages - 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 }; - } -} - /** * ID-based wrapper around `deleteSession`. * @@ -734,54 +571,6 @@ async function deleteSession(projectName, sessionId) { } } -// Add a project manually to the config (without creating folders) -async function addProjectManually(projectPath, displayName = null) { - const absolutePath = path.resolve(projectPath); - - try { - // Check if the path exists - await fs.access(absolutePath); - } catch (error) { - throw new Error(`Path does not exist: ${absolutePath}`); - } - - // Generate project name (encode path for use as directory name) - const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-'); - - // Check if project already exists in config - const config = await loadProjectConfig(); - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - - if (config[projectName]) { - throw new Error(`Project already configured for path: ${absolutePath}`); - } - - // Allow adding projects even if the directory exists - this enables tracking - // existing Claude Code or Cursor projects in the UI - - // Add to config as manually added project - config[projectName] = { - manuallyAdded: true, - originalPath: absolutePath - }; - - if (displayName) { - config[projectName].displayName = displayName; - } - - await saveProjectConfig(config); - - - return { - name: projectName, - path: absolutePath, - fullPath: absolutePath, - displayName: displayName || await generateDisplayName(projectName, absolutePath), - sessions: [], - cursorSessions: [] - }; -} - function normalizeComparablePath(inputPath) { if (!inputPath || typeof inputPath !== 'string') { return ''; @@ -985,252 +774,6 @@ async function parseCodexSessionFile(filePath) { } } -// Get messages for a specific Codex session -async function getCodexSessionMessages(sessionId, limit = null, offset = 0) { - try { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - - // Find the session file by searching for the session ID - const findSessionFile = async (dir) => { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const found = await findSessionFile(fullPath); - if (found) return found; - } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) { - return fullPath; - } - } - } catch (error) { - // Skip directories we can't read - } - return null; - }; - - const sessionFilePath = await findSessionFile(codexSessionsDir); - - if (!sessionFilePath) { - console.warn(`Codex session file not found for session ${sessionId}`); - return { messages: [], total: 0, hasMore: false }; - } - - const messages = []; - let tokenUsage = null; - const fileStream = fsSync.createReadStream(sessionFilePath); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - // Helper to extract text from Codex content array - const extractText = (content) => { - if (!Array.isArray(content)) return content; - return content - .map(item => { - if (item.type === 'input_text' || item.type === 'output_text') { - return item.text; - } - if (item.type === 'text') { - return item.text; - } - return ''; - }) - .filter(Boolean) - .join('\n'); - }; - - for await (const line of rl) { - if (line.trim()) { - try { - const entry = JSON.parse(line); - - // Extract token usage from token_count events (keep latest) - if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { - const info = entry.payload.info; - if (info.total_token_usage) { - tokenUsage = { - used: info.total_token_usage.total_tokens || 0, - total: info.model_context_window || 200000 - }; - } - } - - // Use event_msg.user_message for user-visible inputs. - if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) { - messages.push({ - type: 'user', - timestamp: entry.timestamp, - message: { - role: 'user', - content: entry.payload.message - } - }); - } - - // response_item.message may include internal prompts for non-assistant roles. - // Keep only assistant output from response_item. - if ( - entry.type === 'response_item' && - entry.payload?.type === 'message' && - entry.payload.role === 'assistant' - ) { - const content = entry.payload.content; - const textContent = extractText(content); - - // Only add if there's actual content - if (textContent?.trim()) { - messages.push({ - type: 'assistant', - timestamp: entry.timestamp, - message: { - role: 'assistant', - content: textContent - } - }); - } - } - - if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') { - const summaryText = entry.payload.summary - ?.map(s => s.text) - .filter(Boolean) - .join('\n'); - if (summaryText?.trim()) { - messages.push({ - type: 'thinking', - timestamp: entry.timestamp, - message: { - role: 'assistant', - content: summaryText - } - }); - } - } - - if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { - let toolName = entry.payload.name; - let toolInput = entry.payload.arguments; - - // Map Codex tool names to Claude equivalents - if (toolName === 'shell_command') { - toolName = 'Bash'; - try { - const args = JSON.parse(entry.payload.arguments); - toolInput = JSON.stringify({ command: args.command }); - } catch (e) { - // Keep original if parsing fails - } - } - - messages.push({ - type: 'tool_use', - timestamp: entry.timestamp, - toolName: toolName, - toolInput: toolInput, - toolCallId: entry.payload.call_id - }); - } - - if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') { - messages.push({ - type: 'tool_result', - timestamp: entry.timestamp, - toolCallId: entry.payload.call_id, - output: entry.payload.output - }); - } - - if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') { - const toolName = entry.payload.name || 'custom_tool'; - const input = entry.payload.input || ''; - - if (toolName === 'apply_patch') { - // Parse Codex patch format and convert to Claude Edit format - const fileMatch = input.match(/\*\*\* Update File: (.+)/); - const filePath = fileMatch ? fileMatch[1].trim() : 'unknown'; - - // Extract old and new content from patch - const lines = input.split('\n'); - const oldLines = []; - const newLines = []; - - for (const line of lines) { - if (line.startsWith('-') && !line.startsWith('---')) { - oldLines.push(line.substring(1)); - } else if (line.startsWith('+') && !line.startsWith('+++')) { - newLines.push(line.substring(1)); - } - } - - messages.push({ - type: 'tool_use', - timestamp: entry.timestamp, - toolName: 'Edit', - toolInput: JSON.stringify({ - file_path: filePath, - old_string: oldLines.join('\n'), - new_string: newLines.join('\n') - }), - toolCallId: entry.payload.call_id - }); - } else { - messages.push({ - type: 'tool_use', - timestamp: entry.timestamp, - toolName: toolName, - toolInput: input, - toolCallId: entry.payload.call_id - }); - } - } - - if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') { - messages.push({ - type: 'tool_result', - timestamp: entry.timestamp, - toolCallId: entry.payload.call_id, - output: entry.payload.output || '' - }); - } - - } catch (parseError) { - // Skip malformed lines - } - } - } - - // Sort by timestamp - messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); - - const total = messages.length; - - // Apply pagination if limit is specified - if (limit !== null) { - const startIndex = Math.max(0, total - offset - limit); - const endIndex = total - offset; - const paginatedMessages = messages.slice(startIndex, endIndex); - const hasMore = startIndex > 0; - - return { - messages: paginatedMessages, - total, - hasMore, - offset, - limit, - tokenUsage - }; - } - - return { messages, tokenUsage }; - - } catch (error) { - console.error(`Error reading Codex session messages for ${sessionId}:`, error); - return { messages: [], total: 0, hasMore: false }; - } -} - async function deleteCodexSession(sessionId) { try { const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); @@ -1823,72 +1366,13 @@ async function searchGeminiSessionsForProject( } } -async function getGeminiCliSessionMessages(sessionId) { - const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); - let projectDirs; - try { - projectDirs = await fs.readdir(geminiTmpDir); - } catch { - return []; - } - - for (const projectDir of projectDirs) { - const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); - let chatFiles; - try { - chatFiles = await fs.readdir(chatsDir); - } catch { - continue; - } - - for (const chatFile of chatFiles) { - if (!chatFile.endsWith('.json')) continue; - try { - const filePath = path.join(chatsDir, chatFile); - const data = await fs.readFile(filePath, 'utf8'); - const session = JSON.parse(data); - const fileSessionId = session.sessionId || chatFile.replace('.json', ''); - if (fileSessionId !== sessionId) continue; - - return (session.messages || []).map(msg => { - const role = msg.type === 'user' ? 'user' - : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant' - : msg.type; - - let content = ''; - if (typeof msg.content === 'string') { - content = msg.content; - } else if (Array.isArray(msg.content)) { - content = msg.content.filter(p => p.text).map(p => p.text).join('\n'); - } - - return { - type: 'message', - message: { role, content }, - timestamp: msg.timestamp || null - }; - }); - } catch { - continue; - } - } - } - - return []; -} - // Only functions with consumers outside this module are exported. Folder-name // based helpers (`getSessions`, `deleteSession`, etc.) are kept as internal // implementation details of the id-based wrappers below. export { - getSessionMessages, deleteSessionById, - addProjectManually, getProjectPathById, claudeFolderNameFromPath, - clearProjectDirectoryCache, - getCodexSessionMessages, deleteCodexSession, - getGeminiCliSessionMessages, searchConversations };