@mmmbuto/nexuscli 0.7.7 → 0.7.9
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.md +18 -30
- package/bin/nexuscli.js +6 -6
- package/frontend/dist/assets/{index-CHOlrfA0.css → index-WfmfixF4.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/lib/server/.env.example +1 -1
- package/lib/server/lib/pty-adapter.js +1 -15
- package/lib/server/routes/codex.js +9 -2
- package/lib/server/routes/gemini.js +9 -3
- package/lib/server/routes/sessions.js +15 -0
- package/lib/server/server.js +9 -0
- package/lib/server/services/claude-wrapper.js +1 -11
- package/lib/server/services/codex-output-parser.js +0 -8
- package/lib/server/services/codex-wrapper.js +3 -3
- package/lib/server/services/context-bridge.js +143 -24
- package/lib/server/services/gemini-wrapper.js +4 -3
- package/lib/server/services/session-importer.js +155 -0
- package/lib/server/services/workspace-manager.js +2 -7
- package/lib/server/tests/performance.test.js +1 -1
- package/lib/server/tests/services.test.js +2 -2
- package/lib/setup/postinstall.js +4 -1
- package/package.json +1 -1
- package/lib/server/db.js.old +0 -225
- package/lib/server/docs/API_WRAPPER_CONTRACT.md +0 -682
- package/lib/server/docs/ARCHITECTURE.md +0 -441
- package/lib/server/docs/DATABASE_SCHEMA.md +0 -783
- package/lib/server/docs/DESIGN_PRINCIPLES.md +0 -598
- package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +0 -488
- package/lib/server/docs/PIPELINE_INTEGRATION.md +0 -636
- package/lib/server/docs/README.md +0 -272
- package/lib/server/docs/UI_DESIGN.md +0 -916
- package/lib/server/services/base-cli-wrapper.js +0 -137
- package/lib/server/services/cli-loader.js.backup +0 -446
- /package/frontend/dist/assets/{index-BAY_sRAu.js → index-BbBoc8w4.js} +0 -0
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Base CLI Wrapper - Common interrupt logic for all CLI wrappers
|
|
3
|
-
*
|
|
4
|
-
* Provides unified process tracking and interrupt capability for:
|
|
5
|
-
* - ClaudeWrapper (PTY)
|
|
6
|
-
* - GeminiWrapper (PTY)
|
|
7
|
-
* - CodexWrapper (child_process.spawn)
|
|
8
|
-
*
|
|
9
|
-
* @version 0.5.0 - Interrupt Support
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
class BaseCliWrapper {
|
|
13
|
-
constructor() {
|
|
14
|
-
// Map: sessionId → { process, type, startTime }
|
|
15
|
-
this.activeProcesses = new Map();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Register active process for interrupt capability
|
|
20
|
-
* @param {string} sessionId - Session/conversation ID
|
|
21
|
-
* @param {Object} process - PTY process or child_process
|
|
22
|
-
* @param {string} type - 'pty' or 'spawn'
|
|
23
|
-
*/
|
|
24
|
-
registerProcess(sessionId, process, type = 'pty') {
|
|
25
|
-
this.activeProcesses.set(sessionId, {
|
|
26
|
-
process,
|
|
27
|
-
type,
|
|
28
|
-
startTime: Date.now()
|
|
29
|
-
});
|
|
30
|
-
console.log(`[BaseWrapper] Registered ${type} process for session ${sessionId}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Unregister process on completion
|
|
35
|
-
* @param {string} sessionId
|
|
36
|
-
*/
|
|
37
|
-
unregisterProcess(sessionId) {
|
|
38
|
-
if (this.activeProcesses.has(sessionId)) {
|
|
39
|
-
this.activeProcesses.delete(sessionId);
|
|
40
|
-
console.log(`[BaseWrapper] Unregistered process for session ${sessionId}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Interrupt running process
|
|
46
|
-
*
|
|
47
|
-
* Strategy:
|
|
48
|
-
* - PTY: Try ESC (0x1B) first, then SIGINT
|
|
49
|
-
* - Spawn: SIGINT directly
|
|
50
|
-
*
|
|
51
|
-
* @param {string} sessionId
|
|
52
|
-
* @returns {{ success: boolean, method?: string, reason?: string }}
|
|
53
|
-
*/
|
|
54
|
-
interrupt(sessionId) {
|
|
55
|
-
const entry = this.activeProcesses.get(sessionId);
|
|
56
|
-
|
|
57
|
-
if (!entry) {
|
|
58
|
-
console.log(`[BaseWrapper] No active process for session ${sessionId}`);
|
|
59
|
-
return { success: false, reason: 'no_active_process' };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const { process, type } = entry;
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
if (type === 'pty') {
|
|
66
|
-
// PTY process: Try ESC first (gentler), then SIGINT
|
|
67
|
-
if (typeof process.sendEsc === 'function' && process.sendEsc()) {
|
|
68
|
-
console.log(`[BaseWrapper] Sent ESC to PTY session ${sessionId}`);
|
|
69
|
-
// Don't remove yet - let onExit handle cleanup
|
|
70
|
-
return { success: true, method: 'esc' };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ESC failed or not available, use SIGINT
|
|
74
|
-
if (typeof process.kill === 'function') {
|
|
75
|
-
process.kill('SIGINT');
|
|
76
|
-
console.log(`[BaseWrapper] Sent SIGINT to PTY session ${sessionId}`);
|
|
77
|
-
return { success: true, method: 'sigint' };
|
|
78
|
-
}
|
|
79
|
-
} else if (type === 'spawn') {
|
|
80
|
-
// child_process.spawn: SIGINT directly
|
|
81
|
-
if (typeof process.kill === 'function') {
|
|
82
|
-
process.kill('SIGINT');
|
|
83
|
-
console.log(`[BaseWrapper] Sent SIGINT to spawn session ${sessionId}`);
|
|
84
|
-
return { success: true, method: 'sigint' };
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return { success: false, reason: 'kill_not_available' };
|
|
89
|
-
} catch (err) {
|
|
90
|
-
console.error(`[BaseWrapper] Interrupt error for session ${sessionId}:`, err.message);
|
|
91
|
-
return { success: false, reason: err.message };
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Check if session has active process
|
|
97
|
-
* @param {string} sessionId
|
|
98
|
-
* @returns {boolean}
|
|
99
|
-
*/
|
|
100
|
-
isActive(sessionId) {
|
|
101
|
-
return this.activeProcesses.has(sessionId);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Get count of active processes
|
|
106
|
-
* @returns {number}
|
|
107
|
-
*/
|
|
108
|
-
getActiveCount() {
|
|
109
|
-
return this.activeProcesses.size;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Get all active session IDs
|
|
114
|
-
* @returns {string[]}
|
|
115
|
-
*/
|
|
116
|
-
getActiveSessions() {
|
|
117
|
-
return Array.from(this.activeProcesses.keys());
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Get process info for session
|
|
122
|
-
* @param {string} sessionId
|
|
123
|
-
* @returns {{ type: string, startTime: number, duration: number } | null}
|
|
124
|
-
*/
|
|
125
|
-
getProcessInfo(sessionId) {
|
|
126
|
-
const entry = this.activeProcesses.get(sessionId);
|
|
127
|
-
if (!entry) return null;
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
type: entry.type,
|
|
131
|
-
startTime: entry.startTime,
|
|
132
|
-
duration: Date.now() - entry.startTime
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
module.exports = BaseCliWrapper;
|
|
@@ -1,446 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CliLoader - Unified message loader for TRI CLI (Claude/Codex/Gemini)
|
|
3
|
-
*
|
|
4
|
-
* Loads messages on-demand from CLI history files (lazy loading).
|
|
5
|
-
* Filesystem is the source of truth - no DB caching of messages.
|
|
6
|
-
*
|
|
7
|
-
* Session file locations:
|
|
8
|
-
* - Claude: ~/.claude/projects/<workspace-slug>/<sessionId>.jsonl
|
|
9
|
-
* - Codex: ~/.codex/sessions/<sessionId>.jsonl (if available)
|
|
10
|
-
* - Gemini: ~/.gemini/sessions/<sessionId>.jsonl (if available)
|
|
11
|
-
*
|
|
12
|
-
* @version 0.4.0 - TRI CLI Support
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const fs = require('fs');
|
|
16
|
-
const path = require('path');
|
|
17
|
-
const readline = require('readline');
|
|
18
|
-
|
|
19
|
-
const DEFAULT_LIMIT = 30;
|
|
20
|
-
|
|
21
|
-
// Engine-specific paths
|
|
22
|
-
const ENGINE_PATHS = {
|
|
23
|
-
claude: path.join(process.env.HOME || '', '.claude'),
|
|
24
|
-
codex: path.join(process.env.HOME || '', '.codex'),
|
|
25
|
-
gemini: path.join(process.env.HOME || '', '.gemini'),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
class CliLoader {
|
|
29
|
-
constructor() {
|
|
30
|
-
this.claudePath = ENGINE_PATHS.claude;
|
|
31
|
-
this.codexPath = ENGINE_PATHS.codex;
|
|
32
|
-
this.geminiPath = ENGINE_PATHS.gemini;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Load messages from CLI history by session.
|
|
37
|
-
* Supports all three engines: Claude, Codex, Gemini.
|
|
38
|
-
*
|
|
39
|
-
* @param {Object} params
|
|
40
|
-
* @param {string} params.sessionId - Session UUID
|
|
41
|
-
* @param {string} params.engine - 'claude'|'claude-code'|'codex'|'gemini'
|
|
42
|
-
* @param {string} params.workspacePath - Workspace directory (required for Claude)
|
|
43
|
-
* @param {number} [params.limit=30] - Max messages to return
|
|
44
|
-
* @param {number} [params.before] - Timestamp cursor for pagination (ms)
|
|
45
|
-
* @param {string} [params.mode='asc'] - Return order ('asc'|'desc')
|
|
46
|
-
* @returns {Promise<{messages: Array, pagination: Object}>}
|
|
47
|
-
*/
|
|
48
|
-
async loadMessagesFromCLI({
|
|
49
|
-
sessionId,
|
|
50
|
-
threadId, // optional native thread id (e.g., Codex exec thread)
|
|
51
|
-
sessionPath, // alias kept for compatibility
|
|
52
|
-
engine = 'claude',
|
|
53
|
-
workspacePath,
|
|
54
|
-
limit = DEFAULT_LIMIT,
|
|
55
|
-
before,
|
|
56
|
-
mode = 'asc'
|
|
57
|
-
}) {
|
|
58
|
-
if (!sessionId) {
|
|
59
|
-
throw new Error('sessionId is required');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const startedAt = Date.now();
|
|
63
|
-
const normalizedEngine = this._normalizeEngine(engine);
|
|
64
|
-
const nativeId = threadId || sessionPath || sessionId;
|
|
65
|
-
|
|
66
|
-
let result;
|
|
67
|
-
switch (normalizedEngine) {
|
|
68
|
-
case 'claude':
|
|
69
|
-
result = await this.loadClaudeMessages({ sessionId, workspacePath, limit, before, mode });
|
|
70
|
-
break;
|
|
71
|
-
|
|
72
|
-
case 'codex':
|
|
73
|
-
result = await this.loadCodexMessages({ sessionId, nativeId, limit, before, mode });
|
|
74
|
-
break;
|
|
75
|
-
|
|
76
|
-
case 'gemini':
|
|
77
|
-
result = await this.loadGeminiMessages({ sessionId, limit, before, mode });
|
|
78
|
-
break;
|
|
79
|
-
|
|
80
|
-
default:
|
|
81
|
-
throw new Error(`Unsupported engine: ${engine}`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
console.log(`[CliLoader] ${normalizedEngine} messages loaded in ${Date.now() - startedAt}ms (session ${sessionId}, ${result.messages.length} msgs)`);
|
|
85
|
-
return result;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Normalize engine name variants
|
|
90
|
-
*/
|
|
91
|
-
_normalizeEngine(engine) {
|
|
92
|
-
if (!engine) return 'claude';
|
|
93
|
-
const lower = engine.toLowerCase();
|
|
94
|
-
if (lower.includes('claude')) return 'claude';
|
|
95
|
-
if (lower.includes('codex') || lower.includes('openai')) return 'codex';
|
|
96
|
-
if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
|
|
97
|
-
return lower;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Convert workspace path to slug (for .claude/projects/ directory)
|
|
102
|
-
* Same as Claude Code behavior: /path/to/dir → -path-to-dir
|
|
103
|
-
* Also converts dots to dashes (e.g., com.termux → com-termux)
|
|
104
|
-
*/
|
|
105
|
-
pathToSlug(workspacePath) {
|
|
106
|
-
if (!workspacePath) return '-default';
|
|
107
|
-
// Replace slashes AND dots with dashes (matches Claude Code behavior)
|
|
108
|
-
return workspacePath.replace(/[\/\.]/g, '-');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ============================================================
|
|
112
|
-
// CLAUDE - Load from ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
113
|
-
// ============================================================
|
|
114
|
-
|
|
115
|
-
async loadClaudeMessages({ sessionId, workspacePath, limit, before, mode }) {
|
|
116
|
-
if (!workspacePath) {
|
|
117
|
-
console.warn('[CliLoader] No workspacePath for Claude, using cwd');
|
|
118
|
-
workspacePath = process.cwd();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const slug = this.pathToSlug(workspacePath);
|
|
122
|
-
const sessionFile = path.join(this.claudePath, 'projects', slug, `${sessionId}.jsonl`);
|
|
123
|
-
|
|
124
|
-
if (!fs.existsSync(sessionFile)) {
|
|
125
|
-
console.warn(`[CliLoader] Claude session file not found: ${sessionFile}`);
|
|
126
|
-
return this._emptyResult();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
130
|
-
|
|
131
|
-
// Filter and normalize
|
|
132
|
-
const messages = rawMessages
|
|
133
|
-
.filter(entry => entry.type === 'user' || entry.type === 'assistant')
|
|
134
|
-
.map(entry => this._normalizeClaudeEntry(entry));
|
|
135
|
-
|
|
136
|
-
return this._paginateMessages(messages, limit, before, mode);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Normalize Claude Code session entry to message shape
|
|
141
|
-
*/
|
|
142
|
-
_normalizeClaudeEntry(entry) {
|
|
143
|
-
// Extract content - handle both string and array of content blocks
|
|
144
|
-
let content = '';
|
|
145
|
-
const rawContent = entry.message?.content;
|
|
146
|
-
|
|
147
|
-
if (typeof rawContent === 'string') {
|
|
148
|
-
content = rawContent;
|
|
149
|
-
} else if (Array.isArray(rawContent)) {
|
|
150
|
-
// Claude Code uses array of content blocks: [{type: 'text', text: '...'}, ...]
|
|
151
|
-
content = rawContent
|
|
152
|
-
.filter(block => block.type === 'text' && block.text)
|
|
153
|
-
.map(block => block.text)
|
|
154
|
-
.join('\n');
|
|
155
|
-
} else if (entry.display || entry.text) {
|
|
156
|
-
// Fallback for older formats
|
|
157
|
-
content = entry.display || entry.text || '';
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const role = entry.message?.role || entry.type || 'assistant';
|
|
161
|
-
const created_at = new Date(entry.timestamp).getTime() || Date.now();
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
id: entry.message?.id || `claude-${created_at}`,
|
|
165
|
-
role,
|
|
166
|
-
content,
|
|
167
|
-
engine: 'claude',
|
|
168
|
-
created_at,
|
|
169
|
-
metadata: {
|
|
170
|
-
model: entry.message?.model,
|
|
171
|
-
stop_reason: entry.message?.stop_reason
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ============================================================
|
|
177
|
-
// CODEX - Load from ~/.codex/sessions/<sessionId>.jsonl
|
|
178
|
-
// ============================================================
|
|
179
|
-
|
|
180
|
-
async loadCodexMessages({ sessionId, nativeId, limit, before, mode }) {
|
|
181
|
-
const baseDir = path.join(this.codexPath, 'sessions');
|
|
182
|
-
let sessionFile = path.join(baseDir, `${nativeId || sessionId}.jsonl`);
|
|
183
|
-
|
|
184
|
-
// If flat file missing, search nested rollout-* files by threadId
|
|
185
|
-
if (!fs.existsSync(sessionFile)) {
|
|
186
|
-
sessionFile = this.findCodexSessionFile(baseDir, nativeId || sessionId);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Codex exec may not persist sessions; handle gracefully
|
|
190
|
-
if (!sessionFile || !fs.existsSync(sessionFile)) {
|
|
191
|
-
console.log(`[CliLoader] Codex session file not found (id=${nativeId || sessionId})`);
|
|
192
|
-
return this._emptyResult();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
196
|
-
|
|
197
|
-
// Normalize then filter only chat messages
|
|
198
|
-
const messages = rawMessages
|
|
199
|
-
.map(entry => this._normalizeCodexEntry(entry))
|
|
200
|
-
.filter(msg => msg && (msg.role === 'user' || msg.role === 'assistant'));
|
|
201
|
-
|
|
202
|
-
return this._paginateMessages(messages, limit, before, mode);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Normalize Codex session entry to message shape
|
|
207
|
-
*/
|
|
208
|
-
_normalizeCodexEntry(entry) {
|
|
209
|
-
// Skip non-chat bookkeeping events
|
|
210
|
-
const skipTypes = ['session_meta', 'turn_context', 'event_msg', 'token_count'];
|
|
211
|
-
if (skipTypes.includes(entry.type)) return null;
|
|
212
|
-
|
|
213
|
-
const role =
|
|
214
|
-
entry.role ||
|
|
215
|
-
entry.payload?.role ||
|
|
216
|
-
(entry.payload?.type === 'message' ? entry.payload.role : null) ||
|
|
217
|
-
entry.message?.role ||
|
|
218
|
-
'assistant';
|
|
219
|
-
|
|
220
|
-
const created_at = entry.timestamp
|
|
221
|
-
? new Date(entry.timestamp).getTime()
|
|
222
|
-
: (entry.payload?.timestamp ? new Date(entry.payload.timestamp).getTime() : Date.now());
|
|
223
|
-
|
|
224
|
-
// Codex may store content in multiple shapes
|
|
225
|
-
let content = '';
|
|
226
|
-
if (typeof entry.content === 'string') {
|
|
227
|
-
content = entry.content;
|
|
228
|
-
} else if (typeof entry.payload?.content === 'string') {
|
|
229
|
-
content = entry.payload.content;
|
|
230
|
-
} else if (Array.isArray(entry.payload?.content)) {
|
|
231
|
-
content = entry.payload.content
|
|
232
|
-
.map(block => block.text || block.message || block.title || '')
|
|
233
|
-
.filter(Boolean)
|
|
234
|
-
.join('\n');
|
|
235
|
-
} else if (entry.payload?.text) {
|
|
236
|
-
content = entry.payload.text;
|
|
237
|
-
} else if (entry.message) {
|
|
238
|
-
content = typeof entry.message === 'string' ? entry.message : JSON.stringify(entry.message);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
id: entry.id || `codex-${created_at}`,
|
|
243
|
-
role,
|
|
244
|
-
content,
|
|
245
|
-
engine: 'codex',
|
|
246
|
-
created_at,
|
|
247
|
-
metadata: {
|
|
248
|
-
model: entry.model,
|
|
249
|
-
reasoning_effort: entry.reasoning_effort
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Find Codex rollout file by threadId within YYYY/MM/DD directories
|
|
256
|
-
*/
|
|
257
|
-
findCodexSessionFile(baseDir, threadId) {
|
|
258
|
-
if (!threadId || !fs.existsSync(baseDir)) return null;
|
|
259
|
-
try {
|
|
260
|
-
const years = fs.readdirSync(baseDir);
|
|
261
|
-
for (const year of years) {
|
|
262
|
-
const yearPath = path.join(baseDir, year);
|
|
263
|
-
if (!fs.statSync(yearPath).isDirectory()) continue;
|
|
264
|
-
const months = fs.readdirSync(yearPath);
|
|
265
|
-
for (const month of months) {
|
|
266
|
-
const monthPath = path.join(yearPath, month);
|
|
267
|
-
if (!fs.statSync(monthPath).isDirectory()) continue;
|
|
268
|
-
const days = fs.readdirSync(monthPath);
|
|
269
|
-
for (const day of days) {
|
|
270
|
-
const dayPath = path.join(monthPath, day);
|
|
271
|
-
if (!fs.statSync(dayPath).isDirectory()) continue;
|
|
272
|
-
const files = fs.readdirSync(dayPath);
|
|
273
|
-
for (const file of files) {
|
|
274
|
-
if (file.endsWith('.jsonl') && file.includes(threadId)) {
|
|
275
|
-
return path.join(dayPath, file);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
} catch (err) {
|
|
282
|
-
console.warn(`[CliLoader] Failed to search Codex session file: ${err.message}`);
|
|
283
|
-
}
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ============================================================
|
|
288
|
-
// GEMINI - Load from ~/.gemini/sessions/<sessionId>.jsonl
|
|
289
|
-
// ============================================================
|
|
290
|
-
|
|
291
|
-
async loadGeminiMessages({ sessionId, limit, before, mode }) {
|
|
292
|
-
const sessionFile = path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
|
|
293
|
-
|
|
294
|
-
// Gemini CLI may not save sessions - check if file exists
|
|
295
|
-
if (!fs.existsSync(sessionFile)) {
|
|
296
|
-
console.log(`[CliLoader] Gemini session file not found: ${sessionFile}`);
|
|
297
|
-
return this._emptyResult();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const rawMessages = await this._parseJsonlFile(sessionFile);
|
|
301
|
-
|
|
302
|
-
// Filter and normalize
|
|
303
|
-
const messages = rawMessages
|
|
304
|
-
.filter(entry => entry.role === 'user' || entry.role === 'model' || entry.role === 'assistant')
|
|
305
|
-
.map(entry => this._normalizeGeminiEntry(entry));
|
|
306
|
-
|
|
307
|
-
return this._paginateMessages(messages, limit, before, mode);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Normalize Gemini session entry to message shape
|
|
312
|
-
*/
|
|
313
|
-
_normalizeGeminiEntry(entry) {
|
|
314
|
-
// Gemini uses 'model' instead of 'assistant'
|
|
315
|
-
const role = entry.role === 'model' ? 'assistant' : (entry.role || 'assistant');
|
|
316
|
-
const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
|
|
317
|
-
|
|
318
|
-
// Gemini content format
|
|
319
|
-
let content = '';
|
|
320
|
-
if (typeof entry.content === 'string') {
|
|
321
|
-
content = entry.content;
|
|
322
|
-
} else if (Array.isArray(entry.parts)) {
|
|
323
|
-
// Gemini uses parts array: [{text: '...'}]
|
|
324
|
-
content = entry.parts
|
|
325
|
-
.filter(p => p.text)
|
|
326
|
-
.map(p => p.text)
|
|
327
|
-
.join('\n');
|
|
328
|
-
} else if (entry.text) {
|
|
329
|
-
content = entry.text;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return {
|
|
333
|
-
id: entry.id || `gemini-${created_at}`,
|
|
334
|
-
role,
|
|
335
|
-
content,
|
|
336
|
-
engine: 'gemini',
|
|
337
|
-
created_at,
|
|
338
|
-
metadata: {
|
|
339
|
-
model: entry.model
|
|
340
|
-
}
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// ============================================================
|
|
345
|
-
// UTILITY METHODS
|
|
346
|
-
// ============================================================
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Parse JSONL file line by line (memory efficient)
|
|
350
|
-
*/
|
|
351
|
-
async _parseJsonlFile(filePath) {
|
|
352
|
-
const entries = [];
|
|
353
|
-
|
|
354
|
-
const fileStream = fs.createReadStream(filePath);
|
|
355
|
-
const rl = readline.createInterface({
|
|
356
|
-
input: fileStream,
|
|
357
|
-
crlfDelay: Infinity
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
for await (const line of rl) {
|
|
361
|
-
if (!line.trim()) continue;
|
|
362
|
-
|
|
363
|
-
try {
|
|
364
|
-
const entry = JSON.parse(line);
|
|
365
|
-
entries.push(entry);
|
|
366
|
-
} catch (e) {
|
|
367
|
-
// Skip malformed lines
|
|
368
|
-
console.warn(`[CliLoader] Skipping malformed JSON line in ${filePath}`);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return entries;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Apply pagination to messages array
|
|
377
|
-
*/
|
|
378
|
-
_paginateMessages(messages, limit, before, mode) {
|
|
379
|
-
// Filter by timestamp if 'before' cursor provided
|
|
380
|
-
let filtered = messages;
|
|
381
|
-
if (before) {
|
|
382
|
-
filtered = messages.filter(m => m.created_at < Number(before));
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Sort newest first for pagination slicing
|
|
386
|
-
filtered.sort((a, b) => b.created_at - a.created_at);
|
|
387
|
-
|
|
388
|
-
// Apply limit
|
|
389
|
-
const page = filtered.slice(0, limit);
|
|
390
|
-
const hasMore = filtered.length > limit;
|
|
391
|
-
const oldestTimestamp = page.length ? page[page.length - 1].created_at : null;
|
|
392
|
-
|
|
393
|
-
// Return in requested order (default asc for UI rendering)
|
|
394
|
-
const ordered = mode === 'desc'
|
|
395
|
-
? page
|
|
396
|
-
: [...page].sort((a, b) => a.created_at - b.created_at);
|
|
397
|
-
|
|
398
|
-
return {
|
|
399
|
-
messages: ordered,
|
|
400
|
-
pagination: {
|
|
401
|
-
hasMore,
|
|
402
|
-
oldestTimestamp,
|
|
403
|
-
total: messages.length
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Return empty result structure
|
|
410
|
-
*/
|
|
411
|
-
_emptyResult() {
|
|
412
|
-
return {
|
|
413
|
-
messages: [],
|
|
414
|
-
pagination: {
|
|
415
|
-
hasMore: false,
|
|
416
|
-
oldestTimestamp: null,
|
|
417
|
-
total: 0
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Get session file path for an engine
|
|
424
|
-
* Useful for external checks
|
|
425
|
-
*/
|
|
426
|
-
getSessionFilePath(sessionId, engine, workspacePath) {
|
|
427
|
-
const normalizedEngine = this._normalizeEngine(engine);
|
|
428
|
-
|
|
429
|
-
switch (normalizedEngine) {
|
|
430
|
-
case 'claude':
|
|
431
|
-
const slug = this.pathToSlug(workspacePath);
|
|
432
|
-
return path.join(this.claudePath, 'projects', slug, `${sessionId}.jsonl`);
|
|
433
|
-
|
|
434
|
-
case 'codex':
|
|
435
|
-
return path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
|
|
436
|
-
|
|
437
|
-
case 'gemini':
|
|
438
|
-
return path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
|
|
439
|
-
|
|
440
|
-
default:
|
|
441
|
-
return null;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
module.exports = CliLoader;
|
|
File without changes
|