@siteboon/claude-code-ui 1.25.2 → 1.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +239 -0
- package/README.ja.md +115 -230
- package/README.ko.md +116 -231
- package/README.md +2 -1
- package/README.ru.md +75 -54
- package/README.zh-CN.md +121 -238
- package/dist/assets/index-BenyXiE2.css +32 -0
- package/dist/assets/{index-DF_FFT3b.js → index-dyw-e9VE.js} +249 -237
- package/dist/index.html +2 -2
- package/dist/sw.js +102 -27
- package/package.json +3 -2
- package/server/claude-sdk.js +106 -62
- package/server/cli.js +10 -7
- package/server/cursor-cli.js +59 -73
- package/server/database/db.js +142 -1
- package/server/database/init.sql +28 -1
- package/server/gemini-cli.js +46 -48
- package/server/gemini-response-handler.js +12 -73
- package/server/index.js +82 -55
- package/server/middleware/auth.js +2 -2
- package/server/openai-codex.js +43 -28
- package/server/projects.js +1 -1
- package/server/providers/claude/adapter.js +278 -0
- package/server/providers/codex/adapter.js +248 -0
- package/server/providers/cursor/adapter.js +353 -0
- package/server/providers/gemini/adapter.js +186 -0
- package/server/providers/registry.js +44 -0
- package/server/providers/types.js +119 -0
- package/server/providers/utils.js +29 -0
- package/server/routes/agent.js +7 -5
- package/server/routes/cli-auth.js +38 -0
- package/server/routes/codex.js +1 -19
- package/server/routes/gemini.js +0 -30
- package/server/routes/git.js +48 -20
- package/server/routes/messages.js +61 -0
- package/server/routes/plugins.js +5 -1
- package/server/routes/settings.js +99 -1
- package/server/routes/taskmaster.js +2 -2
- package/server/services/notification-orchestrator.js +227 -0
- package/server/services/vapid-keys.js +35 -0
- package/server/utils/plugin-loader.js +53 -4
- package/shared/networkHosts.js +22 -0
- package/dist/assets/index-WNTmA_ug.css +0 -32
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor provider adapter.
|
|
3
|
+
*
|
|
4
|
+
* Normalizes Cursor CLI session history into NormalizedMessage format.
|
|
5
|
+
* @module adapters/cursor
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
|
12
|
+
|
|
13
|
+
const PROVIDER = 'cursor';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
|
17
|
+
* and return sorted message blobs in chronological order.
|
|
18
|
+
* @param {string} sessionId
|
|
19
|
+
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
|
|
20
|
+
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
|
21
|
+
*/
|
|
22
|
+
async function loadCursorBlobs(sessionId, projectPath) {
|
|
23
|
+
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable
|
|
24
|
+
const { default: sqlite3 } = await import('sqlite3');
|
|
25
|
+
const { open } = await import('sqlite');
|
|
26
|
+
|
|
27
|
+
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
|
28
|
+
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
|
29
|
+
|
|
30
|
+
const db = await open({
|
|
31
|
+
filename: storeDbPath,
|
|
32
|
+
driver: sqlite3.Database,
|
|
33
|
+
mode: sqlite3.OPEN_READONLY,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');
|
|
38
|
+
|
|
39
|
+
const blobMap = new Map();
|
|
40
|
+
const parentRefs = new Map();
|
|
41
|
+
const childRefs = new Map();
|
|
42
|
+
const jsonBlobs = [];
|
|
43
|
+
|
|
44
|
+
for (const blob of allBlobs) {
|
|
45
|
+
blobMap.set(blob.id, blob);
|
|
46
|
+
|
|
47
|
+
if (blob.data && blob.data[0] === 0x7B) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(blob.data.toString('utf8'));
|
|
50
|
+
jsonBlobs.push({ ...blob, parsed });
|
|
51
|
+
} catch {
|
|
52
|
+
// skip unparseable blobs
|
|
53
|
+
}
|
|
54
|
+
} else if (blob.data) {
|
|
55
|
+
const parents = [];
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < blob.data.length - 33) {
|
|
58
|
+
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
|
59
|
+
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
|
60
|
+
if (blobMap.has(parentHash)) {
|
|
61
|
+
parents.push(parentHash);
|
|
62
|
+
}
|
|
63
|
+
i += 34;
|
|
64
|
+
} else {
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (parents.length > 0) {
|
|
69
|
+
parentRefs.set(blob.id, parents);
|
|
70
|
+
for (const parentId of parents) {
|
|
71
|
+
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
|
|
72
|
+
childRefs.get(parentId).push(blob.id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Topological sort (DFS)
|
|
79
|
+
const visited = new Set();
|
|
80
|
+
const sorted = [];
|
|
81
|
+
function visit(nodeId) {
|
|
82
|
+
if (visited.has(nodeId)) return;
|
|
83
|
+
visited.add(nodeId);
|
|
84
|
+
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
|
|
85
|
+
const b = blobMap.get(nodeId);
|
|
86
|
+
if (b) sorted.push(b);
|
|
87
|
+
}
|
|
88
|
+
for (const blob of allBlobs) {
|
|
89
|
+
if (!parentRefs.has(blob.id)) visit(blob.id);
|
|
90
|
+
}
|
|
91
|
+
for (const blob of allBlobs) visit(blob.id);
|
|
92
|
+
|
|
93
|
+
// Order JSON blobs by DAG appearance
|
|
94
|
+
const messageOrder = new Map();
|
|
95
|
+
let orderIndex = 0;
|
|
96
|
+
for (const blob of sorted) {
|
|
97
|
+
if (blob.data && blob.data[0] !== 0x7B) {
|
|
98
|
+
for (const jb of jsonBlobs) {
|
|
99
|
+
try {
|
|
100
|
+
const idBytes = Buffer.from(jb.id, 'hex');
|
|
101
|
+
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
|
|
102
|
+
messageOrder.set(jb.id, orderIndex++);
|
|
103
|
+
}
|
|
104
|
+
} catch { /* skip */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
|
110
|
+
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
|
111
|
+
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
|
112
|
+
return oa !== ob ? oa - ob : a.rowid - b.rowid;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const messages = [];
|
|
116
|
+
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
|
117
|
+
const blob = sortedJsonBlobs[idx];
|
|
118
|
+
const parsed = blob.parsed;
|
|
119
|
+
if (!parsed) continue;
|
|
120
|
+
const role = parsed?.role || parsed?.message?.role;
|
|
121
|
+
if (role === 'system') continue;
|
|
122
|
+
messages.push({
|
|
123
|
+
id: blob.id,
|
|
124
|
+
sequence: idx + 1,
|
|
125
|
+
rowid: blob.rowid,
|
|
126
|
+
content: parsed,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return messages;
|
|
131
|
+
} finally {
|
|
132
|
+
await db.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
|
138
|
+
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
|
139
|
+
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
|
140
|
+
* @param {string} sessionId
|
|
141
|
+
* @returns {import('../types.js').NormalizedMessage[]}
|
|
142
|
+
*/
|
|
143
|
+
export function normalizeMessage(raw, sessionId) {
|
|
144
|
+
// Structured assistant message with content array
|
|
145
|
+
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
|
146
|
+
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
|
|
147
|
+
}
|
|
148
|
+
// Plain string line (non-JSON output)
|
|
149
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
150
|
+
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
|
|
151
|
+
}
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @type {import('../types.js').ProviderAdapter}
|
|
157
|
+
*/
|
|
158
|
+
export const cursorAdapter = {
|
|
159
|
+
normalizeMessage,
|
|
160
|
+
/**
|
|
161
|
+
* Fetch session history for Cursor from SQLite store.db.
|
|
162
|
+
*/
|
|
163
|
+
async fetchHistory(sessionId, opts = {}) {
|
|
164
|
+
const { projectPath = '', limit = null, offset = 0 } = opts;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const blobs = await loadCursorBlobs(sessionId, projectPath);
|
|
168
|
+
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
|
|
169
|
+
|
|
170
|
+
// Apply pagination
|
|
171
|
+
if (limit !== null && limit > 0) {
|
|
172
|
+
const start = offset;
|
|
173
|
+
const page = allNormalized.slice(start, start + limit);
|
|
174
|
+
return {
|
|
175
|
+
messages: page,
|
|
176
|
+
total: allNormalized.length,
|
|
177
|
+
hasMore: start + limit < allNormalized.length,
|
|
178
|
+
offset,
|
|
179
|
+
limit,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
messages: allNormalized,
|
|
185
|
+
total: allNormalized.length,
|
|
186
|
+
hasMore: false,
|
|
187
|
+
offset: 0,
|
|
188
|
+
limit: null,
|
|
189
|
+
};
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// DB doesn't exist or is unreadable — return empty
|
|
192
|
+
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
|
|
193
|
+
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalize raw Cursor blob messages into NormalizedMessage[].
|
|
199
|
+
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
|
|
200
|
+
* @param {string} sessionId
|
|
201
|
+
* @returns {import('../types.js').NormalizedMessage[]}
|
|
202
|
+
*/
|
|
203
|
+
normalizeCursorBlobs(blobs, sessionId) {
|
|
204
|
+
const messages = [];
|
|
205
|
+
const toolUseMap = new Map();
|
|
206
|
+
|
|
207
|
+
// Use a fixed base timestamp so messages have stable, monotonically-increasing
|
|
208
|
+
// timestamps based on their sequence number rather than wall-clock time.
|
|
209
|
+
const baseTime = Date.now();
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < blobs.length; i++) {
|
|
212
|
+
const blob = blobs[i];
|
|
213
|
+
const content = blob.content;
|
|
214
|
+
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
|
215
|
+
const baseId = blob.id || generateMessageId('cursor');
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
if (!content?.role || !content?.content) {
|
|
219
|
+
// Try nested message format
|
|
220
|
+
if (content?.message?.role && content?.message?.content) {
|
|
221
|
+
if (content.message.role === 'system') continue;
|
|
222
|
+
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
|
223
|
+
let text = '';
|
|
224
|
+
if (Array.isArray(content.message.content)) {
|
|
225
|
+
text = content.message.content
|
|
226
|
+
.map(p => typeof p === 'string' ? p : p?.text || '')
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join('\n');
|
|
229
|
+
} else if (typeof content.message.content === 'string') {
|
|
230
|
+
text = content.message.content;
|
|
231
|
+
}
|
|
232
|
+
if (text?.trim()) {
|
|
233
|
+
messages.push(createNormalizedMessage({
|
|
234
|
+
id: baseId,
|
|
235
|
+
sessionId,
|
|
236
|
+
timestamp: ts,
|
|
237
|
+
provider: PROVIDER,
|
|
238
|
+
kind: 'text',
|
|
239
|
+
role,
|
|
240
|
+
content: text,
|
|
241
|
+
sequence: blob.sequence,
|
|
242
|
+
rowid: blob.rowid,
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (content.role === 'system') continue;
|
|
250
|
+
|
|
251
|
+
// Tool results
|
|
252
|
+
if (content.role === 'tool') {
|
|
253
|
+
const toolItems = Array.isArray(content.content) ? content.content : [];
|
|
254
|
+
for (const item of toolItems) {
|
|
255
|
+
if (item?.type !== 'tool-result') continue;
|
|
256
|
+
const toolCallId = item.toolCallId || content.id;
|
|
257
|
+
messages.push(createNormalizedMessage({
|
|
258
|
+
id: `${baseId}_tr`,
|
|
259
|
+
sessionId,
|
|
260
|
+
timestamp: ts,
|
|
261
|
+
provider: PROVIDER,
|
|
262
|
+
kind: 'tool_result',
|
|
263
|
+
toolId: toolCallId,
|
|
264
|
+
content: item.result || '',
|
|
265
|
+
isError: false,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const role = content.role === 'user' ? 'user' : 'assistant';
|
|
272
|
+
|
|
273
|
+
if (Array.isArray(content.content)) {
|
|
274
|
+
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
|
275
|
+
const part = content.content[partIdx];
|
|
276
|
+
|
|
277
|
+
if (part?.type === 'text' && part?.text) {
|
|
278
|
+
messages.push(createNormalizedMessage({
|
|
279
|
+
id: `${baseId}_${partIdx}`,
|
|
280
|
+
sessionId,
|
|
281
|
+
timestamp: ts,
|
|
282
|
+
provider: PROVIDER,
|
|
283
|
+
kind: 'text',
|
|
284
|
+
role,
|
|
285
|
+
content: part.text,
|
|
286
|
+
sequence: blob.sequence,
|
|
287
|
+
rowid: blob.rowid,
|
|
288
|
+
}));
|
|
289
|
+
} else if (part?.type === 'reasoning' && part?.text) {
|
|
290
|
+
messages.push(createNormalizedMessage({
|
|
291
|
+
id: `${baseId}_${partIdx}`,
|
|
292
|
+
sessionId,
|
|
293
|
+
timestamp: ts,
|
|
294
|
+
provider: PROVIDER,
|
|
295
|
+
kind: 'thinking',
|
|
296
|
+
content: part.text,
|
|
297
|
+
}));
|
|
298
|
+
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
|
299
|
+
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
|
|
300
|
+
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
|
|
301
|
+
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
|
302
|
+
messages.push(createNormalizedMessage({
|
|
303
|
+
id: `${baseId}_${partIdx}`,
|
|
304
|
+
sessionId,
|
|
305
|
+
timestamp: ts,
|
|
306
|
+
provider: PROVIDER,
|
|
307
|
+
kind: 'tool_use',
|
|
308
|
+
toolName,
|
|
309
|
+
toolInput: part.args || part.input,
|
|
310
|
+
toolId,
|
|
311
|
+
}));
|
|
312
|
+
toolUseMap.set(toolId, messages[messages.length - 1]);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else if (typeof content.content === 'string' && content.content.trim()) {
|
|
316
|
+
messages.push(createNormalizedMessage({
|
|
317
|
+
id: baseId,
|
|
318
|
+
sessionId,
|
|
319
|
+
timestamp: ts,
|
|
320
|
+
provider: PROVIDER,
|
|
321
|
+
kind: 'text',
|
|
322
|
+
role,
|
|
323
|
+
content: content.content,
|
|
324
|
+
sequence: blob.sequence,
|
|
325
|
+
rowid: blob.rowid,
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.warn('Error normalizing cursor blob:', error);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Attach tool results to tool_use messages
|
|
334
|
+
for (const msg of messages) {
|
|
335
|
+
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
|
336
|
+
const toolUse = toolUseMap.get(msg.toolId);
|
|
337
|
+
toolUse.toolResult = {
|
|
338
|
+
content: msg.content,
|
|
339
|
+
isError: msg.isError,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Sort by sequence/rowid
|
|
345
|
+
messages.sort((a, b) => {
|
|
346
|
+
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
|
|
347
|
+
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
|
|
348
|
+
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return messages;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini provider adapter.
|
|
3
|
+
*
|
|
4
|
+
* Normalizes Gemini CLI session history into NormalizedMessage format.
|
|
5
|
+
* @module adapters/gemini
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import sessionManager from '../../sessionManager.js';
|
|
9
|
+
import { getGeminiCliSessionMessages } from '../../projects.js';
|
|
10
|
+
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
|
11
|
+
|
|
12
|
+
const PROVIDER = 'gemini';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
|
|
16
|
+
* Handles: message (delta/final), tool_use, tool_result, result, error.
|
|
17
|
+
* @param {object} raw - A parsed NDJSON event
|
|
18
|
+
* @param {string} sessionId
|
|
19
|
+
* @returns {import('../types.js').NormalizedMessage[]}
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeMessage(raw, sessionId) {
|
|
22
|
+
const ts = raw.timestamp || new Date().toISOString();
|
|
23
|
+
const baseId = raw.uuid || generateMessageId('gemini');
|
|
24
|
+
|
|
25
|
+
if (raw.type === 'message' && raw.role === 'assistant') {
|
|
26
|
+
const content = raw.content || '';
|
|
27
|
+
const msgs = [];
|
|
28
|
+
if (content) {
|
|
29
|
+
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
|
|
30
|
+
}
|
|
31
|
+
// If not a delta, also send stream_end
|
|
32
|
+
if (raw.delta !== true) {
|
|
33
|
+
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
|
|
34
|
+
}
|
|
35
|
+
return msgs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (raw.type === 'tool_use') {
|
|
39
|
+
return [createNormalizedMessage({
|
|
40
|
+
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
|
41
|
+
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
|
|
42
|
+
toolId: raw.tool_id || baseId,
|
|
43
|
+
})];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (raw.type === 'tool_result') {
|
|
47
|
+
return [createNormalizedMessage({
|
|
48
|
+
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
|
49
|
+
kind: 'tool_result', toolId: raw.tool_id || '',
|
|
50
|
+
content: raw.output === undefined ? '' : String(raw.output),
|
|
51
|
+
isError: raw.status === 'error',
|
|
52
|
+
})];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (raw.type === 'result') {
|
|
56
|
+
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
|
57
|
+
if (raw.stats?.total_tokens) {
|
|
58
|
+
msgs.push(createNormalizedMessage({
|
|
59
|
+
sessionId, timestamp: ts, provider: PROVIDER,
|
|
60
|
+
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
return msgs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (raw.type === 'error') {
|
|
67
|
+
return [createNormalizedMessage({
|
|
68
|
+
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
|
69
|
+
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
|
70
|
+
})];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @type {import('../types.js').ProviderAdapter}
|
|
78
|
+
*/
|
|
79
|
+
export const geminiAdapter = {
|
|
80
|
+
normalizeMessage,
|
|
81
|
+
/**
|
|
82
|
+
* Fetch session history for Gemini.
|
|
83
|
+
* First tries in-memory session manager, then falls back to CLI sessions on disk.
|
|
84
|
+
*/
|
|
85
|
+
async fetchHistory(sessionId, opts = {}) {
|
|
86
|
+
let rawMessages;
|
|
87
|
+
try {
|
|
88
|
+
rawMessages = sessionManager.getSessionMessages(sessionId);
|
|
89
|
+
|
|
90
|
+
// Fallback to Gemini CLI sessions on disk
|
|
91
|
+
if (rawMessages.length === 0) {
|
|
92
|
+
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
|
|
96
|
+
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const normalized = [];
|
|
100
|
+
for (let i = 0; i < rawMessages.length; i++) {
|
|
101
|
+
const raw = rawMessages[i];
|
|
102
|
+
const ts = raw.timestamp || new Date().toISOString();
|
|
103
|
+
const baseId = raw.uuid || generateMessageId('gemini');
|
|
104
|
+
|
|
105
|
+
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
|
|
106
|
+
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
|
|
107
|
+
const role = raw.message?.role || raw.role;
|
|
108
|
+
const content = raw.message?.content || raw.content;
|
|
109
|
+
|
|
110
|
+
if (!role || !content) continue;
|
|
111
|
+
|
|
112
|
+
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(content)) {
|
|
115
|
+
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
|
116
|
+
const part = content[partIdx];
|
|
117
|
+
if (part.type === 'text' && part.text) {
|
|
118
|
+
normalized.push(createNormalizedMessage({
|
|
119
|
+
id: `${baseId}_${partIdx}`,
|
|
120
|
+
sessionId,
|
|
121
|
+
timestamp: ts,
|
|
122
|
+
provider: PROVIDER,
|
|
123
|
+
kind: 'text',
|
|
124
|
+
role: normalizedRole,
|
|
125
|
+
content: part.text,
|
|
126
|
+
}));
|
|
127
|
+
} else if (part.type === 'tool_use') {
|
|
128
|
+
normalized.push(createNormalizedMessage({
|
|
129
|
+
id: `${baseId}_${partIdx}`,
|
|
130
|
+
sessionId,
|
|
131
|
+
timestamp: ts,
|
|
132
|
+
provider: PROVIDER,
|
|
133
|
+
kind: 'tool_use',
|
|
134
|
+
toolName: part.name,
|
|
135
|
+
toolInput: part.input,
|
|
136
|
+
toolId: part.id || generateMessageId('gemini_tool'),
|
|
137
|
+
}));
|
|
138
|
+
} else if (part.type === 'tool_result') {
|
|
139
|
+
normalized.push(createNormalizedMessage({
|
|
140
|
+
id: `${baseId}_${partIdx}`,
|
|
141
|
+
sessionId,
|
|
142
|
+
timestamp: ts,
|
|
143
|
+
provider: PROVIDER,
|
|
144
|
+
kind: 'tool_result',
|
|
145
|
+
toolId: part.tool_use_id || '',
|
|
146
|
+
content: part.content === undefined ? '' : String(part.content),
|
|
147
|
+
isError: Boolean(part.is_error),
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else if (typeof content === 'string' && content.trim()) {
|
|
152
|
+
normalized.push(createNormalizedMessage({
|
|
153
|
+
id: baseId,
|
|
154
|
+
sessionId,
|
|
155
|
+
timestamp: ts,
|
|
156
|
+
provider: PROVIDER,
|
|
157
|
+
kind: 'text',
|
|
158
|
+
role: normalizedRole,
|
|
159
|
+
content,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Attach tool results to tool_use messages
|
|
165
|
+
const toolResultMap = new Map();
|
|
166
|
+
for (const msg of normalized) {
|
|
167
|
+
if (msg.kind === 'tool_result' && msg.toolId) {
|
|
168
|
+
toolResultMap.set(msg.toolId, msg);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const msg of normalized) {
|
|
172
|
+
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
|
173
|
+
const tr = toolResultMap.get(msg.toolId);
|
|
174
|
+
msg.toolResult = { content: tr.content, isError: tr.isError };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
messages: normalized,
|
|
180
|
+
total: normalized.length,
|
|
181
|
+
hasMore: false,
|
|
182
|
+
offset: 0,
|
|
183
|
+
limit: null,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Registry
|
|
3
|
+
*
|
|
4
|
+
* Centralizes provider adapter lookup. All code that needs a provider adapter
|
|
5
|
+
* should go through this registry instead of importing individual adapters directly.
|
|
6
|
+
*
|
|
7
|
+
* @module providers/registry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { claudeAdapter } from './claude/adapter.js';
|
|
11
|
+
import { cursorAdapter } from './cursor/adapter.js';
|
|
12
|
+
import { codexAdapter } from './codex/adapter.js';
|
|
13
|
+
import { geminiAdapter } from './gemini/adapter.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
|
|
17
|
+
* @typedef {import('./types.js').SessionProvider} SessionProvider
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {Map<string, ProviderAdapter>} */
|
|
21
|
+
const providers = new Map();
|
|
22
|
+
|
|
23
|
+
// Register built-in providers
|
|
24
|
+
providers.set('claude', claudeAdapter);
|
|
25
|
+
providers.set('cursor', cursorAdapter);
|
|
26
|
+
providers.set('codex', codexAdapter);
|
|
27
|
+
providers.set('gemini', geminiAdapter);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get a provider adapter by name.
|
|
31
|
+
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
|
|
32
|
+
* @returns {ProviderAdapter | undefined}
|
|
33
|
+
*/
|
|
34
|
+
export function getProvider(name) {
|
|
35
|
+
return providers.get(name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get all registered provider names.
|
|
40
|
+
* @returns {string[]}
|
|
41
|
+
*/
|
|
42
|
+
export function getAllProviders() {
|
|
43
|
+
return Array.from(providers.keys());
|
|
44
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Types & Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the normalized message format and the provider adapter interface.
|
|
5
|
+
* All providers normalize their native formats into NormalizedMessage
|
|
6
|
+
* before sending over REST or WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* @module providers/types
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Session Provider ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ─── Message Kind ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
|
|
21
|
+
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
|
|
22
|
+
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ─── NormalizedMessage ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} NormalizedMessage
|
|
29
|
+
* @property {string} id - Unique message id (for dedup between server + realtime)
|
|
30
|
+
* @property {string} sessionId
|
|
31
|
+
* @property {string} timestamp - ISO 8601
|
|
32
|
+
* @property {SessionProvider} provider
|
|
33
|
+
* @property {MessageKind} kind
|
|
34
|
+
*
|
|
35
|
+
* Additional fields depending on kind:
|
|
36
|
+
* - text: role ('user'|'assistant'), content, images?
|
|
37
|
+
* - tool_use: toolName, toolInput, toolId
|
|
38
|
+
* - tool_result: toolId, content, isError
|
|
39
|
+
* - thinking: content
|
|
40
|
+
* - stream_delta: content
|
|
41
|
+
* - stream_end: (no extra fields)
|
|
42
|
+
* - error: content
|
|
43
|
+
* - complete: (no extra fields)
|
|
44
|
+
* - status: text, tokens?, canInterrupt?
|
|
45
|
+
* - permission_request: requestId, toolName, input, context?
|
|
46
|
+
* - permission_cancelled: requestId
|
|
47
|
+
* - session_created: newSessionId
|
|
48
|
+
* - interactive_prompt: content
|
|
49
|
+
* - task_notification: status, summary
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
// ─── Fetch History ───────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} FetchHistoryOptions
|
|
56
|
+
* @property {string} [projectName] - Project name (required for Claude)
|
|
57
|
+
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
|
|
58
|
+
* @property {number|null} [limit] - Page size (null = all messages)
|
|
59
|
+
* @property {number} [offset] - Pagination offset (default: 0)
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} FetchHistoryResult
|
|
64
|
+
* @property {NormalizedMessage[]} messages - Normalized messages
|
|
65
|
+
* @property {number} total - Total number of messages in the session
|
|
66
|
+
* @property {boolean} hasMore - Whether more messages exist before the current page
|
|
67
|
+
* @property {number} offset - Current offset
|
|
68
|
+
* @property {number|null} limit - Page size used
|
|
69
|
+
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every provider adapter MUST implement this interface.
|
|
76
|
+
*
|
|
77
|
+
* @typedef {Object} ProviderAdapter
|
|
78
|
+
*
|
|
79
|
+
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
|
|
80
|
+
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
|
|
81
|
+
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
|
|
82
|
+
*
|
|
83
|
+
* Provider implementations:
|
|
84
|
+
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
|
|
85
|
+
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
|
|
86
|
+
* - Codex: reads ~/.codex/sessions/*.jsonl
|
|
87
|
+
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
|
|
88
|
+
*
|
|
89
|
+
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
|
|
90
|
+
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
|
|
91
|
+
* Used by provider files to convert both history and realtime events.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate a unique message ID.
|
|
98
|
+
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
|
|
99
|
+
* @param {string} [prefix='msg'] - Optional prefix
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function generateMessageId(prefix = 'msg') {
|
|
103
|
+
return `${prefix}_${crypto.randomUUID()}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a NormalizedMessage with common fields pre-filled.
|
|
108
|
+
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
|
|
109
|
+
* @returns {NormalizedMessage}
|
|
110
|
+
*/
|
|
111
|
+
export function createNormalizedMessage(fields) {
|
|
112
|
+
return {
|
|
113
|
+
...fields,
|
|
114
|
+
id: fields.id || generateMessageId(fields.kind),
|
|
115
|
+
sessionId: fields.sessionId || '',
|
|
116
|
+
timestamp: fields.timestamp || new Date().toISOString(),
|
|
117
|
+
provider: fields.provider,
|
|
118
|
+
};
|
|
119
|
+
}
|