@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.
Files changed (33) hide show
  1. package/README.md +12 -4
  2. package/bin/nexuscli.js +6 -6
  3. package/frontend/dist/assets/{index-CikJbUR5.js → index-BAY_sRAu.js} +1704 -1704
  4. package/frontend/dist/assets/{index-Bn_l1e6e.css → index-CHOlrfA0.css} +1 -1
  5. package/frontend/dist/index.html +2 -2
  6. package/lib/server/.env.example +1 -1
  7. package/lib/server/db.js.old +225 -0
  8. package/lib/server/docs/API_WRAPPER_CONTRACT.md +682 -0
  9. package/lib/server/docs/ARCHITECTURE.md +441 -0
  10. package/lib/server/docs/DATABASE_SCHEMA.md +783 -0
  11. package/lib/server/docs/DESIGN_PRINCIPLES.md +598 -0
  12. package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +488 -0
  13. package/lib/server/docs/PIPELINE_INTEGRATION.md +636 -0
  14. package/lib/server/docs/README.md +272 -0
  15. package/lib/server/docs/UI_DESIGN.md +916 -0
  16. package/lib/server/lib/pty-adapter.js +15 -1
  17. package/lib/server/routes/chat.js +70 -8
  18. package/lib/server/routes/codex.js +61 -7
  19. package/lib/server/routes/gemini.js +66 -12
  20. package/lib/server/routes/sessions.js +7 -2
  21. package/lib/server/server.js +2 -0
  22. package/lib/server/services/base-cli-wrapper.js +137 -0
  23. package/lib/server/services/claude-wrapper.js +11 -1
  24. package/lib/server/services/cli-loader.js.backup +446 -0
  25. package/lib/server/services/codex-output-parser.js +8 -0
  26. package/lib/server/services/codex-wrapper.js +13 -4
  27. package/lib/server/services/context-bridge.js +24 -20
  28. package/lib/server/services/gemini-wrapper.js +26 -8
  29. package/lib/server/services/session-manager.js +20 -0
  30. package/lib/server/services/workspace-manager.js +1 -1
  31. package/lib/server/tests/performance.test.js +1 -1
  32. package/lib/server/tests/services.test.js +2 -2
  33. 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.sessionId - Session ID
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(sessionId);
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(sessionId, availableTokens, config);
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(sessionId, maxTokens, config = {}) {
128
+ buildTokenAwareHistory(conversationId, maxTokens, config = {}) {
127
129
  // Get more messages than we need, we'll trim
128
- const messages = Message.getContextMessages(sessionId, 20);
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, filter out non-code content
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
- content = this.extractCodeContent(content);
146
- if (!content) continue; // Skip if no code
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(sessionId, isEngineBridge = false) {
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(sessionId);
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(sessionId);
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(sessionId, logPrefix = '[ContextBridge]') {
257
- const messages = Message.getByConversation(sessionId, 40);
260
+ triggerSummaryGeneration(conversationId, logPrefix = '[ContextBridge]') {
261
+ const messages = Message.getByConversation(conversationId, 40);
258
262
 
259
- this.summaryGenerator.generateAndSave(sessionId, messages)
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(sessionId) {
276
- const messageCount = Message.countByConversation(sessionId);
277
- const lastEngine = Message.getLastEngine(sessionId);
278
- const hasSummary = !!this.summaryGenerator.getSummary(sessionId);
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,