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