mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-18 03:21:32 +00:00
Compare commits
6 Commits
v1.29.5
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
863a98d583 | ||
|
|
acfa7cfffb | ||
|
|
3e99187a01 | ||
|
|
ef916615f8 | ||
|
|
9ddda5ba5e | ||
|
|
9f99f6ab53 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,7 +9,6 @@ lerna-debug.log*
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
dist-server/
|
dist-server/
|
||||||
dist-ssr/
|
|
||||||
build/
|
build/
|
||||||
out/
|
out/
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
notifyRunStopped,
|
notifyRunStopped,
|
||||||
notifyUserIfEnabled
|
notifyUserIfEnabled
|
||||||
} from './services/notification-orchestrator.js';
|
} 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';
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
import { getStatusChecker } from './providers/registry.js';
|
import { getStatusChecker } from './providers/registry.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
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';
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
import { getStatusChecker } from './providers/registry.js';
|
import { getStatusChecker } from './providers/registry.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Gemini Response Handler - JSON Stream processing
|
// Gemini Response Handler - JSON Stream processing
|
||||||
import { geminiAdapter } from './providers/gemini/adapter.js';
|
import { geminiAdapter } from './providers/gemini/index.js';
|
||||||
|
|
||||||
class GeminiResponseHandler {
|
class GeminiResponseHandler {
|
||||||
constructor(ws, options = {}) {
|
constructor(ws, options = {}) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import { Codex } from '@openai/codex-sdk';
|
import { Codex } from '@openai/codex-sdk';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
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';
|
import { createNormalizedMessage } from './providers/types.js';
|
||||||
import { getStatusChecker } from './providers/registry.js';
|
import { getStatusChecker } from './providers/registry.js';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
* @module adapters/claude
|
* @module adapters/claude
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSessionMessages } from '../../projects.js';
|
|
||||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
import { isInternalContent } from '../utils.js';
|
import { isInternalContent } from '../utils.js';
|
||||||
|
|
||||||
@@ -200,79 +199,3 @@ export function normalizeMessage(raw, sessionId) {
|
|||||||
|
|
||||||
return messages;
|
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
1
server/providers/claude/config.js
Normal file
1
server/providers/claude/config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// TODO: migrate Claude session list/delete endpoints from server/index.js
|
||||||
8
server/providers/claude/index.js
Normal file
8
server/providers/claude/index.js
Normal file
@@ -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 };
|
||||||
82
server/providers/claude/sessions.js
Normal file
82
server/providers/claude/sessions.js
Normal file
@@ -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<import('../types.js').FetchHistoryResult>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
* @module adapters/codex
|
* @module adapters/codex
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCodexSessionMessages } from '../../projects.js';
|
|
||||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
|
|
||||||
const PROVIDER = 'codex';
|
const PROVIDER = 'codex';
|
||||||
@@ -16,7 +15,7 @@ const PROVIDER = 'codex';
|
|||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
* @returns {import('../types.js').NormalizedMessage[]}
|
* @returns {import('../types.js').NormalizedMessage[]}
|
||||||
*/
|
*/
|
||||||
function normalizeCodexHistoryEntry(raw, sessionId) {
|
export function normalizeCodexHistoryEntry(raw, sessionId) {
|
||||||
const ts = raw.timestamp || new Date().toISOString();
|
const ts = raw.timestamp || new Date().toISOString();
|
||||||
const baseId = raw.uuid || generateMessageId('codex');
|
const baseId = raw.uuid || generateMessageId('codex');
|
||||||
|
|
||||||
@@ -191,58 +190,3 @@ export function normalizeMessage(raw, sessionId) {
|
|||||||
|
|
||||||
return [];
|
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
1
server/providers/codex/config.js
Normal file
1
server/providers/codex/config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// TODO: migrate GET /config from server/routes/codex.js
|
||||||
8
server/providers/codex/index.js
Normal file
8
server/providers/codex/index.js
Normal file
@@ -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 };
|
||||||
1
server/providers/codex/mcp.js
Normal file
1
server/providers/codex/mcp.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// TODO: migrate MCP CRUD endpoints from server/routes/codex.js
|
||||||
63
server/providers/codex/sessions.js
Normal file
63
server/providers/codex/sessions.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Cursor provider adapter.
|
* 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
|
* @module adapters/cursor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import { createNormalizedMessage } from '../types.js';
|
||||||
import os from 'os';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
|
||||||
|
|
||||||
const PROVIDER = 'cursor';
|
const PROVIDER = 'cursor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
||||||
* and return sorted message blobs in chronological order.
|
* and return sorted message blobs in chronological order.
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
@@ -129,6 +129,7 @@ async function loadCursorBlobs(sessionId, projectPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
>>>>>>> refactor/split-server-index
|
||||||
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
||||||
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
||||||
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
||||||
@@ -146,203 +147,3 @@ export function normalizeMessage(raw, sessionId) {
|
|||||||
}
|
}
|
||||||
return [];
|
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
1
server/providers/cursor/config.js
Normal file
1
server/providers/cursor/config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// TODO: migrate GET/POST /config from server/routes/cursor.js
|
||||||
8
server/providers/cursor/index.js
Normal file
8
server/providers/cursor/index.js
Normal file
@@ -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 };
|
||||||
1
server/providers/cursor/mcp.js
Normal file
1
server/providers/cursor/mcp.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// TODO: migrate MCP CRUD endpoints from server/routes/cursor.js
|
||||||
330
server/providers/cursor/sessions.js
Normal file
330
server/providers/cursor/sessions.js
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* 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<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
||||||
|
*/
|
||||||
|
async function loadCursorBlobs(sessionId, projectPath) {
|
||||||
|
// 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('SELECT rowid, id, data FROM blobs').all();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@
|
|||||||
* @module adapters/gemini
|
* @module adapters/gemini
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sessionManager from '../../sessionManager.js';
|
|
||||||
import { getGeminiCliSessionMessages } from '../../projects.js';
|
|
||||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||||
|
|
||||||
const PROVIDER = 'gemini';
|
const PROVIDER = 'gemini';
|
||||||
@@ -72,115 +70,3 @@ export function normalizeMessage(raw, sessionId) {
|
|||||||
|
|
||||||
return [];
|
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
8
server/providers/gemini/index.js
Normal file
8
server/providers/gemini/index.js
Normal file
@@ -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 };
|
||||||
121
server/providers/gemini/sessions.js
Normal file
121
server/providers/gemini/sessions.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
* @module providers/registry
|
* @module providers/registry
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { claudeAdapter } from './claude/adapter.js';
|
import { claudeAdapter } from './claude/index.js';
|
||||||
import { cursorAdapter } from './cursor/adapter.js';
|
import { cursorAdapter } from './cursor/index.js';
|
||||||
import { codexAdapter } from './codex/adapter.js';
|
import { codexAdapter } from './codex/index.js';
|
||||||
import { geminiAdapter } from './gemini/adapter.js';
|
import { geminiAdapter } from './gemini/index.js';
|
||||||
|
|
||||||
import * as claudeStatus from './claude/status.js';
|
import * as claudeStatus from './claude/status.js';
|
||||||
import * as cursorStatus from './cursor/status.js';
|
import * as cursorStatus from './cursor/status.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user