From a2ef1d77a8a1f20450f3779b09a606f43742f9d1 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:59:41 +0300 Subject: [PATCH] refactor: make providers use dedicated session handling classes --- .../list/claude/claude-sessions.provider.ts | 305 ++++++++++++++ .../providers/list/claude/claude.provider.ts | 312 +------------- .../list/codex/codex-sessions.provider.ts | 319 ++++++++++++++ .../providers/list/codex/codex.provider.ts | 326 +-------------- .../list/cursor/cursor-sessions.provider.ts | 382 +++++++++++++++++ .../providers/list/cursor/cursor.provider.ts | 389 +----------------- .../list/gemini/gemini-sessions.provider.ts | 219 ++++++++++ .../providers/list/gemini/gemini.provider.ts | 226 +--------- .../providers/services/sessions.service.ts | 4 +- .../shared/base/abstract.provider.ts | 17 +- server/shared/interfaces.ts | 12 +- server/shared/types.ts | 2 + server/shared/utils.ts | 5 +- 13 files changed, 1256 insertions(+), 1262 deletions(-) create mode 100644 server/modules/providers/list/claude/claude-sessions.provider.ts create mode 100644 server/modules/providers/list/codex/codex-sessions.provider.ts create mode 100644 server/modules/providers/list/cursor/cursor-sessions.provider.ts create mode 100644 server/modules/providers/list/gemini/gemini-sessions.provider.ts diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts new file mode 100644 index 00000000..5d0e3bd5 --- /dev/null +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -0,0 +1,305 @@ +import { getSessionMessages } from '@/projects.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 = 'claude'; + +type ClaudeToolResult = { + content: unknown; + isError: boolean; + subagentTools?: unknown; + toolUseResult?: unknown; +}; + +type ClaudeHistoryResult = + | AnyRecord[] + | { + messages?: AnyRecord[]; + total?: number; + hasMore?: boolean; + }; + +const loadClaudeSessionMessages = getSessionMessages as unknown as ( + projectName: string, + sessionId: string, + limit: number | null, + offset: number, +) => Promise; + +/** + * 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 (const part of raw.message.content) { + 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`, + 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 { projectName, limit = null, offset = 0 } = options; + if (!projectName) { + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + let result: ClaudeHistoryResult; + try { + result = await loadClaudeSessionMessages(projectName, 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, + }; + } +} diff --git a/server/modules/providers/list/claude/claude.provider.ts b/server/modules/providers/list/claude/claude.provider.ts index 9ca9b443..675d82dd 100644 --- a/server/modules/providers/list/claude/claude.provider.ts +++ b/server/modules/providers/list/claude/claude.provider.ts @@ -1,321 +1,15 @@ -import { getSessionMessages } from '@/projects.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js'; import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; -import type { IProviderAuth } from '@/shared/interfaces.js'; -import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; - -const PROVIDER = 'claude'; - -type RawProviderMessage = Record; - -type ClaudeToolResult = { - content: unknown; - isError: boolean; - subagentTools?: unknown; - toolUseResult?: unknown; -}; - -type ClaudeHistoryResult = - | RawProviderMessage[] - | { - messages?: RawProviderMessage[]; - total?: number; - hasMore?: boolean; - }; - -const loadClaudeSessionMessages = getSessionMessages as unknown as ( - projectName: string, - sessionId: string, - limit: number | null, - offset: number, -) => Promise; - -/** - * 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)); -} - -function readRawProviderMessage(raw: unknown): RawProviderMessage | null { - return readObjectRecord(raw) as RawProviderMessage | null; -} +import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; +import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js'; export class ClaudeProvider extends AbstractProvider { readonly mcp = new ClaudeMcpProvider(); readonly auth: IProviderAuth = new ClaudeProviderAuth(); + readonly sessions: IProviderSessions = new ClaudeSessionsProvider(); constructor() { super('claude'); } - - /** - * 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 = readRawProviderMessage(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 (const part of raw.message.content) { - 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`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: 'user', - content: text, - })); - } - } - } - - if (messages.length === 0) { - const textParts = raw.message.content - .filter((part: RawProviderMessage) => part.type === 'text') - .map((part: RawProviderMessage) => 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 { projectName, limit = null, offset = 0 } = options; - if (!projectName) { - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - - let result: ClaudeHistoryResult; - try { - result = await loadClaudeSessionMessages(projectName, 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, - }; - } } diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts new file mode 100644 index 00000000..1ea986f7 --- /dev/null +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -0,0 +1,319 @@ +import { getCodexSessionMessages } from '@/projects.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 = 'codex'; + +type CodexHistoryResult = + | AnyRecord[] + | { + messages?: AnyRecord[]; + total?: number; + hasMore?: boolean; + tokenUsage?: unknown; + }; + +const loadCodexSessionMessages = getCodexSessionMessages as unknown as ( + sessionId: string, + limit: number | null, + offset: number, +) => Promise; + +export class CodexSessionsProvider implements IProviderSessions { + /** + * Normalizes a persisted Codex JSONL entry. + * + * Live Codex SDK events are transformed before they reach normalizeMessage(), + * while history entries already use a compact message/tool shape from projects.js. + */ + private normalizeHistoryEntry(raw: AnyRecord, sessionId: string | null): NormalizedMessage[] { + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('codex'); + + if (raw.message?.role === 'user') { + const content = typeof raw.message.content === 'string' + ? raw.message.content + : Array.isArray(raw.message.content) + ? raw.message.content + .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '') + .filter(Boolean) + .join('\n') + : String(raw.message.content || ''); + if (!content.trim()) { + return []; + } + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content, + })]; + } + + if (raw.message?.role === 'assistant') { + const content = typeof raw.message.content === 'string' + ? raw.message.content + : Array.isArray(raw.message.content) + ? raw.message.content + .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '') + .filter(Boolean) + .join('\n') + : ''; + if (!content.trim()) { + return []; + } + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content, + })]; + } + + if (raw.type === 'thinking' || raw.isReasoning) { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: raw.message?.content || '', + })]; + } + + if (raw.type === 'tool_use' || raw.toolName) { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.toolName || 'Unknown', + toolInput: raw.toolInput, + toolId: raw.toolCallId || baseId, + })]; + } + + if (raw.type === 'tool_result') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: raw.toolCallId || '', + content: raw.output || '', + isError: Boolean(raw.isError), + })]; + } + + return []; + } + + /** + * Normalizes either a Codex history entry or a transformed live SDK event. + */ + normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { + const raw = readObjectRecord(rawMessage); + if (!raw) { + return []; + } + + if (raw.message?.role) { + return this.normalizeHistoryEntry(raw, sessionId); + } + + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('codex'); + + if (raw.type === 'item') { + switch (raw.itemType) { + case 'agent_message': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: raw.message?.content || '', + })]; + case 'reasoning': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: raw.message?.content || '', + })]; + case 'command_execution': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: 'Bash', + toolInput: { command: raw.command }, + toolId: baseId, + output: raw.output, + exitCode: raw.exitCode, + status: raw.status, + })]; + case 'file_change': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: 'FileChanges', + toolInput: raw.changes, + toolId: baseId, + status: raw.status, + })]; + case 'mcp_tool_call': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.tool || 'MCP', + toolInput: raw.arguments, + toolId: baseId, + server: raw.server, + result: raw.result, + error: raw.error, + status: raw.status, + })]; + case 'web_search': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: 'WebSearch', + toolInput: { query: raw.query }, + toolId: baseId, + })]; + case 'todo_list': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: 'TodoList', + toolInput: { items: raw.items }, + toolId: baseId, + })]; + case 'error': + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'error', + content: raw.message?.content || 'Unknown error', + })]; + default: + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.itemType || 'Unknown', + toolInput: raw.item || raw, + toolId: baseId, + })]; + } + } + + if (raw.type === 'turn_complete') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'complete', + })]; + } + if (raw.type === 'turn_failed') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'error', + content: raw.error?.message || 'Turn failed', + })]; + } + + return []; + } + + /** + * Loads Codex JSONL history and keeps token usage metadata when projects.js + * provides it. + */ + async fetchHistory( + sessionId: string, + options: FetchHistoryOptions = {}, + ): Promise { + const { limit = null, offset = 0 } = options; + + let result: CodexHistoryResult; + try { + result = await loadCodexSessionMessages(sessionId, limit, offset); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[CodexProvider] 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 tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage; + + const normalized: NormalizedMessage[] = []; + for (const raw of rawMessages) { + normalized.push(...this.normalizeHistoryEntry(raw, sessionId)); + } + + const toolResultMap = new Map(); + for (const msg of normalized) { + if (msg.kind === 'tool_result' && msg.toolId) { + toolResultMap.set(msg.toolId, msg); + } + } + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const toolResult = toolResultMap.get(msg.toolId); + if (toolResult) { + msg.toolResult = { content: toolResult.content, isError: toolResult.isError }; + } + } + } + + return { + messages: normalized, + total, + hasMore, + offset, + limit, + tokenUsage, + }; + } +} diff --git a/server/modules/providers/list/codex/codex.provider.ts b/server/modules/providers/list/codex/codex.provider.ts index cbd4fb4f..fe1b9eb5 100644 --- a/server/modules/providers/list/codex/codex.provider.ts +++ b/server/modules/providers/list/codex/codex.provider.ts @@ -1,335 +1,15 @@ -import { getCodexSessionMessages } from '@/projects.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js'; import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; -import type { IProviderAuth } from '@/shared/interfaces.js'; -import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; - -const PROVIDER = 'codex'; - -type RawProviderMessage = Record; - -type CodexHistoryResult = - | RawProviderMessage[] - | { - messages?: RawProviderMessage[]; - total?: number; - hasMore?: boolean; - tokenUsage?: unknown; - }; - -const loadCodexSessionMessages = getCodexSessionMessages as unknown as ( - sessionId: string, - limit: number | null, - offset: number, -) => Promise; - -function readRawProviderMessage(raw: unknown): RawProviderMessage | null { - return readObjectRecord(raw) as RawProviderMessage | null; -} +import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js'; +import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js'; export class CodexProvider extends AbstractProvider { readonly mcp = new CodexMcpProvider(); readonly auth: IProviderAuth = new CodexProviderAuth(); + readonly sessions: IProviderSessions = new CodexSessionsProvider(); constructor() { super('codex'); } - - /** - * Normalizes a persisted Codex JSONL entry. - * - * Live Codex SDK events are transformed before they reach normalizeMessage(), - * while history entries already use a compact message/tool shape from projects.js. - */ - private normalizeHistoryEntry(raw: RawProviderMessage, sessionId: string | null): NormalizedMessage[] { - const ts = raw.timestamp || new Date().toISOString(); - const baseId = raw.uuid || generateMessageId('codex'); - - if (raw.message?.role === 'user') { - const content = typeof raw.message.content === 'string' - ? raw.message.content - : Array.isArray(raw.message.content) - ? raw.message.content - .map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '') - .filter(Boolean) - .join('\n') - : String(raw.message.content || ''); - if (!content.trim()) { - return []; - } - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: 'user', - content, - })]; - } - - if (raw.message?.role === 'assistant') { - const content = typeof raw.message.content === 'string' - ? raw.message.content - : Array.isArray(raw.message.content) - ? raw.message.content - .map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '') - .filter(Boolean) - .join('\n') - : ''; - if (!content.trim()) { - return []; - } - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: 'assistant', - content, - })]; - } - - if (raw.type === 'thinking' || raw.isReasoning) { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'thinking', - content: raw.message?.content || '', - })]; - } - - if (raw.type === 'tool_use' || raw.toolName) { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: raw.toolName || 'Unknown', - toolInput: raw.toolInput, - toolId: raw.toolCallId || baseId, - })]; - } - - if (raw.type === 'tool_result') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: raw.toolCallId || '', - content: raw.output || '', - isError: Boolean(raw.isError), - })]; - } - - return []; - } - - /** - * Normalizes either a Codex history entry or a transformed live SDK event. - */ - normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { - const raw = readRawProviderMessage(rawMessage); - if (!raw) { - return []; - } - - if (raw.message?.role) { - return this.normalizeHistoryEntry(raw, sessionId); - } - - const ts = raw.timestamp || new Date().toISOString(); - const baseId = raw.uuid || generateMessageId('codex'); - - if (raw.type === 'item') { - switch (raw.itemType) { - case 'agent_message': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: 'assistant', - content: raw.message?.content || '', - })]; - case 'reasoning': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'thinking', - content: raw.message?.content || '', - })]; - case 'command_execution': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: 'Bash', - toolInput: { command: raw.command }, - toolId: baseId, - output: raw.output, - exitCode: raw.exitCode, - status: raw.status, - })]; - case 'file_change': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: 'FileChanges', - toolInput: raw.changes, - toolId: baseId, - status: raw.status, - })]; - case 'mcp_tool_call': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: raw.tool || 'MCP', - toolInput: raw.arguments, - toolId: baseId, - server: raw.server, - result: raw.result, - error: raw.error, - status: raw.status, - })]; - case 'web_search': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: 'WebSearch', - toolInput: { query: raw.query }, - toolId: baseId, - })]; - case 'todo_list': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: 'TodoList', - toolInput: { items: raw.items }, - toolId: baseId, - })]; - case 'error': - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'error', - content: raw.message?.content || 'Unknown error', - })]; - default: - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: raw.itemType || 'Unknown', - toolInput: raw.item || raw, - toolId: baseId, - })]; - } - } - - if (raw.type === 'turn_complete') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'complete', - })]; - } - if (raw.type === 'turn_failed') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'error', - content: raw.error?.message || 'Turn failed', - })]; - } - - return []; - } - - /** - * Loads Codex JSONL history and keeps token usage metadata when projects.js - * provides it. - */ - async fetchHistory( - sessionId: string, - options: FetchHistoryOptions = {}, - ): Promise { - const { limit = null, offset = 0 } = options; - - let result: CodexHistoryResult; - try { - result = await loadCodexSessionMessages(sessionId, limit, offset); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[CodexProvider] 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 tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage; - - const normalized: NormalizedMessage[] = []; - for (const raw of rawMessages) { - normalized.push(...this.normalizeHistoryEntry(raw, sessionId)); - } - - const toolResultMap = new Map(); - for (const msg of normalized) { - if (msg.kind === 'tool_result' && msg.toolId) { - toolResultMap.set(msg.toolId, msg); - } - } - for (const msg of normalized) { - if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { - const toolResult = toolResultMap.get(msg.toolId); - if (toolResult) { - msg.toolResult = { content: toolResult.content, isError: toolResult.isError }; - } - } - } - - return { - messages: normalized, - total, - hasMore, - offset, - limit, - tokenUsage, - }; - } } diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts new file mode 100644 index 00000000..a200e568 --- /dev/null +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -0,0 +1,382 @@ +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; + +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 = 'cursor'; + +type CursorDbBlob = { + rowid: number; + id: string; + data?: Buffer; +}; + +type CursorJsonBlob = CursorDbBlob & { + parsed: AnyRecord; +}; + +type CursorMessageBlob = { + id: string; + sequence: number; + rowid: number; + content: AnyRecord; +}; + +export class CursorSessionsProvider implements IProviderSessions { + /** + * Loads Cursor's SQLite blob DAG and returns message blobs in conversation + * order. Cursor history is stored as content-addressed blobs rather than JSONL. + */ + private async loadCursorBlobs(sessionId: string, projectPath: string): Promise { + // Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable + const { default: Database } = await import('better-sqlite3'); + + const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); + const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); + + const db = new Database(storeDbPath, { readonly: true, fileMustExist: true }); + + try { + const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all(); + + const blobMap = new Map(); + const parentRefs = new Map(); + const childRefs = new Map(); + const jsonBlobs: CursorJsonBlob[] = []; + + for (const blob of allBlobs) { + blobMap.set(blob.id, blob); + + if (blob.data && blob.data[0] === 0x7B) { + try { + const parsed = JSON.parse(blob.data.toString('utf8')) as AnyRecord; + jsonBlobs.push({ ...blob, parsed }); + } catch { + // Cursor can include binary or partial blobs; only JSON blobs become messages. + } + } else if (blob.data) { + const parents: string[] = []; + let i = 0; + while (i < blob.data.length - 33) { + if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) { + const parentHash = blob.data.slice(i + 2, i + 34).toString('hex'); + if (blobMap.has(parentHash)) { + parents.push(parentHash); + } + i += 34; + } else { + i++; + } + } + if (parents.length > 0) { + parentRefs.set(blob.id, parents); + for (const parentId of parents) { + if (!childRefs.has(parentId)) { + childRefs.set(parentId, []); + } + childRefs.get(parentId)?.push(blob.id); + } + } + } + } + + const visited = new Set(); + const sorted: CursorDbBlob[] = []; + const visit = (nodeId: string): void => { + if (visited.has(nodeId)) { + return; + } + visited.add(nodeId); + for (const parentId of parentRefs.get(nodeId) || []) { + visit(parentId); + } + const blob = blobMap.get(nodeId); + if (blob) { + sorted.push(blob); + } + }; + + for (const blob of allBlobs) { + if (!parentRefs.has(blob.id)) { + visit(blob.id); + } + } + for (const blob of allBlobs) { + visit(blob.id); + } + + const messageOrder = new Map(); + let orderIndex = 0; + for (const blob of sorted) { + if (blob.data && blob.data[0] !== 0x7B) { + for (const jsonBlob of jsonBlobs) { + try { + const idBytes = Buffer.from(jsonBlob.id, 'hex'); + if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) { + messageOrder.set(jsonBlob.id, orderIndex++); + } + } catch { + // Ignore malformed blob ids that cannot be decoded as hex. + } + } + } + } + + const sortedJsonBlobs = jsonBlobs.sort((a, b) => { + const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid; + }); + + const messages: CursorMessageBlob[] = []; + for (let idx = 0; idx < sortedJsonBlobs.length; idx++) { + const blob = sortedJsonBlobs[idx]; + const parsed = blob.parsed; + const role = parsed?.role || parsed?.message?.role; + if (role === 'system') { + continue; + } + messages.push({ + id: blob.id, + sequence: idx + 1, + rowid: blob.rowid, + content: parsed, + }); + } + + return messages; + } finally { + db.close(); + } + } + + /** + * Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is + * normalized from SQLite blobs in fetchHistory(). + */ + normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { + const raw = readObjectRecord(rawMessage); + if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) { + return [createNormalizedMessage({ + kind: 'stream_delta', + content: raw.message.content[0].text, + sessionId, + provider: PROVIDER, + })]; + } + + if (typeof rawMessage === 'string' && rawMessage.trim()) { + return [createNormalizedMessage({ + kind: 'stream_delta', + content: rawMessage, + sessionId, + provider: PROVIDER, + })]; + } + + return []; + } + + /** + * Fetches and paginates Cursor session history from its project-scoped store.db. + */ + async fetchHistory( + sessionId: string, + options: FetchHistoryOptions = {}, + ): Promise { + const { projectPath = '', limit = null, offset = 0 } = options; + + try { + const blobs = await this.loadCursorBlobs(sessionId, projectPath); + const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); + + if (limit !== null && limit > 0) { + const start = offset; + const page = allNormalized.slice(start, start + limit); + return { + messages: page, + total: allNormalized.length, + hasMore: start + limit < allNormalized.length, + offset, + limit, + }; + } + + return { + messages: allNormalized, + total: allNormalized.length, + hasMore: false, + offset: 0, + limit: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + } + + /** + * Converts Cursor SQLite message blobs into normalized messages and attaches + * matching tool results to their tool_use entries. + */ + private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] { + const messages: NormalizedMessage[] = []; + const toolUseMap = new Map(); + const baseTime = Date.now(); + + for (let i = 0; i < blobs.length; i++) { + const blob = blobs[i]; + const content = blob.content; + const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString(); + const baseId = blob.id || generateMessageId('cursor'); + + try { + if (!content?.role || !content?.content) { + if (content?.message?.role && content?.message?.content) { + if (content.message.role === 'system') { + continue; + } + const role = content.message.role === 'user' ? 'user' : 'assistant'; + let text = ''; + if (Array.isArray(content.message.content)) { + text = content.message.content + .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '') + .filter(Boolean) + .join('\n'); + } else if (typeof content.message.content === 'string') { + text = content.message.content; + } + if (text?.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: text, + sequence: blob.sequence, + rowid: blob.rowid, + })); + } + } + continue; + } + + if (content.role === 'system') { + continue; + } + + if (content.role === 'tool') { + const toolItems = Array.isArray(content.content) ? content.content : []; + for (const item of toolItems) { + if (item?.type !== 'tool-result') { + continue; + } + const toolCallId = item.toolCallId || content.id; + messages.push(createNormalizedMessage({ + id: `${baseId}_tr`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: toolCallId, + content: item.result || '', + isError: false, + })); + } + continue; + } + + const role = content.role === 'user' ? 'user' : 'assistant'; + + if (Array.isArray(content.content)) { + for (let partIdx = 0; partIdx < content.content.length; partIdx++) { + const part = content.content[partIdx]; + + if (part?.type === 'text' && part?.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: part.text, + sequence: blob.sequence, + rowid: blob.rowid, + })); + } else if (part?.type === 'reasoning' && part?.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: part.text, + })); + } else if (part?.type === 'tool-call' || part?.type === 'tool_use') { + const rawToolName = part.toolName || part.name || 'Unknown Tool'; + const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName; + const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`; + const message = createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName, + toolInput: part.args || part.input, + toolId, + }); + messages.push(message); + toolUseMap.set(toolId, message); + } + } + } else if (typeof content.content === 'string' && content.content.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: content.content, + sequence: blob.sequence, + rowid: blob.rowid, + })); + } + } catch (error) { + console.warn('Error normalizing cursor blob:', error); + } + } + + for (const msg of messages) { + if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) { + const toolUse = toolUseMap.get(msg.toolId); + if (toolUse) { + toolUse.toolResult = { + content: msg.content, + isError: msg.isError, + }; + } + } + } + + messages.sort((a, b) => { + if (a.sequence !== undefined && b.sequence !== undefined) { + return a.sequence - b.sequence; + } + if (a.rowid !== undefined && b.rowid !== undefined) { + return a.rowid - b.rowid; + } + return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + }); + + return messages; + } +} diff --git a/server/modules/providers/list/cursor/cursor.provider.ts b/server/modules/providers/list/cursor/cursor.provider.ts index 00edf0d4..7e834a10 100644 --- a/server/modules/providers/list/cursor/cursor.provider.ts +++ b/server/modules/providers/list/cursor/cursor.provider.ts @@ -1,398 +1,15 @@ -import crypto from 'node:crypto'; -import os from 'node:os'; -import path from 'node:path'; - import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js'; import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; -import type { IProviderAuth } from '@/shared/interfaces.js'; -import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; - -const PROVIDER = 'cursor'; - -type RawProviderMessage = Record; - -type CursorDbBlob = { - rowid: number; - id: string; - data?: Buffer; -}; - -type CursorJsonBlob = CursorDbBlob & { - parsed: RawProviderMessage; -}; - -type CursorMessageBlob = { - id: string; - sequence: number; - rowid: number; - content: RawProviderMessage; -}; - -function readRawProviderMessage(raw: unknown): RawProviderMessage | null { - return readObjectRecord(raw) as RawProviderMessage | null; -} +import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js'; +import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js'; export class CursorProvider extends AbstractProvider { readonly mcp = new CursorMcpProvider(); readonly auth: IProviderAuth = new CursorProviderAuth(); + readonly sessions: IProviderSessions = new CursorSessionsProvider(); constructor() { super('cursor'); } - - /** - * Loads Cursor's SQLite blob DAG and returns message blobs in conversation - * order. Cursor history is stored as content-addressed blobs rather than JSONL. - */ - private async loadCursorBlobs(sessionId: string, projectPath: string): Promise { - // Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable - const { default: Database } = await import('better-sqlite3'); - - const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); - const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); - - const db = new Database(storeDbPath, { readonly: true, fileMustExist: true }); - - try { - const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all(); - - const blobMap = new Map(); - const parentRefs = new Map(); - const childRefs = new Map(); - const jsonBlobs: CursorJsonBlob[] = []; - - for (const blob of allBlobs) { - blobMap.set(blob.id, blob); - - if (blob.data && blob.data[0] === 0x7B) { - try { - const parsed = JSON.parse(blob.data.toString('utf8')) as RawProviderMessage; - jsonBlobs.push({ ...blob, parsed }); - } catch { - // Cursor can include binary or partial blobs; only JSON blobs become messages. - } - } else if (blob.data) { - const parents: string[] = []; - let i = 0; - while (i < blob.data.length - 33) { - if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) { - const parentHash = blob.data.slice(i + 2, i + 34).toString('hex'); - if (blobMap.has(parentHash)) { - parents.push(parentHash); - } - i += 34; - } else { - i++; - } - } - if (parents.length > 0) { - parentRefs.set(blob.id, parents); - for (const parentId of parents) { - if (!childRefs.has(parentId)) { - childRefs.set(parentId, []); - } - childRefs.get(parentId)?.push(blob.id); - } - } - } - } - - const visited = new Set(); - const sorted: CursorDbBlob[] = []; - const visit = (nodeId: string): void => { - if (visited.has(nodeId)) { - return; - } - visited.add(nodeId); - for (const parentId of parentRefs.get(nodeId) || []) { - visit(parentId); - } - const blob = blobMap.get(nodeId); - if (blob) { - sorted.push(blob); - } - }; - - for (const blob of allBlobs) { - if (!parentRefs.has(blob.id)) { - visit(blob.id); - } - } - for (const blob of allBlobs) { - visit(blob.id); - } - - const messageOrder = new Map(); - let orderIndex = 0; - for (const blob of sorted) { - if (blob.data && blob.data[0] !== 0x7B) { - for (const jsonBlob of jsonBlobs) { - try { - const idBytes = Buffer.from(jsonBlob.id, 'hex'); - if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) { - messageOrder.set(jsonBlob.id, orderIndex++); - } - } catch { - // Ignore malformed blob ids that cannot be decoded as hex. - } - } - } - } - - const sortedJsonBlobs = jsonBlobs.sort((a, b) => { - const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid; - }); - - const messages: CursorMessageBlob[] = []; - for (let idx = 0; idx < sortedJsonBlobs.length; idx++) { - const blob = sortedJsonBlobs[idx]; - const parsed = blob.parsed; - const role = parsed?.role || parsed?.message?.role; - if (role === 'system') { - continue; - } - messages.push({ - id: blob.id, - sequence: idx + 1, - rowid: blob.rowid, - content: parsed, - }); - } - - return messages; - } finally { - db.close(); - } - } - - /** - * Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is - * normalized from SQLite blobs in fetchHistory(). - */ - normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { - const raw = readRawProviderMessage(rawMessage); - if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) { - return [createNormalizedMessage({ - kind: 'stream_delta', - content: raw.message.content[0].text, - sessionId, - provider: PROVIDER, - })]; - } - - if (typeof rawMessage === 'string' && rawMessage.trim()) { - return [createNormalizedMessage({ - kind: 'stream_delta', - content: rawMessage, - sessionId, - provider: PROVIDER, - })]; - } - - return []; - } - - /** - * Fetches and paginates Cursor session history from its project-scoped store.db. - */ - async fetchHistory( - sessionId: string, - options: FetchHistoryOptions = {}, - ): Promise { - const { projectPath = '', limit = null, offset = 0 } = options; - - try { - const blobs = await this.loadCursorBlobs(sessionId, projectPath); - const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); - - if (limit !== null && limit > 0) { - const start = offset; - const page = allNormalized.slice(start, start + limit); - return { - messages: page, - total: allNormalized.length, - hasMore: start + limit < allNormalized.length, - offset, - limit, - }; - } - - return { - messages: allNormalized, - total: allNormalized.length, - hasMore: false, - offset: 0, - limit: null, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message); - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - } - - /** - * Converts Cursor SQLite message blobs into normalized messages and attaches - * matching tool results to their tool_use entries. - */ - private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] { - const messages: NormalizedMessage[] = []; - const toolUseMap = new Map(); - const baseTime = Date.now(); - - for (let i = 0; i < blobs.length; i++) { - const blob = blobs[i]; - const content = blob.content; - const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString(); - const baseId = blob.id || generateMessageId('cursor'); - - try { - if (!content?.role || !content?.content) { - if (content?.message?.role && content?.message?.content) { - if (content.message.role === 'system') { - continue; - } - const role = content.message.role === 'user' ? 'user' : 'assistant'; - let text = ''; - if (Array.isArray(content.message.content)) { - text = content.message.content - .map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '') - .filter(Boolean) - .join('\n'); - } else if (typeof content.message.content === 'string') { - text = content.message.content; - } - if (text?.trim()) { - messages.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role, - content: text, - sequence: blob.sequence, - rowid: blob.rowid, - })); - } - } - continue; - } - - if (content.role === 'system') { - continue; - } - - if (content.role === 'tool') { - const toolItems = Array.isArray(content.content) ? content.content : []; - for (const item of toolItems) { - if (item?.type !== 'tool-result') { - continue; - } - const toolCallId = item.toolCallId || content.id; - messages.push(createNormalizedMessage({ - id: `${baseId}_tr`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: toolCallId, - content: item.result || '', - isError: false, - })); - } - continue; - } - - const role = content.role === 'user' ? 'user' : 'assistant'; - - if (Array.isArray(content.content)) { - for (let partIdx = 0; partIdx < content.content.length; partIdx++) { - const part = content.content[partIdx]; - - if (part?.type === 'text' && part?.text) { - messages.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role, - content: part.text, - sequence: blob.sequence, - rowid: blob.rowid, - })); - } else if (part?.type === 'reasoning' && part?.text) { - messages.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'thinking', - content: part.text, - })); - } else if (part?.type === 'tool-call' || part?.type === 'tool_use') { - const rawToolName = part.toolName || part.name || 'Unknown Tool'; - const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName; - const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`; - const message = createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName, - toolInput: part.args || part.input, - toolId, - }); - messages.push(message); - toolUseMap.set(toolId, message); - } - } - } else if (typeof content.content === 'string' && content.content.trim()) { - messages.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role, - content: content.content, - sequence: blob.sequence, - rowid: blob.rowid, - })); - } - } catch (error) { - console.warn('Error normalizing cursor blob:', error); - } - } - - for (const msg of messages) { - if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) { - const toolUse = toolUseMap.get(msg.toolId); - if (toolUse) { - toolUse.toolResult = { - content: msg.content, - isError: msg.isError, - }; - } - } - } - - messages.sort((a, b) => { - if (a.sequence !== undefined && b.sequence !== undefined) { - return a.sequence - b.sequence; - } - if (a.rowid !== undefined && b.rowid !== undefined) { - return a.rowid - b.rowid; - } - return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); - }); - - return messages; - } } diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts new file mode 100644 index 00000000..c181ab3b --- /dev/null +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -0,0 +1,219 @@ +import sessionManager from '@/sessionManager.js'; +import { getGeminiCliSessionMessages } from '@/projects.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'; + +export class GeminiSessionsProvider implements IProviderSessions { + /** + * Normalizes live Gemini stream-json events into the shared message shape. + * + * Gemini history uses a different session file shape, so fetchHistory handles + * that separately after loading raw persisted messages. + */ + normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { + const raw = readObjectRecord(rawMessage); + if (!raw) { + return []; + } + + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('gemini'); + + if (raw.type === 'message' && raw.role === 'assistant') { + const content = raw.content || ''; + const messages: NormalizedMessage[] = []; + if (content) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'stream_delta', + content, + })); + } + if (raw.delta !== true) { + messages.push(createNormalizedMessage({ + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'stream_end', + })); + } + return messages; + } + + if (raw.type === 'tool_use') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.tool_name, + toolInput: raw.parameters || {}, + toolId: raw.tool_id || baseId, + })]; + } + + if (raw.type === 'tool_result') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: raw.tool_id || '', + content: raw.output === undefined ? '' : String(raw.output), + isError: raw.status === 'error', + })]; + } + + if (raw.type === 'result') { + const messages = [createNormalizedMessage({ + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'stream_end', + })]; + if (raw.stats?.total_tokens) { + messages.push(createNormalizedMessage({ + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'status', + text: 'Complete', + tokens: raw.stats.total_tokens, + canInterrupt: false, + })); + } + return messages; + } + + if (raw.type === 'error') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'error', + content: raw.error || raw.message || 'Unknown Gemini streaming error', + })]; + } + + return []; + } + + /** + * Loads Gemini history from the in-memory session manager first, then falls + * back to Gemini CLI session files on disk. + */ + async fetchHistory( + sessionId: string, + _options: FetchHistoryOptions = {}, + ): Promise { + let rawMessages: AnyRecord[]; + try { + rawMessages = sessionManager.getSessionMessages(sessionId) as AnyRecord[]; + + if (rawMessages.length === 0) { + rawMessages = await getGeminiCliSessionMessages(sessionId) as AnyRecord[]; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + const normalized: NormalizedMessage[] = []; + for (let i = 0; i < rawMessages.length; i++) { + const raw = rawMessages[i]; + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('gemini'); + + const role = raw.message?.role || raw.role; + const content = raw.message?.content || raw.content; + + if (!role || !content) { + continue; + } + + const normalizedRole = role === 'user' ? 'user' : 'assistant'; + + if (Array.isArray(content)) { + for (let partIdx = 0; partIdx < content.length; partIdx++) { + const part = content[partIdx]; + if (part.type === 'text' && part.text) { + normalized.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: normalizedRole, + content: part.text, + })); + } else if (part.type === 'tool_use') { + normalized.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: part.name, + toolInput: part.input, + toolId: part.id || generateMessageId('gemini_tool'), + })); + } else if (part.type === 'tool_result') { + normalized.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: part.tool_use_id || '', + content: part.content === undefined ? '' : String(part.content), + isError: Boolean(part.is_error), + })); + } + } + } else if (typeof content === 'string' && content.trim()) { + normalized.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: normalizedRole, + content, + })); + } + } + + const toolResultMap = new Map(); + for (const msg of normalized) { + if (msg.kind === 'tool_result' && msg.toolId) { + toolResultMap.set(msg.toolId, msg); + } + } + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const toolResult = toolResultMap.get(msg.toolId); + if (toolResult) { + msg.toolResult = { content: toolResult.content, isError: toolResult.isError }; + } + } + } + + return { + messages: normalized, + total: normalized.length, + hasMore: false, + offset: 0, + limit: null, + }; + } +} diff --git a/server/modules/providers/list/gemini/gemini.provider.ts b/server/modules/providers/list/gemini/gemini.provider.ts index b5ca3ccf..d968b7c0 100644 --- a/server/modules/providers/list/gemini/gemini.provider.ts +++ b/server/modules/providers/list/gemini/gemini.provider.ts @@ -1,235 +1,15 @@ -import sessionManager from '@/sessionManager.js'; -import { getGeminiCliSessionMessages } from '@/projects.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js'; import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; -import type { IProviderAuth } from '@/shared/interfaces.js'; -import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; - -const PROVIDER = 'gemini'; - -type RawProviderMessage = Record; - -function readRawProviderMessage(raw: unknown): RawProviderMessage | null { - return readObjectRecord(raw) as RawProviderMessage | null; -} +import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js'; +import type { IProviderAuth, IProviderSessions } from '@/shared/interfaces.js'; export class GeminiProvider extends AbstractProvider { readonly mcp = new GeminiMcpProvider(); readonly auth: IProviderAuth = new GeminiProviderAuth(); + readonly sessions: IProviderSessions = new GeminiSessionsProvider(); constructor() { super('gemini'); } - - /** - * Normalizes live Gemini stream-json events into the shared message shape. - * - * Gemini history uses a different session file shape, so fetchHistory handles - * that separately after loading raw persisted messages. - */ - normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] { - const raw = readRawProviderMessage(rawMessage); - if (!raw) { - return []; - } - - const ts = raw.timestamp || new Date().toISOString(); - const baseId = raw.uuid || generateMessageId('gemini'); - - if (raw.type === 'message' && raw.role === 'assistant') { - const content = raw.content || ''; - const messages: NormalizedMessage[] = []; - if (content) { - messages.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'stream_delta', - content, - })); - } - if (raw.delta !== true) { - messages.push(createNormalizedMessage({ - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'stream_end', - })); - } - return messages; - } - - if (raw.type === 'tool_use') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: raw.tool_name, - toolInput: raw.parameters || {}, - toolId: raw.tool_id || baseId, - })]; - } - - if (raw.type === 'tool_result') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: raw.tool_id || '', - content: raw.output === undefined ? '' : String(raw.output), - isError: raw.status === 'error', - })]; - } - - if (raw.type === 'result') { - const messages = [createNormalizedMessage({ - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'stream_end', - })]; - if (raw.stats?.total_tokens) { - messages.push(createNormalizedMessage({ - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'status', - text: 'Complete', - tokens: raw.stats.total_tokens, - canInterrupt: false, - })); - } - return messages; - } - - if (raw.type === 'error') { - return [createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'error', - content: raw.error || raw.message || 'Unknown Gemini streaming error', - })]; - } - - return []; - } - - /** - * Loads Gemini history from the in-memory session manager first, then falls - * back to Gemini CLI session files on disk. - */ - async fetchHistory( - sessionId: string, - _options: FetchHistoryOptions = {}, - ): Promise { - let rawMessages: RawProviderMessage[]; - try { - rawMessages = sessionManager.getSessionMessages(sessionId) as RawProviderMessage[]; - - if (rawMessages.length === 0) { - rawMessages = await getGeminiCliSessionMessages(sessionId) as RawProviderMessage[]; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message); - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - - const normalized: NormalizedMessage[] = []; - for (let i = 0; i < rawMessages.length; i++) { - const raw = rawMessages[i]; - const ts = raw.timestamp || new Date().toISOString(); - const baseId = raw.uuid || generateMessageId('gemini'); - - const role = raw.message?.role || raw.role; - const content = raw.message?.content || raw.content; - - if (!role || !content) { - continue; - } - - const normalizedRole = role === 'user' ? 'user' : 'assistant'; - - if (Array.isArray(content)) { - for (let partIdx = 0; partIdx < content.length; partIdx++) { - const part = content[partIdx]; - if (part.type === 'text' && part.text) { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: normalizedRole, - content: part.text, - })); - } else if (part.type === 'tool_use') { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName: part.name, - toolInput: part.input, - toolId: part.id || generateMessageId('gemini_tool'), - })); - } else if (part.type === 'tool_result') { - normalized.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_result', - toolId: part.tool_use_id || '', - content: part.content === undefined ? '' : String(part.content), - isError: Boolean(part.is_error), - })); - } - } - } else if (typeof content === 'string' && content.trim()) { - normalized.push(createNormalizedMessage({ - id: baseId, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'text', - role: normalizedRole, - content, - })); - } - } - - const toolResultMap = new Map(); - for (const msg of normalized) { - if (msg.kind === 'tool_result' && msg.toolId) { - toolResultMap.set(msg.toolId, msg); - } - } - for (const msg of normalized) { - if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { - const toolResult = toolResultMap.get(msg.toolId); - if (toolResult) { - msg.toolResult = { content: toolResult.content, isError: toolResult.isError }; - } - } - } - - return { - messages: normalized, - total: normalized.length, - hasMore: false, - offset: 0, - limit: null, - }; - } } diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 41a649ff..adff6e8f 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -29,7 +29,7 @@ export const sessionsService = { raw: unknown, sessionId: string | null, ): NormalizedMessage[] { - return providerRegistry.resolveProvider(providerName).normalizeMessage(raw, sessionId); + return providerRegistry.resolveProvider(providerName).sessions.normalizeMessage(raw, sessionId); }, /** @@ -40,6 +40,6 @@ export const sessionsService = { sessionId: string, options?: FetchHistoryOptions, ): Promise { - return providerRegistry.resolveProvider(providerName).fetchHistory(sessionId, options); + return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options); }, }; diff --git a/server/modules/providers/shared/base/abstract.provider.ts b/server/modules/providers/shared/base/abstract.provider.ts index db5ff1f9..4a591baf 100644 --- a/server/modules/providers/shared/base/abstract.provider.ts +++ b/server/modules/providers/shared/base/abstract.provider.ts @@ -1,10 +1,5 @@ -import type { IProvider, IProviderAuth, IProviderMcp } from '@/shared/interfaces.js'; -import type { - FetchHistoryOptions, - FetchHistoryResult, - LLMProvider, - NormalizedMessage, -} from '@/shared/types.js'; +import type { IProvider, IProviderAuth, IProviderMcp, IProviderSessions } from '@/shared/interfaces.js'; +import type { LLMProvider } from '@/shared/types.js'; /** * Shared provider base. @@ -17,15 +12,9 @@ export abstract class AbstractProvider implements IProvider { readonly id: LLMProvider; abstract readonly mcp: IProviderMcp; abstract readonly auth: IProviderAuth; + abstract readonly sessions: IProviderSessions; protected constructor(id: LLMProvider) { this.id = id; } - - abstract normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[]; - - abstract fetchHistory( - sessionId: string, - options?: FetchHistoryOptions, - ): Promise; } diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index 432a5a8f..954b38a3 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -19,9 +19,7 @@ export interface IProvider { readonly id: LLMProvider; readonly mcp: IProviderMcp; readonly auth: IProviderAuth; - - normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[]; - fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise; + readonly sessions: IProviderSessions; } @@ -46,3 +44,11 @@ export interface IProviderMcp { input: { name: string; scope?: McpScope; workspacePath?: string }, ): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>; } + +/** + * Session/history contract for one provider. + */ +export interface IProviderSessions { + normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[]; + fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise; +} diff --git a/server/shared/types.ts b/server/shared/types.ts index 21933420..7fe545c5 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -5,6 +5,8 @@ export type ApiSuccessShape = { data: TData; }; +export type AnyRecord = Record; + // --------------------------------------------------------------------------------------------- export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor'; diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 40fbe64a..de6aed56 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { + AnyRecord, ApiSuccessShape, AppErrorOptions, NormalizedMessage, @@ -89,12 +90,12 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali * treat the returned value as a JSON-style object map without repeating the same * defensive shape checks at every config read site. */ -export const readObjectRecord = (value: unknown): Record | null => { +export const readObjectRecord = (value: any): AnyRecord | null => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } - return value as Record; + return value as AnyRecord; }; /**