mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-01 11:17:24 +00:00
feat: unified message architecture with provider adapters and session store
- Add provider adapter layer (server/providers/) with registry pattern
- Claude, Cursor, Codex, Gemini adapters normalize native formats to NormalizedMessage
- Shared types.js defines ProviderAdapter interface and message kinds
- Registry enables polymorphic provider lookup
- Add unified REST endpoint: GET /api/sessions/:id/messages?provider=...
- Replaces four provider-specific message endpoints with one
- Delegates to provider adapters via registry
- Add frontend session-keyed store (useSessionStore)
- Per-session Map with serverMessages/realtimeMessages/merged
- Dedup by ID, stale threshold for re-fetch, background session accumulation
- No localStorage for messages — backend JSONL is source of truth
- Add normalizedToChatMessages converter (useChatMessages)
- Converts NormalizedMessage[] to existing ChatMessage[] UI format
- Wire unified store into ChatInterface, useChatSessionState, useChatRealtimeHandlers
- Session switch uses store cache for instant render
- Background WebSocket messages routed to correct session slot
This commit is contained in:
278
server/providers/claude/adapter.js
Normal file
278
server/providers/claude/adapter.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Claude provider adapter.
|
||||
*
|
||||
* Normalizes Claude SDK session history into NormalizedMessage format.
|
||||
* @module adapters/claude
|
||||
*/
|
||||
|
||||
import { getSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
import { isInternalContent } from '../utils.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
|
||||
/**
|
||||
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
|
||||
* Handles both history entries (JSONL `{ message: { role, content } }`) and
|
||||
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
|
||||
* @param {object} raw - A single entry from JSONL or a live SDK event
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// ── Streaming events (realtime) ──────────────────────────────────────────
|
||||
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 })];
|
||||
}
|
||||
|
||||
// ── History / full-message events ────────────────────────────────────────
|
||||
const messages = [];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('claude');
|
||||
|
||||
// User message
|
||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
// Handle tool_result parts
|
||||
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') {
|
||||
// Regular text parts from user
|
||||
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 no text parts were found, check if it's a pure user message
|
||||
if (messages.length === 0) {
|
||||
const textParts = raw.message.content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.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;
|
||||
}
|
||||
|
||||
// Thinking message
|
||||
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;
|
||||
}
|
||||
|
||||
// Tool use result (codex-style in Claude)
|
||||
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;
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const claudeAdapter = {
|
||||
normalizeMessage,
|
||||
|
||||
/**
|
||||
* Fetch session history from JSONL files, returning normalized messages.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { projectName, limit = null, offset = 0 } = opts;
|
||||
if (!projectName) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await getSessionMessages(projectName, sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
|
||||
// First pass: collect tool results for attachment to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const raw of rawMessages) {
|
||||
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
toolResultMap.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: raw.timestamp,
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: normalize all messages
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
const entries = normalizeMessage(raw, sessionId);
|
||||
normalized.push(...entries);
|
||||
}
|
||||
|
||||
// Attach tool results to their corresponding tool_use messages
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = {
|
||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||
isError: tr.isError,
|
||||
toolUseResult: tr.toolUseResult,
|
||||
};
|
||||
msg.subagentTools = tr.subagentTools;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
},
|
||||
};
|
||||
248
server/providers/codex/adapter.js
Normal file
248
server/providers/codex/adapter.js
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Codex (OpenAI) provider adapter.
|
||||
*
|
||||
* Normalizes Codex SDK session history into NormalizedMessage format.
|
||||
* @module adapters/codex
|
||||
*/
|
||||
|
||||
import { getCodexSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
/**
|
||||
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
|
||||
* @param {object} raw - A single parsed message from Codex JSONL
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
function normalizeCodexHistoryEntry(raw, sessionId) {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
// User message
|
||||
if (raw.message?.role === 'user') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content.map(p => typeof p === 'string' ? p : p?.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,
|
||||
})];
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
if (raw.message?.role === 'assistant') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||
: '';
|
||||
if (!content.trim()) return [];
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
// Thinking/reasoning
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
}
|
||||
|
||||
// Tool use
|
||||
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,
|
||||
})];
|
||||
}
|
||||
|
||||
// Tool result
|
||||
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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
|
||||
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// History format: has message.role
|
||||
if (raw.message?.role) {
|
||||
return normalizeCodexHistoryEntry(raw, sessionId);
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
// SDK event format (output of transformCodexEvent)
|
||||
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:
|
||||
// Unknown item type — pass through as generic tool_use
|
||||
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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const codexAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history from Codex JSONL files.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { limit = null, offset = 0 } = opts;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = result.tokenUsage || null;
|
||||
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
const entries = normalizeCodexHistoryEntry(raw, sessionId);
|
||||
normalized.push(...entries);
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
},
|
||||
};
|
||||
353
server/providers/cursor/adapter.js
Normal file
353
server/providers/cursor/adapter.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Cursor provider adapter.
|
||||
*
|
||||
* Normalizes Cursor CLI session history into NormalizedMessage format.
|
||||
* @module adapters/cursor
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
/**
|
||||
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
||||
* and return sorted message blobs in chronological order.
|
||||
* @param {string} sessionId
|
||||
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
|
||||
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
||||
*/
|
||||
async function loadCursorBlobs(sessionId, projectPath) {
|
||||
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable
|
||||
const { default: sqlite3 } = await import('sqlite3');
|
||||
const { open } = await import('sqlite');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY,
|
||||
});
|
||||
|
||||
try {
|
||||
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');
|
||||
|
||||
const blobMap = new Map();
|
||||
const parentRefs = new Map();
|
||||
const childRefs = new Map();
|
||||
const jsonBlobs = [];
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
blobMap.set(blob.id, blob);
|
||||
|
||||
if (blob.data && blob.data[0] === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(blob.data.toString('utf8'));
|
||||
jsonBlobs.push({ ...blob, parsed });
|
||||
} catch {
|
||||
// skip unparseable blobs
|
||||
}
|
||||
} else if (blob.data) {
|
||||
const parents = [];
|
||||
let i = 0;
|
||||
while (i < blob.data.length - 33) {
|
||||
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
||||
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
||||
if (blobMap.has(parentHash)) {
|
||||
parents.push(parentHash);
|
||||
}
|
||||
i += 34;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (parents.length > 0) {
|
||||
parentRefs.set(blob.id, parents);
|
||||
for (const parentId of parents) {
|
||||
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
|
||||
childRefs.get(parentId).push(blob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort (DFS)
|
||||
const visited = new Set();
|
||||
const sorted = [];
|
||||
function visit(nodeId) {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
|
||||
const b = blobMap.get(nodeId);
|
||||
if (b) sorted.push(b);
|
||||
}
|
||||
for (const blob of allBlobs) {
|
||||
if (!parentRefs.has(blob.id)) visit(blob.id);
|
||||
}
|
||||
for (const blob of allBlobs) visit(blob.id);
|
||||
|
||||
// Order JSON blobs by DAG appearance
|
||||
const messageOrder = new Map();
|
||||
let orderIndex = 0;
|
||||
for (const blob of sorted) {
|
||||
if (blob.data && blob.data[0] !== 0x7B) {
|
||||
for (const jb of jsonBlobs) {
|
||||
try {
|
||||
const idBytes = Buffer.from(jb.id, 'hex');
|
||||
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
|
||||
messageOrder.set(jb.id, orderIndex++);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return oa !== ob ? oa - ob : a.rowid - b.rowid;
|
||||
});
|
||||
|
||||
const messages = [];
|
||||
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
||||
const blob = sortedJsonBlobs[idx];
|
||||
const parsed = blob.parsed;
|
||||
if (!parsed) continue;
|
||||
const role = parsed?.role || parsed?.message?.role;
|
||||
if (role === 'system') continue;
|
||||
messages.push({
|
||||
id: blob.id,
|
||||
sequence: idx + 1,
|
||||
rowid: blob.rowid,
|
||||
content: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
||||
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
||||
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// Structured assistant message with content array
|
||||
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
// Plain string line (non-JSON output)
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const cursorAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history for Cursor from SQLite store.db.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { projectPath = '', limit = null, offset = 0 } = opts;
|
||||
|
||||
try {
|
||||
const blobs = await loadCursorBlobs(sessionId, projectPath);
|
||||
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
|
||||
|
||||
// Apply pagination
|
||||
if (limit !== null && limit > 0) {
|
||||
const start = offset;
|
||||
const page = allNormalized.slice(start, start + limit);
|
||||
return {
|
||||
messages: page,
|
||||
total: allNormalized.length,
|
||||
hasMore: start + limit < allNormalized.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allNormalized,
|
||||
total: allNormalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// DB doesn't exist or is unreadable — return empty
|
||||
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalize raw Cursor blob messages into NormalizedMessage[].
|
||||
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
normalizeCursorBlobs(blobs, sessionId) {
|
||||
const messages = [];
|
||||
const toolUseMap = new Map();
|
||||
|
||||
// Use a fixed base timestamp so messages have stable, monotonically-increasing
|
||||
// timestamps based on their sequence number rather than wall-clock time.
|
||||
const baseTime = Date.now();
|
||||
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const blob = blobs[i];
|
||||
const content = blob.content;
|
||||
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
||||
const baseId = blob.id || generateMessageId('cursor');
|
||||
|
||||
try {
|
||||
if (!content?.role || !content?.content) {
|
||||
// Try nested message format
|
||||
if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') continue;
|
||||
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
let text = '';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map(p => typeof p === 'string' ? p : p?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'system') continue;
|
||||
|
||||
// Tool results
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = Array.isArray(content.content) ? content.content : [];
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') continue;
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: toolCallId,
|
||||
content: item.result || '',
|
||||
isError: false,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||
const part = content.content[partIdx];
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: part.text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
} else if (part?.type === 'reasoning' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
|
||||
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
|
||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: part.args || part.input,
|
||||
toolId,
|
||||
}));
|
||||
toolUseMap.set(toolId, messages[messages.length - 1]);
|
||||
}
|
||||
}
|
||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: content.content,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error normalizing cursor blob:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
||||
const toolUse = toolUseMap.get(msg.toolId);
|
||||
toolUse.toolResult = {
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by sequence/rowid
|
||||
messages.sort((a, b) => {
|
||||
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
|
||||
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
return messages;
|
||||
},
|
||||
};
|
||||
186
server/providers/gemini/adapter.js
Normal file
186
server/providers/gemini/adapter.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Gemini provider adapter.
|
||||
*
|
||||
* Normalizes Gemini CLI session history into NormalizedMessage format.
|
||||
* @module adapters/gemini
|
||||
*/
|
||||
|
||||
import sessionManager from '../../sessionManager.js';
|
||||
import { getGeminiCliSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
/**
|
||||
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
|
||||
* Handles: message (delta/final), tool_use, tool_result, result, error.
|
||||
* @param {object} raw - A parsed NDJSON event
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
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 msgs = [];
|
||||
if (content) {
|
||||
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
|
||||
}
|
||||
// If not a delta, also send stream_end
|
||||
if (raw.delta !== true) {
|
||||
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
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 msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
||||
if (raw.stats?.total_tokens) {
|
||||
msgs.push(createNormalizedMessage({
|
||||
sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const geminiAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history for Gemini.
|
||||
* First tries in-memory session manager, then falls back to CLI sessions on disk.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
let rawMessages;
|
||||
try {
|
||||
rawMessages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
// Fallback to Gemini CLI sessions on disk
|
||||
if (rawMessages.length === 0) {
|
||||
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const normalized = [];
|
||||
for (let i = 0; i < rawMessages.length; i++) {
|
||||
const raw = rawMessages[i];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
|
||||
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content || raw.content;
|
||||
|
||||
if (!role || !content) continue;
|
||||
|
||||
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx];
|
||||
if (part.type === 'text' && part.text) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id || generateMessageId('gemini_tool'),
|
||||
}));
|
||||
} else if (part.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id || '',
|
||||
content: part.content === undefined ? '' : String(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total: normalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
44
server/providers/registry.js
Normal file
44
server/providers/registry.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Centralizes provider adapter lookup. All code that needs a provider adapter
|
||||
* should go through this registry instead of importing individual adapters directly.
|
||||
*
|
||||
* @module providers/registry
|
||||
*/
|
||||
|
||||
import { claudeAdapter } from './claude/adapter.js';
|
||||
import { cursorAdapter } from './cursor/adapter.js';
|
||||
import { codexAdapter } from './codex/adapter.js';
|
||||
import { geminiAdapter } from './gemini/adapter.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
|
||||
* @typedef {import('./types.js').SessionProvider} SessionProvider
|
||||
*/
|
||||
|
||||
/** @type {Map<string, ProviderAdapter>} */
|
||||
const providers = new Map();
|
||||
|
||||
// Register built-in providers
|
||||
providers.set('claude', claudeAdapter);
|
||||
providers.set('cursor', cursorAdapter);
|
||||
providers.set('codex', codexAdapter);
|
||||
providers.set('gemini', geminiAdapter);
|
||||
|
||||
/**
|
||||
* Get a provider adapter by name.
|
||||
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
|
||||
* @returns {ProviderAdapter | undefined}
|
||||
*/
|
||||
export function getProvider(name) {
|
||||
return providers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered provider names.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAllProviders() {
|
||||
return Array.from(providers.keys());
|
||||
}
|
||||
119
server/providers/types.js
Normal file
119
server/providers/types.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Provider Types & Interface
|
||||
*
|
||||
* Defines the normalized message format and the provider adapter interface.
|
||||
* All providers normalize their native formats into NormalizedMessage
|
||||
* before sending over REST or WebSocket.
|
||||
*
|
||||
* @module providers/types
|
||||
*/
|
||||
|
||||
// ─── Session Provider ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
|
||||
*/
|
||||
|
||||
// ─── Message Kind ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
|
||||
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
|
||||
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
|
||||
*/
|
||||
|
||||
// ─── NormalizedMessage ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedMessage
|
||||
* @property {string} id - Unique message id (for dedup between server + realtime)
|
||||
* @property {string} sessionId
|
||||
* @property {string} timestamp - ISO 8601
|
||||
* @property {SessionProvider} provider
|
||||
* @property {MessageKind} kind
|
||||
*
|
||||
* Additional fields depending on kind:
|
||||
* - text: role ('user'|'assistant'), content, images?
|
||||
* - tool_use: toolName, toolInput, toolId
|
||||
* - tool_result: toolId, content, isError
|
||||
* - thinking: content
|
||||
* - stream_delta: content
|
||||
* - stream_end: (no extra fields)
|
||||
* - error: content
|
||||
* - complete: (no extra fields)
|
||||
* - status: text, tokens?, canInterrupt?
|
||||
* - permission_request: requestId, toolName, input, context?
|
||||
* - permission_cancelled: requestId
|
||||
* - session_created: newSessionId
|
||||
* - interactive_prompt: content
|
||||
* - task_notification: status, summary
|
||||
*/
|
||||
|
||||
// ─── Fetch History ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} FetchHistoryOptions
|
||||
* @property {string} [projectName] - Project name (required for Claude)
|
||||
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
|
||||
* @property {number|null} [limit] - Page size (null = all messages)
|
||||
* @property {number} [offset] - Pagination offset (default: 0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FetchHistoryResult
|
||||
* @property {NormalizedMessage[]} messages - Normalized messages
|
||||
* @property {number} total - Total number of messages in the session
|
||||
* @property {boolean} hasMore - Whether more messages exist before the current page
|
||||
* @property {number} offset - Current offset
|
||||
* @property {number|null} limit - Page size used
|
||||
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
||||
*/
|
||||
|
||||
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every provider adapter MUST implement this interface.
|
||||
*
|
||||
* @typedef {Object} ProviderAdapter
|
||||
*
|
||||
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
|
||||
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
|
||||
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
|
||||
*
|
||||
* Provider implementations:
|
||||
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
|
||||
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
|
||||
* - Codex: reads ~/.codex/sessions/*.jsonl
|
||||
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
|
||||
*
|
||||
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
|
||||
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
|
||||
* Used by provider files to convert both history and realtime events.
|
||||
*/
|
||||
|
||||
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a unique message ID.
|
||||
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
|
||||
* @param {string} [prefix='msg'] - Optional prefix
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateMessageId(prefix = 'msg') {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NormalizedMessage with common fields pre-filled.
|
||||
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
|
||||
* @returns {NormalizedMessage}
|
||||
*/
|
||||
export function createNormalizedMessage(fields) {
|
||||
return {
|
||||
...fields,
|
||||
id: fields.id || generateMessageId(fields.kind),
|
||||
sessionId: fields.sessionId || '',
|
||||
timestamp: fields.timestamp || new Date().toISOString(),
|
||||
provider: fields.provider,
|
||||
};
|
||||
}
|
||||
29
server/providers/utils.js
Normal file
29
server/providers/utils.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared provider utilities.
|
||||
*
|
||||
* @module providers/utils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prefixes that indicate internal/system content which should be hidden from the UI.
|
||||
* @type {readonly string[]}
|
||||
*/
|
||||
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'[Request interrupted',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if user text content is internal/system that should be skipped.
|
||||
* @param {string} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInternalContent(content) {
|
||||
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
|
||||
}
|
||||
Reference in New Issue
Block a user