From ef916615f8cac724c141db373ebee516ba2a8c88 Mon Sep 17 00:00:00 2001 From: simosmik Date: Thu, 16 Apr 2026 07:51:17 +0000 Subject: [PATCH] refactor: creating providers placeholders and barrel file --- server/claude-sdk.js | 2 +- server/cursor-cli.js | 2 +- server/gemini-response-handler.js | 2 +- server/openai-codex.js | 2 +- server/providers/claude/adapter.js | 77 ------- server/providers/claude/config.js | 1 + server/providers/claude/index.js | 8 + server/providers/claude/sessions.js | 82 +++++++ server/providers/codex/adapter.js | 58 +---- server/providers/codex/config.js | 1 + server/providers/codex/index.js | 8 + server/providers/codex/mcp.js | 1 + server/providers/codex/sessions.js | 63 ++++++ server/providers/cursor/adapter.js | 329 +-------------------------- server/providers/cursor/config.js | 1 + server/providers/cursor/index.js | 8 + server/providers/cursor/mcp.js | 1 + server/providers/cursor/sessions.js | 335 ++++++++++++++++++++++++++++ server/providers/gemini/adapter.js | 114 ---------- server/providers/gemini/index.js | 8 + server/providers/gemini/sessions.js | 121 ++++++++++ server/providers/registry.js | 8 +- 22 files changed, 650 insertions(+), 582 deletions(-) create mode 100644 server/providers/claude/config.js create mode 100644 server/providers/claude/index.js create mode 100644 server/providers/claude/sessions.js create mode 100644 server/providers/codex/config.js create mode 100644 server/providers/codex/index.js create mode 100644 server/providers/codex/mcp.js create mode 100644 server/providers/codex/sessions.js create mode 100644 server/providers/cursor/config.js create mode 100644 server/providers/cursor/index.js create mode 100644 server/providers/cursor/mcp.js create mode 100644 server/providers/cursor/sessions.js create mode 100644 server/providers/gemini/index.js create mode 100644 server/providers/gemini/sessions.js diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 918a7bd6..a4f33cf6 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -24,7 +24,7 @@ import { notifyRunStopped, notifyUserIfEnabled } from './services/notification-orchestrator.js'; -import { claudeAdapter } from './providers/claude/adapter.js'; +import { claudeAdapter } from './providers/claude/index.js'; import { createNormalizedMessage } from './providers/types.js'; const activeSessions = new Map(); diff --git a/server/cursor-cli.js b/server/cursor-cli.js index aedd7e0b..c5107d1a 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,7 +1,7 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; -import { cursorAdapter } from './providers/cursor/adapter.js'; +import { cursorAdapter } from './providers/cursor/index.js'; import { createNormalizedMessage } from './providers/types.js'; // Use cross-spawn on Windows for better command execution diff --git a/server/gemini-response-handler.js b/server/gemini-response-handler.js index 9da1f5cc..4b9c0484 100644 --- a/server/gemini-response-handler.js +++ b/server/gemini-response-handler.js @@ -1,5 +1,5 @@ // Gemini Response Handler - JSON Stream processing -import { geminiAdapter } from './providers/gemini/adapter.js'; +import { geminiAdapter } from './providers/gemini/index.js'; class GeminiResponseHandler { constructor(ws, options = {}) { diff --git a/server/openai-codex.js b/server/openai-codex.js index 0169a3b6..c08f90d1 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -15,7 +15,7 @@ import { Codex } from '@openai/codex-sdk'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; -import { codexAdapter } from './providers/codex/adapter.js'; +import { codexAdapter } from './providers/codex/index.js'; import { createNormalizedMessage } from './providers/types.js'; // Track active sessions diff --git a/server/providers/claude/adapter.js b/server/providers/claude/adapter.js index d5f850ba..0a17d140 100644 --- a/server/providers/claude/adapter.js +++ b/server/providers/claude/adapter.js @@ -5,7 +5,6 @@ * @module adapters/claude */ -import { getSessionMessages } from '../../projects.js'; import { createNormalizedMessage, generateMessageId } from '../types.js'; import { isInternalContent } from '../utils.js'; @@ -200,79 +199,3 @@ export function normalizeMessage(raw, sessionId) { return messages; } - -/** - * @type {import('../types.js').ProviderAdapter} - */ -export const claudeAdapter = { - normalizeMessage, - - /** - * Fetch session history from JSONL files, returning normalized messages. - */ - async fetchHistory(sessionId, opts = {}) { - const { projectName, limit = null, offset = 0 } = opts; - if (!projectName) { - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - - let result; - try { - result = await getSessionMessages(projectName, sessionId, limit, offset); - } catch (error) { - console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message); - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - - // getSessionMessages returns either an array (no limit) or { messages, total, hasMore } - 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); - - // First pass: collect tool results for attachment to tool_use messages - 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') { - toolResultMap.set(part.tool_use_id, { - content: part.content, - isError: Boolean(part.is_error), - timestamp: raw.timestamp, - subagentTools: raw.subagentTools, - toolUseResult: raw.toolUseResult, - }); - } - } - } - } - - // Second pass: normalize all messages - const normalized = []; - for (const raw of rawMessages) { - const entries = normalizeMessage(raw, sessionId); - normalized.push(...entries); - } - - // Attach tool results to their corresponding tool_use messages - for (const msg of normalized) { - if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { - const tr = toolResultMap.get(msg.toolId); - msg.toolResult = { - content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), - isError: tr.isError, - toolUseResult: tr.toolUseResult, - }; - msg.subagentTools = tr.subagentTools; - } - } - - return { - messages: normalized, - total, - hasMore, - offset, - limit, - }; - }, -}; diff --git a/server/providers/claude/config.js b/server/providers/claude/config.js new file mode 100644 index 00000000..240430da --- /dev/null +++ b/server/providers/claude/config.js @@ -0,0 +1 @@ +// TODO: migrate Claude session list/delete endpoints from server/index.js diff --git a/server/providers/claude/index.js b/server/providers/claude/index.js new file mode 100644 index 00000000..f882a323 --- /dev/null +++ b/server/providers/claude/index.js @@ -0,0 +1,8 @@ +/** + * Claude provider barrel. + * Assembles the ProviderAdapter from adapter + sessions. + */ +import { normalizeMessage } from './adapter.js'; +import { fetchHistory } from './sessions.js'; + +export const claudeAdapter = { normalizeMessage, fetchHistory }; diff --git a/server/providers/claude/sessions.js b/server/providers/claude/sessions.js new file mode 100644 index 00000000..64692eea --- /dev/null +++ b/server/providers/claude/sessions.js @@ -0,0 +1,82 @@ +/** + * Claude provider session history. + * + * Fetches and normalizes persisted JSONL session data. + * @module adapters/claude/sessions + */ + +import { normalizeMessage } from './adapter.js'; +import { getSessionMessages } from '../../projects.js'; +import { createNormalizedMessage } from '../types.js'; + +/** + * Fetch session history from JSONL files, returning normalized messages. + * @param {string} sessionId + * @param {import('../types.js').FetchHistoryOptions} opts + * @returns {Promise} + */ +export async function fetchHistory(sessionId, opts = {}) { + const { projectName, limit = null, offset = 0 } = opts; + if (!projectName) { + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + let result; + try { + result = await getSessionMessages(projectName, sessionId, limit, offset); + } catch (error) { + console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + // getSessionMessages returns either an array (no limit) or { messages, total, hasMore } + 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); + + // First pass: collect tool results for attachment to tool_use messages + 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') { + toolResultMap.set(part.tool_use_id, { + content: part.content, + isError: Boolean(part.is_error), + timestamp: raw.timestamp, + subagentTools: raw.subagentTools, + toolUseResult: raw.toolUseResult, + }); + } + } + } + } + + // Second pass: normalize all messages + const normalized = []; + for (const raw of rawMessages) { + const entries = normalizeMessage(raw, sessionId); + normalized.push(...entries); + } + + // Attach tool results to their corresponding tool_use messages + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const tr = toolResultMap.get(msg.toolId); + msg.toolResult = { + content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), + isError: tr.isError, + toolUseResult: tr.toolUseResult, + }; + msg.subagentTools = tr.subagentTools; + } + } + + return { + messages: normalized, + total, + hasMore, + offset, + limit, + }; +} diff --git a/server/providers/codex/adapter.js b/server/providers/codex/adapter.js index c9cae00f..42ebe62c 100644 --- a/server/providers/codex/adapter.js +++ b/server/providers/codex/adapter.js @@ -5,7 +5,6 @@ * @module adapters/codex */ -import { getCodexSessionMessages } from '../../projects.js'; import { createNormalizedMessage, generateMessageId } from '../types.js'; const PROVIDER = 'codex'; @@ -16,7 +15,7 @@ const PROVIDER = 'codex'; * @param {string} sessionId * @returns {import('../types.js').NormalizedMessage[]} */ -function normalizeCodexHistoryEntry(raw, sessionId) { +export function normalizeCodexHistoryEntry(raw, sessionId) { const ts = raw.timestamp || new Date().toISOString(); const baseId = raw.uuid || generateMessageId('codex'); @@ -191,58 +190,3 @@ export function normalizeMessage(raw, sessionId) { return []; } - -/** - * @type {import('../types.js').ProviderAdapter} - */ -export const codexAdapter = { - normalizeMessage, - /** - * Fetch session history from Codex JSONL files. - */ - async fetchHistory(sessionId, opts = {}) { - const { limit = null, offset = 0 } = opts; - - let result; - try { - result = await getCodexSessionMessages(sessionId, limit, offset); - } catch (error) { - console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.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 = result.tokenUsage || null; - - const normalized = []; - for (const raw of rawMessages) { - const entries = normalizeCodexHistoryEntry(raw, sessionId); - normalized.push(...entries); - } - - // Attach tool results to tool_use messages - 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 tr = toolResultMap.get(msg.toolId); - msg.toolResult = { content: tr.content, isError: tr.isError }; - } - } - - return { - messages: normalized, - total, - hasMore, - offset, - limit, - tokenUsage, - }; - }, -}; diff --git a/server/providers/codex/config.js b/server/providers/codex/config.js new file mode 100644 index 00000000..034999ba --- /dev/null +++ b/server/providers/codex/config.js @@ -0,0 +1 @@ +// TODO: migrate GET /config from server/routes/codex.js diff --git a/server/providers/codex/index.js b/server/providers/codex/index.js new file mode 100644 index 00000000..8d15557b --- /dev/null +++ b/server/providers/codex/index.js @@ -0,0 +1,8 @@ +/** + * Codex provider barrel. + * Assembles the ProviderAdapter from adapter + sessions. + */ +import { normalizeMessage } from './adapter.js'; +import { fetchHistory } from './sessions.js'; + +export const codexAdapter = { normalizeMessage, fetchHistory }; diff --git a/server/providers/codex/mcp.js b/server/providers/codex/mcp.js new file mode 100644 index 00000000..98186464 --- /dev/null +++ b/server/providers/codex/mcp.js @@ -0,0 +1 @@ +// TODO: migrate MCP CRUD endpoints from server/routes/codex.js diff --git a/server/providers/codex/sessions.js b/server/providers/codex/sessions.js new file mode 100644 index 00000000..80ae5428 --- /dev/null +++ b/server/providers/codex/sessions.js @@ -0,0 +1,63 @@ +/** + * Codex session history fetcher. + * + * Extracted from adapter.js — pure data-access concern. + * @module providers/codex/sessions + */ + +import { normalizeCodexHistoryEntry } from './adapter.js'; +import { getCodexSessionMessages } from '../../projects.js'; + +/** + * Fetch session history from Codex JSONL files. + * @param {string} sessionId + * @param {object} opts + * @param {number|null} [opts.limit] + * @param {number} [opts.offset] + * @returns {Promise<{messages: import('../../providers/types.js').NormalizedMessage[], total: number, hasMore: boolean, offset: number, limit: number|null, tokenUsage: object|null}>} + */ +export async function fetchHistory(sessionId, opts = {}) { + const { limit = null, offset = 0 } = opts; + + let result; + try { + result = await getCodexSessionMessages(sessionId, limit, offset); + } catch (error) { + console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.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 = result.tokenUsage || null; + + const normalized = []; + for (const raw of rawMessages) { + const entries = normalizeCodexHistoryEntry(raw, sessionId); + normalized.push(...entries); + } + + // Attach tool results to tool_use messages + 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 tr = toolResultMap.get(msg.toolId); + msg.toolResult = { content: tr.content, isError: tr.isError }; + } + } + + return { + messages: normalized, + total, + hasMore, + offset, + limit, + tokenUsage, + }; +} diff --git a/server/providers/cursor/adapter.js b/server/providers/cursor/adapter.js index c86215ff..582daacb 100644 --- a/server/providers/cursor/adapter.js +++ b/server/providers/cursor/adapter.js @@ -1,138 +1,15 @@ /** * Cursor provider adapter. * - * Normalizes Cursor CLI session history into NormalizedMessage format. + * Normalizes Cursor CLI realtime NDJSON events into NormalizedMessage format. + * History loading lives in ./sessions.js. * @module adapters/cursor */ -import path from 'path'; -import os from 'os'; -import crypto from 'crypto'; -import { createNormalizedMessage, generateMessageId } from '../types.js'; +import { createNormalizedMessage } from '../types.js'; const PROVIDER = 'cursor'; -/** - * Load raw blobs from Cursor's SQLite store.db, parse the DAG structure, - * and return sorted message blobs in chronological order. - * @param {string} sessionId - * @param {string} projectPath - Absolute project path (used to compute cwdId hash) - * @returns {Promise>} - */ -async function loadCursorBlobs(sessionId, projectPath) { - // Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable - const { default: sqlite3 } = await import('sqlite3'); - const { open } = await import('sqlite'); - - 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 = await open({ - filename: storeDbPath, - driver: sqlite3.Database, - mode: sqlite3.OPEN_READONLY, - }); - - try { - const allBlobs = await db.all('SELECT rowid, id, data FROM blobs'); - - const blobMap = new Map(); - const parentRefs = new Map(); - const childRefs = new Map(); - const jsonBlobs = []; - - 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')); - jsonBlobs.push({ ...blob, parsed }); - } catch { - // skip unparseable blobs - } - } else if (blob.data) { - const parents = []; - 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); - } - } - } - } - - // Topological sort (DFS) - const visited = new Set(); - const sorted = []; - function visit(nodeId) { - if (visited.has(nodeId)) return; - visited.add(nodeId); - for (const pid of (parentRefs.get(nodeId) || [])) visit(pid); - const b = blobMap.get(nodeId); - if (b) sorted.push(b); - } - for (const blob of allBlobs) { - if (!parentRefs.has(blob.id)) visit(blob.id); - } - for (const blob of allBlobs) visit(blob.id); - - // Order JSON blobs by DAG appearance - const messageOrder = new Map(); - let orderIndex = 0; - for (const blob of sorted) { - if (blob.data && blob.data[0] !== 0x7B) { - for (const jb of jsonBlobs) { - try { - const idBytes = Buffer.from(jb.id, 'hex'); - if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) { - messageOrder.set(jb.id, orderIndex++); - } - } catch { /* skip */ } - } - } - } - - const sortedJsonBlobs = jsonBlobs.sort((a, b) => { - const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return oa !== ob ? oa - ob : a.rowid - b.rowid; - }); - - const messages = []; - for (let idx = 0; idx < sortedJsonBlobs.length; idx++) { - const blob = sortedJsonBlobs[idx]; - const parsed = blob.parsed; - if (!parsed) continue; - 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 { - await db.close(); - } -} - /** * Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s). * History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON. @@ -151,203 +28,3 @@ export function normalizeMessage(raw, sessionId) { } return []; } - -/** - * @type {import('../types.js').ProviderAdapter} - */ -export const cursorAdapter = { - normalizeMessage, - /** - * Fetch session history for Cursor from SQLite store.db. - */ - async fetchHistory(sessionId, opts = {}) { - const { projectPath = '', limit = null, offset = 0 } = opts; - - try { - const blobs = await loadCursorBlobs(sessionId, projectPath); - const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId); - - // Apply pagination - 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) { - // DB doesn't exist or is unreadable — return empty - console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message); - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - }, - - /** - * Normalize raw Cursor blob messages into NormalizedMessage[]. - * @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content}) - * @param {string} sessionId - * @returns {import('../types.js').NormalizedMessage[]} - */ - normalizeCursorBlobs(blobs, sessionId) { - const messages = []; - const toolUseMap = new Map(); - - // Use a fixed base timestamp so messages have stable, monotonically-increasing - // timestamps based on their sequence number rather than wall-clock time. - 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) { - // Try nested message format - 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(p => typeof p === 'string' ? p : p?.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; - - // Tool results - 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 toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch' - ? 'Edit' : (part.toolName || part.name || 'Unknown Tool'); - const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`; - messages.push(createNormalizedMessage({ - id: `${baseId}_${partIdx}`, - sessionId, - timestamp: ts, - provider: PROVIDER, - kind: 'tool_use', - toolName, - toolInput: part.args || part.input, - toolId, - })); - toolUseMap.set(toolId, messages[messages.length - 1]); - } - } - } 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); - } - } - - // Attach tool results to tool_use messages - for (const msg of messages) { - if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) { - const toolUse = toolUseMap.get(msg.toolId); - toolUse.toolResult = { - content: msg.content, - isError: msg.isError, - }; - } - } - - // Sort by sequence/rowid - 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/providers/cursor/config.js b/server/providers/cursor/config.js new file mode 100644 index 00000000..fcb7a00d --- /dev/null +++ b/server/providers/cursor/config.js @@ -0,0 +1 @@ +// TODO: migrate GET/POST /config from server/routes/cursor.js diff --git a/server/providers/cursor/index.js b/server/providers/cursor/index.js new file mode 100644 index 00000000..60d031cf --- /dev/null +++ b/server/providers/cursor/index.js @@ -0,0 +1,8 @@ +/** + * Cursor provider barrel. + * Assembles the ProviderAdapter from adapter + sessions. + */ +import { normalizeMessage } from './adapter.js'; +import { fetchHistory, normalizeCursorBlobs } from './sessions.js'; + +export const cursorAdapter = { normalizeMessage, fetchHistory, normalizeCursorBlobs }; diff --git a/server/providers/cursor/mcp.js b/server/providers/cursor/mcp.js new file mode 100644 index 00000000..33cb9621 --- /dev/null +++ b/server/providers/cursor/mcp.js @@ -0,0 +1 @@ +// TODO: migrate MCP CRUD endpoints from server/routes/cursor.js diff --git a/server/providers/cursor/sessions.js b/server/providers/cursor/sessions.js new file mode 100644 index 00000000..f6a7f933 --- /dev/null +++ b/server/providers/cursor/sessions.js @@ -0,0 +1,335 @@ +/** + * Cursor provider session history. + * + * Reads Cursor's SQLite store.db, walks the DAG, and returns + * NormalizedMessage[] for a given session. + * @module providers/cursor/sessions + */ + +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import { createNormalizedMessage, generateMessageId } from '../types.js'; + +const PROVIDER = 'cursor'; + +/** + * Load raw blobs from Cursor's SQLite store.db, parse the DAG structure, + * and return sorted message blobs in chronological order. + * @param {string} sessionId + * @param {string} projectPath - Absolute project path (used to compute cwdId hash) + * @returns {Promise>} + */ +async function loadCursorBlobs(sessionId, projectPath) { + // Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable + const { default: sqlite3 } = await import('sqlite3'); + const { open } = await import('sqlite'); + + 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 = await open({ + filename: storeDbPath, + driver: sqlite3.Database, + mode: sqlite3.OPEN_READONLY, + }); + + try { + const allBlobs = await db.all('SELECT rowid, id, data FROM blobs'); + + const blobMap = new Map(); + const parentRefs = new Map(); + const childRefs = new Map(); + const jsonBlobs = []; + + 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')); + jsonBlobs.push({ ...blob, parsed }); + } catch { + // skip unparseable blobs + } + } else if (blob.data) { + const parents = []; + 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); + } + } + } + } + + // Topological sort (DFS) + const visited = new Set(); + const sorted = []; + function visit(nodeId) { + if (visited.has(nodeId)) return; + visited.add(nodeId); + for (const pid of (parentRefs.get(nodeId) || [])) visit(pid); + const b = blobMap.get(nodeId); + if (b) sorted.push(b); + } + for (const blob of allBlobs) { + if (!parentRefs.has(blob.id)) visit(blob.id); + } + for (const blob of allBlobs) visit(blob.id); + + // Order JSON blobs by DAG appearance + const messageOrder = new Map(); + let orderIndex = 0; + for (const blob of sorted) { + if (blob.data && blob.data[0] !== 0x7B) { + for (const jb of jsonBlobs) { + try { + const idBytes = Buffer.from(jb.id, 'hex'); + if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) { + messageOrder.set(jb.id, orderIndex++); + } + } catch { /* skip */ } + } + } + } + + const sortedJsonBlobs = jsonBlobs.sort((a, b) => { + const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return oa !== ob ? oa - ob : a.rowid - b.rowid; + }); + + const messages = []; + for (let idx = 0; idx < sortedJsonBlobs.length; idx++) { + const blob = sortedJsonBlobs[idx]; + const parsed = blob.parsed; + if (!parsed) continue; + 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 { + await db.close(); + } +} + +/** + * Normalize raw Cursor blob messages into NormalizedMessage[]. + * @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content}) + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ +export function normalizeCursorBlobs(blobs, sessionId) { + const messages = []; + const toolUseMap = new Map(); + + // Use a fixed base timestamp so messages have stable, monotonically-increasing + // timestamps based on their sequence number rather than wall-clock time. + 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) { + // Try nested message format + 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(p => typeof p === 'string' ? p : p?.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; + + // Tool results + 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 toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch' + ? 'Edit' : (part.toolName || part.name || 'Unknown Tool'); + const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`; + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName, + toolInput: part.args || part.input, + toolId, + })); + toolUseMap.set(toolId, messages[messages.length - 1]); + } + } + } 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); + } + } + + // Attach tool results to tool_use messages + for (const msg of messages) { + if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) { + const toolUse = toolUseMap.get(msg.toolId); + toolUse.toolResult = { + content: msg.content, + isError: msg.isError, + }; + } + } + + // Sort by sequence/rowid + 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; +} + +/** + * Fetch session history for Cursor from SQLite store.db. + * @param {string} sessionId + * @param {object} opts + * @param {string} [opts.projectPath=''] + * @param {number|null} [opts.limit=null] + * @param {number} [opts.offset=0] + * @returns {Promise<{messages: import('../types.js').NormalizedMessage[], total: number, hasMore: boolean, offset: number, limit: number|null}>} + */ +export async function fetchHistory(sessionId, opts = {}) { + const { projectPath = '', limit = null, offset = 0 } = opts; + + try { + const blobs = await loadCursorBlobs(sessionId, projectPath); + const allNormalized = normalizeCursorBlobs(blobs, sessionId); + + // Apply pagination + 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) { + // DB doesn't exist or is unreadable — return empty + console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } +} diff --git a/server/providers/gemini/adapter.js b/server/providers/gemini/adapter.js index df303c36..892a24fd 100644 --- a/server/providers/gemini/adapter.js +++ b/server/providers/gemini/adapter.js @@ -5,8 +5,6 @@ * @module adapters/gemini */ -import sessionManager from '../../sessionManager.js'; -import { getGeminiCliSessionMessages } from '../../projects.js'; import { createNormalizedMessage, generateMessageId } from '../types.js'; const PROVIDER = 'gemini'; @@ -72,115 +70,3 @@ export function normalizeMessage(raw, sessionId) { return []; } - -/** - * @type {import('../types.js').ProviderAdapter} - */ -export const geminiAdapter = { - normalizeMessage, - /** - * Fetch session history for Gemini. - * First tries in-memory session manager, then falls back to CLI sessions on disk. - */ - async fetchHistory(sessionId, opts = {}) { - let rawMessages; - try { - rawMessages = sessionManager.getSessionMessages(sessionId); - - // Fallback to Gemini CLI sessions on disk - if (rawMessages.length === 0) { - rawMessages = await getGeminiCliSessionMessages(sessionId); - } - } catch (error) { - console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message); - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } - - const normalized = []; - 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'); - - // sessionManager format: { type: 'message', message: { role, content }, timestamp } - // CLI format: { role: 'user'|'gemini'|'assistant', content: string|array } - 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, - })); - } - } - - // Attach tool results to tool_use messages - 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 tr = toolResultMap.get(msg.toolId); - msg.toolResult = { content: tr.content, isError: tr.isError }; - } - } - - return { - messages: normalized, - total: normalized.length, - hasMore: false, - offset: 0, - limit: null, - }; - }, -}; diff --git a/server/providers/gemini/index.js b/server/providers/gemini/index.js new file mode 100644 index 00000000..0e01c166 --- /dev/null +++ b/server/providers/gemini/index.js @@ -0,0 +1,8 @@ +/** + * Gemini provider barrel. + * Assembles the ProviderAdapter from adapter + sessions. + */ +import { normalizeMessage } from './adapter.js'; +import { fetchHistory } from './sessions.js'; + +export const geminiAdapter = { normalizeMessage, fetchHistory }; diff --git a/server/providers/gemini/sessions.js b/server/providers/gemini/sessions.js new file mode 100644 index 00000000..5e308448 --- /dev/null +++ b/server/providers/gemini/sessions.js @@ -0,0 +1,121 @@ +/** + * Gemini session history fetcher. + * + * Extracted from adapter.js — pure data-access concern. + * @module providers/gemini/sessions + */ + +import sessionManager from '../../sessionManager.js'; +import { getGeminiCliSessionMessages } from '../../projects.js'; +import { createNormalizedMessage, generateMessageId } from '../types.js'; + +const PROVIDER = 'gemini'; + +/** + * Fetch session history for Gemini. + * First tries in-memory session manager, then falls back to CLI sessions on disk. + * @param {string} sessionId + * @param {object} opts + * @returns {Promise<{messages: import('../types.js').NormalizedMessage[], total: number, hasMore: boolean, offset: number, limit: number|null}>} + */ +export async function fetchHistory(sessionId, opts = {}) { + let rawMessages; + try { + rawMessages = sessionManager.getSessionMessages(sessionId); + + // Fallback to Gemini CLI sessions on disk + if (rawMessages.length === 0) { + rawMessages = await getGeminiCliSessionMessages(sessionId); + } + } catch (error) { + console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + const normalized = []; + 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'); + + // sessionManager format: { type: 'message', message: { role, content }, timestamp } + // CLI format: { role: 'user'|'gemini'|'assistant', content: string|array } + 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, + })); + } + } + + // Attach tool results to tool_use messages + 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 tr = toolResultMap.get(msg.toolId); + msg.toolResult = { content: tr.content, isError: tr.isError }; + } + } + + return { + messages: normalized, + total: normalized.length, + hasMore: false, + offset: 0, + limit: null, + }; +} diff --git a/server/providers/registry.js b/server/providers/registry.js index 236c909e..be545078 100644 --- a/server/providers/registry.js +++ b/server/providers/registry.js @@ -7,10 +7,10 @@ * @module providers/registry */ -import { claudeAdapter } from './claude/adapter.js'; -import { cursorAdapter } from './cursor/adapter.js'; -import { codexAdapter } from './codex/adapter.js'; -import { geminiAdapter } from './gemini/adapter.js'; +import { claudeAdapter } from './claude/index.js'; +import { cursorAdapter } from './cursor/index.js'; +import { codexAdapter } from './codex/index.js'; +import { geminiAdapter } from './gemini/index.js'; /** * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter