@mmmbuto/nexuscli 0.7.6 → 0.7.7
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 +12 -4
- package/bin/nexuscli.js +6 -6
- package/frontend/dist/assets/{index-CikJbUR5.js → index-BAY_sRAu.js} +1704 -1704
- package/frontend/dist/assets/{index-Bn_l1e6e.css → index-CHOlrfA0.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/lib/server/.env.example +1 -1
- package/lib/server/db.js.old +225 -0
- package/lib/server/docs/API_WRAPPER_CONTRACT.md +682 -0
- package/lib/server/docs/ARCHITECTURE.md +441 -0
- package/lib/server/docs/DATABASE_SCHEMA.md +783 -0
- package/lib/server/docs/DESIGN_PRINCIPLES.md +598 -0
- package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +488 -0
- package/lib/server/docs/PIPELINE_INTEGRATION.md +636 -0
- package/lib/server/docs/README.md +272 -0
- package/lib/server/docs/UI_DESIGN.md +916 -0
- package/lib/server/lib/pty-adapter.js +15 -1
- package/lib/server/routes/chat.js +70 -8
- package/lib/server/routes/codex.js +61 -7
- package/lib/server/routes/gemini.js +66 -12
- package/lib/server/routes/sessions.js +7 -2
- package/lib/server/server.js +2 -0
- package/lib/server/services/base-cli-wrapper.js +137 -0
- package/lib/server/services/claude-wrapper.js +11 -1
- package/lib/server/services/cli-loader.js.backup +446 -0
- package/lib/server/services/codex-output-parser.js +8 -0
- package/lib/server/services/codex-wrapper.js +13 -4
- package/lib/server/services/context-bridge.js +24 -20
- package/lib/server/services/gemini-wrapper.js +26 -8
- package/lib/server/services/session-manager.js +20 -0
- package/lib/server/services/workspace-manager.js +1 -1
- package/lib/server/tests/performance.test.js +1 -1
- package/lib/server/tests/services.test.js +2 -2
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const pty = require('../lib/pty-adapter');
|
|
4
4
|
const OutputParser = require('./output-parser');
|
|
5
|
+
const BaseCliWrapper = require('./base-cli-wrapper');
|
|
5
6
|
const { getApiKey } = require('../db');
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -18,9 +19,12 @@ const { getApiKey } = require('../db');
|
|
|
18
19
|
* - Parses stdout for tool use, thinking, file ops
|
|
19
20
|
* - Emits status events → SSE stream
|
|
20
21
|
* - Returns final response text → saved in DB
|
|
22
|
+
*
|
|
23
|
+
* @version 0.5.0 - Extended BaseCliWrapper for interrupt support
|
|
21
24
|
*/
|
|
22
|
-
class ClaudeWrapper {
|
|
25
|
+
class ClaudeWrapper extends BaseCliWrapper {
|
|
23
26
|
constructor(options = {}) {
|
|
27
|
+
super(); // Initialize activeProcesses from BaseCliWrapper
|
|
24
28
|
this.claudePath = this.resolveClaudePath(options.claudePath);
|
|
25
29
|
this.workspaceDir = options.workspaceDir || process.env.NEXUSCLI_WORKSPACE || process.cwd();
|
|
26
30
|
}
|
|
@@ -200,6 +204,9 @@ class ClaudeWrapper {
|
|
|
200
204
|
return reject(new Error(msg));
|
|
201
205
|
}
|
|
202
206
|
|
|
207
|
+
// Register process for interrupt capability
|
|
208
|
+
this.registerProcess(conversationId, ptyProcess, 'pty');
|
|
209
|
+
|
|
203
210
|
let stdout = '';
|
|
204
211
|
|
|
205
212
|
// Process output chunks
|
|
@@ -255,6 +262,9 @@ class ClaudeWrapper {
|
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
ptyProcess.onExit(({ exitCode }) => {
|
|
265
|
+
// Unregister process on exit
|
|
266
|
+
this.unregisterProcess(conversationId);
|
|
267
|
+
|
|
258
268
|
console.log(`[ClaudeWrapper] Exit code: ${exitCode}`);
|
|
259
269
|
|
|
260
270
|
if (exitCode !== 0) {
|
|
@@ -0,0 +1,446 @@
|
|
|
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;
|
|
@@ -263,6 +263,13 @@ class CodexOutputParser {
|
|
|
263
263
|
return this.usage;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Get thread ID (native Codex session ID)
|
|
268
|
+
*/
|
|
269
|
+
getThreadId() {
|
|
270
|
+
return this.threadId;
|
|
271
|
+
}
|
|
272
|
+
|
|
266
273
|
/**
|
|
267
274
|
* Reset parser state for new request
|
|
268
275
|
*/
|
|
@@ -270,6 +277,7 @@ class CodexOutputParser {
|
|
|
270
277
|
this.buffer = '';
|
|
271
278
|
this.finalResponse = '';
|
|
272
279
|
this.usage = null;
|
|
280
|
+
this.threadId = null;
|
|
273
281
|
this.pendingCommands.clear();
|
|
274
282
|
}
|
|
275
283
|
}
|
|
@@ -4,19 +4,20 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Based on NexusChat codex-cli-wrapper.js pattern
|
|
6
6
|
* Requires: codex-cli 0.62.1+ with exec subcommand
|
|
7
|
+
*
|
|
8
|
+
* @version 0.5.0 - Extended BaseCliWrapper for interrupt support
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
const { spawn, exec } = require('child_process');
|
|
10
12
|
const CodexOutputParser = require('./codex-output-parser');
|
|
13
|
+
const BaseCliWrapper = require('./base-cli-wrapper');
|
|
11
14
|
|
|
12
|
-
class CodexWrapper {
|
|
15
|
+
class CodexWrapper extends BaseCliWrapper {
|
|
13
16
|
constructor(options = {}) {
|
|
17
|
+
super(); // Initialize activeProcesses from BaseCliWrapper
|
|
14
18
|
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
15
19
|
this.codexBin = options.codexBin || 'codex';
|
|
16
20
|
|
|
17
|
-
// Track active sessions
|
|
18
|
-
this.activeSessions = new Set();
|
|
19
|
-
|
|
20
21
|
console.log('[CodexWrapper] Initialized');
|
|
21
22
|
console.log('[CodexWrapper] Workspace:', this.workspaceDir);
|
|
22
23
|
console.log('[CodexWrapper] Binary:', this.codexBin);
|
|
@@ -91,6 +92,11 @@ class CodexWrapper {
|
|
|
91
92
|
},
|
|
92
93
|
});
|
|
93
94
|
|
|
95
|
+
// Register process for interrupt capability
|
|
96
|
+
// Use threadId if available, otherwise generate temp ID
|
|
97
|
+
const processId = threadId || `codex-${Date.now()}`;
|
|
98
|
+
this.registerProcess(processId, proc, 'spawn');
|
|
99
|
+
|
|
94
100
|
proc.stdout.on('data', (data) => {
|
|
95
101
|
const str = data.toString();
|
|
96
102
|
stdout += str;
|
|
@@ -102,6 +108,9 @@ class CodexWrapper {
|
|
|
102
108
|
});
|
|
103
109
|
|
|
104
110
|
proc.on('close', (exitCode) => {
|
|
111
|
+
// Unregister process on exit
|
|
112
|
+
this.unregisterProcess(processId);
|
|
113
|
+
|
|
105
114
|
clearTimeout(timeout);
|
|
106
115
|
this.handleExit(exitCode, stdout, parser, prompt, resolve, reject);
|
|
107
116
|
});
|
|
@@ -53,15 +53,17 @@ class ContextBridge {
|
|
|
53
53
|
/**
|
|
54
54
|
* Build optimized context for engine switch
|
|
55
55
|
* @param {Object} params
|
|
56
|
-
* @param {string} params.
|
|
56
|
+
* @param {string} params.conversationId - Stable conversation ID (cross-engine)
|
|
57
|
+
* @param {string} params.sessionId - Legacy session ID (fallback)
|
|
57
58
|
* @param {string} params.fromEngine - Previous engine
|
|
58
59
|
* @param {string} params.toEngine - Target engine
|
|
59
60
|
* @param {string} params.userMessage - Current user message
|
|
60
61
|
* @returns {Object} { prompt, isEngineBridge, contextTokens }
|
|
61
62
|
*/
|
|
62
|
-
async buildContext({ sessionId, fromEngine, toEngine, userMessage }) {
|
|
63
|
+
async buildContext({ conversationId, sessionId, fromEngine, toEngine, userMessage }) {
|
|
63
64
|
const config = this.getEngineConfig(toEngine);
|
|
64
65
|
const isEngineBridge = fromEngine && fromEngine !== toEngine;
|
|
66
|
+
const convoId = conversationId || sessionId; // backward compat
|
|
65
67
|
|
|
66
68
|
// Reserve tokens for user message
|
|
67
69
|
const userTokens = this.estimateTokens(userMessage);
|
|
@@ -73,7 +75,7 @@ class ContextBridge {
|
|
|
73
75
|
|
|
74
76
|
// Try summary first (most efficient)
|
|
75
77
|
if (config.preferSummary) {
|
|
76
|
-
const summaryContext = this.summaryGenerator.getBridgeContext(
|
|
78
|
+
const summaryContext = this.summaryGenerator.getBridgeContext(convoId);
|
|
77
79
|
if (summaryContext) {
|
|
78
80
|
const summaryTokens = this.estimateTokens(summaryContext);
|
|
79
81
|
if (summaryTokens <= availableTokens) {
|
|
@@ -86,7 +88,7 @@ class ContextBridge {
|
|
|
86
88
|
|
|
87
89
|
// Fallback to token-aware message history
|
|
88
90
|
if (!contextText && availableTokens > 200) {
|
|
89
|
-
const historyContext = this.buildTokenAwareHistory(
|
|
91
|
+
const historyContext = this.buildTokenAwareHistory(convoId, availableTokens, config);
|
|
90
92
|
if (historyContext.text) {
|
|
91
93
|
contextText = historyContext.text;
|
|
92
94
|
contextTokens = historyContext.tokens;
|
|
@@ -123,9 +125,9 @@ class ContextBridge {
|
|
|
123
125
|
* @param {Object} config - Engine config
|
|
124
126
|
* @returns {Object} { text, tokens, messageCount }
|
|
125
127
|
*/
|
|
126
|
-
buildTokenAwareHistory(
|
|
128
|
+
buildTokenAwareHistory(conversationId, maxTokens, config = {}) {
|
|
127
129
|
// Get more messages than we need, we'll trim
|
|
128
|
-
const messages = Message.getContextMessages(
|
|
130
|
+
const messages = Message.getContextMessages(conversationId, 20);
|
|
129
131
|
|
|
130
132
|
if (messages.length === 0) {
|
|
131
133
|
return { text: '', tokens: 0, messageCount: 0 };
|
|
@@ -139,11 +141,13 @@ class ContextBridge {
|
|
|
139
141
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
140
142
|
const msg = messages[i];
|
|
141
143
|
|
|
142
|
-
// For code-focused engines,
|
|
144
|
+
// For code-focused engines, compress assistant responses to code only
|
|
145
|
+
// BUT always keep user messages for context continuity
|
|
143
146
|
let content = msg.content;
|
|
144
|
-
if (config.codeOnly) {
|
|
145
|
-
|
|
146
|
-
if
|
|
147
|
+
if (config.codeOnly && msg.role === 'assistant') {
|
|
148
|
+
const codeContent = this.extractCodeContent(content);
|
|
149
|
+
// Only use code-only if there's actual code, otherwise keep truncated original
|
|
150
|
+
content = codeContent || (content.length > 500 ? content.substring(0, 500) + '...' : content);
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
// Truncate long messages
|
|
@@ -228,11 +232,11 @@ class ContextBridge {
|
|
|
228
232
|
* @param {boolean} isEngineBridge - Was this an engine switch
|
|
229
233
|
* @returns {boolean} Should generate summary
|
|
230
234
|
*/
|
|
231
|
-
shouldTriggerSummary(
|
|
235
|
+
shouldTriggerSummary(conversationId, isEngineBridge = false) {
|
|
232
236
|
// Always trigger on engine bridge
|
|
233
237
|
if (isEngineBridge) return true;
|
|
234
238
|
|
|
235
|
-
const messageCount = Message.countByConversation(
|
|
239
|
+
const messageCount = Message.countByConversation(conversationId);
|
|
236
240
|
|
|
237
241
|
// Trigger every 10 messages after threshold
|
|
238
242
|
if (messageCount >= SUMMARY_TRIGGER_THRESHOLD && messageCount % 10 === 0) {
|
|
@@ -240,7 +244,7 @@ class ContextBridge {
|
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
// Check if we have a stale summary (older than 20 messages)
|
|
243
|
-
const existingSummary = this.summaryGenerator.getSummary(
|
|
247
|
+
const existingSummary = this.summaryGenerator.getSummary(conversationId);
|
|
244
248
|
if (!existingSummary && messageCount > SUMMARY_TRIGGER_THRESHOLD) {
|
|
245
249
|
return true;
|
|
246
250
|
}
|
|
@@ -253,10 +257,10 @@ class ContextBridge {
|
|
|
253
257
|
* @param {string} sessionId - Session ID
|
|
254
258
|
* @param {string} logPrefix - Log prefix for debugging
|
|
255
259
|
*/
|
|
256
|
-
triggerSummaryGeneration(
|
|
257
|
-
const messages = Message.getByConversation(
|
|
260
|
+
triggerSummaryGeneration(conversationId, logPrefix = '[ContextBridge]') {
|
|
261
|
+
const messages = Message.getByConversation(conversationId, 40);
|
|
258
262
|
|
|
259
|
-
this.summaryGenerator.generateAndSave(
|
|
263
|
+
this.summaryGenerator.generateAndSave(conversationId, messages)
|
|
260
264
|
.then(summary => {
|
|
261
265
|
if (summary) {
|
|
262
266
|
console.log(`${logPrefix} Summary updated: ${summary.summary_short?.substring(0, 50)}...`);
|
|
@@ -272,10 +276,10 @@ class ContextBridge {
|
|
|
272
276
|
* @param {string} sessionId - Session ID
|
|
273
277
|
* @returns {Object} Stats
|
|
274
278
|
*/
|
|
275
|
-
getContextStats(
|
|
276
|
-
const messageCount = Message.countByConversation(
|
|
277
|
-
const lastEngine = Message.getLastEngine(
|
|
278
|
-
const hasSummary = !!this.summaryGenerator.getSummary(
|
|
279
|
+
getContextStats(conversationId) {
|
|
280
|
+
const messageCount = Message.countByConversation(conversationId);
|
|
281
|
+
const lastEngine = Message.getLastEngine(conversationId);
|
|
282
|
+
const hasSummary = !!this.summaryGenerator.getSummary(conversationId);
|
|
279
283
|
|
|
280
284
|
return {
|
|
281
285
|
messageCount,
|