@mmmbuto/nexuscli 0.7.7 → 0.7.8

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 (32) hide show
  1. package/README.md +20 -32
  2. package/bin/nexuscli.js +6 -6
  3. package/frontend/dist/assets/{index-CHOlrfA0.css → index-WfmfixF4.css} +1 -1
  4. package/frontend/dist/index.html +2 -2
  5. package/lib/server/.env.example +1 -1
  6. package/lib/server/lib/pty-adapter.js +1 -15
  7. package/lib/server/routes/codex.js +9 -2
  8. package/lib/server/routes/gemini.js +9 -3
  9. package/lib/server/routes/sessions.js +15 -0
  10. package/lib/server/server.js +9 -0
  11. package/lib/server/services/claude-wrapper.js +1 -11
  12. package/lib/server/services/codex-output-parser.js +0 -8
  13. package/lib/server/services/codex-wrapper.js +3 -3
  14. package/lib/server/services/context-bridge.js +143 -24
  15. package/lib/server/services/gemini-wrapper.js +4 -3
  16. package/lib/server/services/session-importer.js +155 -0
  17. package/lib/server/services/workspace-manager.js +2 -7
  18. package/lib/server/tests/performance.test.js +1 -1
  19. package/lib/server/tests/services.test.js +2 -2
  20. package/package.json +1 -1
  21. package/lib/server/db.js.old +0 -225
  22. package/lib/server/docs/API_WRAPPER_CONTRACT.md +0 -682
  23. package/lib/server/docs/ARCHITECTURE.md +0 -441
  24. package/lib/server/docs/DATABASE_SCHEMA.md +0 -783
  25. package/lib/server/docs/DESIGN_PRINCIPLES.md +0 -598
  26. package/lib/server/docs/NEXUSCHAT_ANALYSIS.md +0 -488
  27. package/lib/server/docs/PIPELINE_INTEGRATION.md +0 -636
  28. package/lib/server/docs/README.md +0 -272
  29. package/lib/server/docs/UI_DESIGN.md +0 -916
  30. package/lib/server/services/base-cli-wrapper.js +0 -137
  31. package/lib/server/services/cli-loader.js.backup +0 -446
  32. /package/frontend/dist/assets/{index-BAY_sRAu.js → index-BbBoc8w4.js} +0 -0
@@ -263,13 +263,6 @@ 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
-
273
266
  /**
274
267
  * Reset parser state for new request
275
268
  */
@@ -277,7 +270,6 @@ class CodexOutputParser {
277
270
  this.buffer = '';
278
271
  this.finalResponse = '';
279
272
  this.usage = null;
280
- this.threadId = null;
281
273
  this.pendingCommands.clear();
282
274
  }
283
275
  }
@@ -35,7 +35,7 @@ class CodexWrapper extends BaseCliWrapper {
35
35
  * @param {Function} options.onStatus - Callback for status events
36
36
  * @returns {Promise<Object>} Response with text, usage, threadId
37
37
  */
38
- async sendMessage({ prompt, model, threadId, reasoningEffort, workspacePath, imageFiles = [], onStatus }) {
38
+ async sendMessage({ prompt, model, threadId, reasoningEffort, workspacePath, imageFiles = [], onStatus, processId: processIdOverride }) {
39
39
  return new Promise((resolve, reject) => {
40
40
  const parser = new CodexOutputParser();
41
41
  const cwd = workspacePath || this.workspaceDir;
@@ -93,8 +93,8 @@ class CodexWrapper extends BaseCliWrapper {
93
93
  });
94
94
 
95
95
  // Register process for interrupt capability
96
- // Use threadId if available, otherwise generate temp ID
97
- const processId = threadId || `codex-${Date.now()}`;
96
+ // Prefer external processId (Nexus sessionId), else threadId, else temp
97
+ const processId = processIdOverride || threadId || `codex-${Date.now()}`;
98
98
  this.registerProcess(processId, proc, 'spawn');
99
99
 
100
100
  proc.stdout.on('data', (data) => {
@@ -67,47 +67,66 @@ class ContextBridge {
67
67
 
68
68
  // Reserve tokens for user message
69
69
  const userTokens = this.estimateTokens(userMessage);
70
- const availableTokens = config.maxTokens - userTokens - 200; // 200 token buffer
70
+ const availableTokens = Math.max(0, config.maxTokens - userTokens - 200); // 200 token buffer, never negative
71
71
 
72
72
  let contextText = '';
73
73
  let contextTokens = 0;
74
74
  let contextSource = 'none';
75
75
 
76
- // Try summary first (most efficient)
77
- if (config.preferSummary) {
78
- const summaryContext = this.summaryGenerator.getBridgeContext(convoId);
79
- if (summaryContext) {
80
- const summaryTokens = this.estimateTokens(summaryContext);
81
- if (summaryTokens <= availableTokens) {
82
- contextText = summaryContext;
83
- contextTokens = summaryTokens;
84
- contextSource = 'summary';
76
+ // For engine bridge, use structured handoff template
77
+ if (isEngineBridge) {
78
+ const handoffContext = this.buildEngineHandoffContext(convoId, fromEngine, toEngine, availableTokens, config);
79
+ if (handoffContext.text) {
80
+ contextText = handoffContext.text;
81
+ contextTokens = handoffContext.tokens;
82
+ contextSource = handoffContext.source;
83
+ }
84
+ // If handoff couldn't fit, fall back to history-only context
85
+ if (!contextText && availableTokens > 200) {
86
+ const historyContext = this.buildTokenAwareHistory(convoId, availableTokens, config);
87
+ if (historyContext.text) {
88
+ contextText = historyContext.text;
89
+ contextTokens = historyContext.tokens;
90
+ contextSource = 'history_fallback';
91
+ }
92
+ }
93
+ } else {
94
+ // Try summary first (most efficient)
95
+ if (config.preferSummary) {
96
+ const summaryContext = this.summaryGenerator.getBridgeContext(convoId);
97
+ if (summaryContext) {
98
+ const summaryTokens = this.estimateTokens(summaryContext);
99
+ if (summaryTokens <= availableTokens) {
100
+ contextText = summaryContext;
101
+ contextTokens = summaryTokens;
102
+ contextSource = 'summary';
103
+ }
85
104
  }
86
105
  }
87
- }
88
106
 
89
- // Fallback to token-aware message history
90
- if (!contextText && availableTokens > 200) {
91
- const historyContext = this.buildTokenAwareHistory(convoId, availableTokens, config);
92
- if (historyContext.text) {
93
- contextText = historyContext.text;
94
- contextTokens = historyContext.tokens;
95
- contextSource = 'history';
107
+ // Fallback to token-aware message history
108
+ if (!contextText && availableTokens > 200) {
109
+ const historyContext = this.buildTokenAwareHistory(convoId, availableTokens, config);
110
+ if (historyContext.text) {
111
+ contextText = historyContext.text;
112
+ contextTokens = historyContext.tokens;
113
+ contextSource = 'history';
114
+ }
96
115
  }
97
116
  }
98
117
 
99
118
  // Build final prompt
100
119
  let prompt = userMessage;
101
120
 
121
+ if (availableTokens === 0) {
122
+ console.log(`[ContextBridge] Budget exhausted before context; sending raw message (engine=${toEngine})`);
123
+ }
124
+
102
125
  if (contextText) {
103
- if (isEngineBridge) {
104
- prompt = `${contextText}\n\n[Switching from ${fromEngine} to ${toEngine}]\n\nUser message:\n${userMessage}`;
105
- } else {
106
- prompt = `${contextText}\n\nUser message:\n${userMessage}`;
107
- }
126
+ prompt = `${contextText}\n\n${userMessage}`;
108
127
  }
109
128
 
110
- console.log(`[ContextBridge] Built context: ${contextTokens} tokens from ${contextSource}, bridge: ${isEngineBridge}`);
129
+ console.log(`[ContextBridge] Built context: ${contextTokens} tokens from ${contextSource}, bridge: ${isEngineBridge}, avail=${availableTokens}, total=${contextTokens + userTokens}`);
111
130
 
112
131
  return {
113
132
  prompt,
@@ -118,6 +137,106 @@ class ContextBridge {
118
137
  };
119
138
  }
120
139
 
140
+ /**
141
+ * Build structured context for engine handoff
142
+ * Uses a clear template that helps the new engine understand previous context
143
+ * @param {string} conversationId
144
+ * @param {string} fromEngine
145
+ * @param {string} toEngine
146
+ * @param {number} maxTokens
147
+ * @param {Object} config
148
+ * @returns {Object} { text, tokens, source }
149
+ */
150
+ buildEngineHandoffContext(conversationId, fromEngine, toEngine, maxTokens, config = {}) {
151
+ const engineNames = {
152
+ 'claude': 'Claude Code (Anthropic)',
153
+ 'codex': 'Codex (OpenAI)',
154
+ 'gemini': 'Gemini (Google)',
155
+ 'deepseek': 'DeepSeek'
156
+ };
157
+
158
+ const fromName = engineNames[fromEngine] || fromEngine;
159
+ const toName = engineNames[toEngine] || toEngine;
160
+
161
+ // Get summary if available
162
+ const summary = this.summaryGenerator.getSummary(conversationId);
163
+
164
+ // Get recent messages (last 5)
165
+ const messages = Message.getContextMessages(conversationId, 5);
166
+ const messageCount = Message.countByConversation(conversationId);
167
+
168
+ // Build structured template
169
+ const sections = [];
170
+
171
+ // Header
172
+ sections.push(`<previous_session_context engine="${fromEngine}" total_messages="${messageCount}">`);
173
+ sections.push(`This conversation was previously handled by ${fromName}.`);
174
+ sections.push(`You are now continuing as ${toName}.`);
175
+ sections.push('');
176
+
177
+ // Summary section (if available)
178
+ if (summary && summary.summary_short) {
179
+ sections.push('## Summary');
180
+ sections.push(summary.summary_short);
181
+ sections.push('');
182
+
183
+ // Key decisions
184
+ if (summary.key_decisions && summary.key_decisions.length > 0) {
185
+ sections.push('## Key Decisions');
186
+ summary.key_decisions.slice(0, 5).forEach(d => sections.push(`- ${d}`));
187
+ sections.push('');
188
+ }
189
+
190
+ // Files modified
191
+ if (summary.files_modified && summary.files_modified.length > 0) {
192
+ sections.push('## Files Modified');
193
+ summary.files_modified.slice(0, 10).forEach(f => sections.push(`- ${f}`));
194
+ sections.push('');
195
+ }
196
+ }
197
+
198
+ // Recent messages (always include for continuity)
199
+ if (messages.length > 0) {
200
+ sections.push('## Recent Messages');
201
+ for (const msg of messages) {
202
+ const role = msg.role === 'user' ? 'User' : 'Assistant';
203
+ const engine = msg.engine ? ` [${msg.engine}]` : '';
204
+ // Truncate long messages
205
+ let content = msg.content || '';
206
+ if (content.length > 500) {
207
+ content = content.substring(0, 500) + '...';
208
+ }
209
+ sections.push(`${role}${engine}: ${content}`);
210
+ sections.push('');
211
+ }
212
+ }
213
+
214
+ sections.push('</previous_session_context>');
215
+ sections.push('');
216
+ sections.push('Continue assisting with the following request:');
217
+
218
+ const text = sections.join('\n');
219
+ const tokens = this.estimateTokens(text);
220
+
221
+ // Check token budget
222
+ if (tokens > maxTokens) {
223
+ // Fallback to simpler context if too long
224
+ console.log(`[ContextBridge] Handoff template too long (${tokens} > ${maxTokens}), using fallback`);
225
+ const fallback = this.buildTokenAwareHistory(conversationId, maxTokens, config);
226
+ return {
227
+ text: fallback.text,
228
+ tokens: fallback.tokens,
229
+ source: 'handoff_fallback_history'
230
+ };
231
+ }
232
+
233
+ return {
234
+ text,
235
+ tokens,
236
+ source: summary ? 'handoff+summary' : 'handoff+history'
237
+ };
238
+ }
239
+
121
240
  /**
122
241
  * Build token-aware history context
123
242
  * @param {string} sessionId - Session ID
@@ -80,7 +80,8 @@ class GeminiWrapper extends BaseCliWrapper {
80
80
  threadId,
81
81
  model = DEFAULT_MODEL,
82
82
  workspacePath,
83
- onStatus
83
+ onStatus,
84
+ processId: processIdOverride
84
85
  }) {
85
86
  return new Promise((resolve, reject) => {
86
87
  const parser = new GeminiOutputParser();
@@ -127,8 +128,8 @@ class GeminiWrapper extends BaseCliWrapper {
127
128
  }
128
129
 
129
130
  // Register process for interrupt capability
130
- // Use threadId if available, otherwise generate temp ID
131
- const processId = threadId || `gemini-${Date.now()}`;
131
+ // Prefer external processId (Nexus sessionId), else threadId, else temp
132
+ const processId = processIdOverride || threadId || `gemini-${Date.now()}`;
132
133
  this.registerProcess(processId, ptyProcess, 'pty');
133
134
 
134
135
  let stdout = '';
@@ -0,0 +1,155 @@
1
+ /**
2
+ * SessionImporter - Importa sessioni CLI native in tabella sessions (indice NexusCLI)
3
+ *
4
+ * Scansione:
5
+ * - Claude: ~/.claude/projects/<slug>/<sessionId>.jsonl
6
+ * - Codex : ~/.codex/sessions/<sessionId>.jsonl
7
+ * - Gemini: ~/.gemini/sessions/<sessionId>.jsonl
8
+ *
9
+ * Note:
10
+ * - Usa FILESYSTEM come source of truth: non legge contenuti, solo metadati.
11
+ * - workspace_path stimato da slug (best effort, non reversibile al 100%).
12
+ * - Non sovrascrive entry esistenti in DB.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { prepare, saveDb } = require('../db');
18
+
19
+ const HOME = process.env.HOME || '';
20
+
21
+ const CLAUDE_PROJECTS = path.join(HOME, '.claude', 'projects');
22
+ const CODEX_SESSIONS = path.join(HOME, '.codex', 'sessions');
23
+ const GEMINI_SESSIONS = path.join(HOME, '.gemini', 'sessions');
24
+
25
+ class SessionImporter {
26
+ constructor() {}
27
+
28
+ /**
29
+ * Importa tutte le sessioni per tutti gli engine
30
+ * @returns {{claude:number, codex:number, gemini:number}}
31
+ */
32
+ importAll() {
33
+ const claude = this.importClaudeSessions();
34
+ const codex = this.importCodexSessions();
35
+ const gemini = this.importGeminiSessions();
36
+ return { claude, codex, gemini };
37
+ }
38
+
39
+ /**
40
+ * Claude: ~/.claude/projects/<slug>/<sessionId>.jsonl
41
+ */
42
+ importClaudeSessions() {
43
+ let imported = 0;
44
+ if (!fs.existsSync(CLAUDE_PROJECTS)) return imported;
45
+
46
+ const projectSlugs = fs.readdirSync(CLAUDE_PROJECTS);
47
+ for (const slug of projectSlugs) {
48
+ const projectDir = path.join(CLAUDE_PROJECTS, slug);
49
+ if (!fs.statSync(projectDir).isDirectory()) continue;
50
+
51
+ const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
52
+ for (const file of files) {
53
+ const sessionId = file.replace('.jsonl', '');
54
+ if (this.sessionExists(sessionId)) continue;
55
+
56
+ const workspacePath = this.slugToPath(slug);
57
+ this.insertSession(sessionId, 'claude', workspacePath, null);
58
+ imported++;
59
+ }
60
+ }
61
+
62
+ if (imported > 0) saveDb();
63
+ console.log(`[SessionImporter] Claude imported: ${imported}`);
64
+ return imported;
65
+ }
66
+
67
+ /**
68
+ * Codex: ~/.codex/sessions/<sessionId>.jsonl
69
+ */
70
+ importCodexSessions() {
71
+ let imported = 0;
72
+ if (!fs.existsSync(CODEX_SESSIONS)) return imported;
73
+
74
+ const files = fs.readdirSync(CODEX_SESSIONS)
75
+ .filter(f => f.endsWith('.jsonl') || f.endsWith('.json'));
76
+ for (const file of files) {
77
+ const sessionId = file.replace('.jsonl', '');
78
+ if (this.sessionExists(sessionId)) continue;
79
+
80
+ this.insertSession(sessionId, 'codex', '', null);
81
+ imported++;
82
+ }
83
+
84
+ if (imported > 0) saveDb();
85
+ console.log(`[SessionImporter] Codex imported: ${imported}`);
86
+ return imported;
87
+ }
88
+
89
+ /**
90
+ * Gemini: ~/.gemini/sessions/<sessionId>.jsonl
91
+ */
92
+ importGeminiSessions() {
93
+ let imported = 0;
94
+ if (!fs.existsSync(GEMINI_SESSIONS)) return imported;
95
+
96
+ const files = fs.readdirSync(GEMINI_SESSIONS)
97
+ .filter(f => f.endsWith('.jsonl') || f.endsWith('.json'));
98
+ for (const file of files) {
99
+ const sessionId = file.replace('.jsonl', '');
100
+ if (this.sessionExists(sessionId)) continue;
101
+
102
+ this.insertSession(sessionId, 'gemini', '', null);
103
+ imported++;
104
+ }
105
+
106
+ if (imported > 0) saveDb();
107
+ console.log(`[SessionImporter] Gemini imported: ${imported}`);
108
+ return imported;
109
+ }
110
+
111
+ /**
112
+ * Inserisce riga minima in sessions
113
+ */
114
+ insertSession(id, engine, workspacePath, conversationId) {
115
+ try {
116
+ const now = Date.now();
117
+ const stmt = prepare(`
118
+ INSERT INTO sessions (id, engine, workspace_path, conversation_id, title, created_at, last_used_at)
119
+ VALUES (?, ?, ?, ?, ?, ?, ?)
120
+ `);
121
+ stmt.run(id, engine, workspacePath || '', conversationId || null, 'Imported Chat', now, now);
122
+ } catch (err) {
123
+ console.warn(`[SessionImporter] insert failed for ${id}: ${err.message}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Controlla se la sessione esiste già
129
+ */
130
+ sessionExists(sessionId) {
131
+ try {
132
+ const stmt = prepare('SELECT 1 FROM sessions WHERE id = ?');
133
+ return !!stmt.get(sessionId);
134
+ } catch (err) {
135
+ console.warn(`[SessionImporter] exists check failed: ${err.message}`);
136
+ return true; // default to skip to avoid duplicates
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Best-effort reverse di slug Claude in path
142
+ * -path-to-dir → /path/to/dir
143
+ * Conserva i punti.
144
+ */
145
+ slugToPath(slug) {
146
+ if (!slug) return '';
147
+ // Claude slug era workspacePath.replace(/\//g, '-'); mantiene i punti
148
+ // Invertiamo: leading '-' → '/', i restanti '-' → '/'
149
+ let tmp = slug;
150
+ if (tmp.startsWith('-')) tmp = '/' + tmp.slice(1);
151
+ return tmp.replace(/-/g, '/');
152
+ }
153
+ }
154
+
155
+ module.exports = new SessionImporter();
@@ -328,13 +328,8 @@ class WorkspaceManager {
328
328
  // Sort by most recent first
329
329
  sessions.sort((a, b) => b.last_used_at - a.last_used_at);
330
330
 
331
- // Limit to most recent 10 sessions (lazy load the rest)
332
- const limit = 10;
333
- const totalSessions = sessions.length;
334
- const limitedSessions = sessions.slice(0, limit);
335
-
336
- console.log(`[WorkspaceManager] Loaded ${limitedSessions.length} of ${totalSessions} sessions (sorted by recency)`);
337
- return limitedSessions;
331
+ console.log(`[WorkspaceManager] Loaded ${sessions.length} sessions (sorted by recency)`);
332
+ return sessions;
338
333
  }
339
334
 
340
335
  /**
@@ -45,7 +45,7 @@ describe('Performance Benchmarks', () => {
45
45
 
46
46
  test('Workspace validation should be fast', async () => {
47
47
  const manager = new WorkspaceManager();
48
- const testPath = '/var/www/myapp';
48
+ const testPath = '/home/user/myproject';
49
49
 
50
50
  const start = Date.now();
51
51
  const validated = await manager.validateWorkspace(testPath);
@@ -16,7 +16,7 @@ describe('WorkspaceManager', () => {
16
16
 
17
17
  test('should validate workspace path', async () => {
18
18
  // Test with allowed path
19
- const validPath = '/var/www/myapp';
19
+ const validPath = '/home/user/myproject';
20
20
  const result = await manager.validateWorkspace(validPath);
21
21
  expect(result).toBe(validPath);
22
22
  });
@@ -147,7 +147,7 @@ describe('SummaryGenerator', () => {
147
147
  describe('Integration - Service Interactions', () => {
148
148
  test('WorkspaceManager should use consistent path resolution', async () => {
149
149
  const manager = new WorkspaceManager();
150
- const testPath = '/var/www/myapp';
150
+ const testPath = '/home/user/myproject';
151
151
  const validated = await manager.validateWorkspace(testPath);
152
152
  expect(validated).toBe(testPath);
153
153
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscli",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "NexusCLI - TRI CLI Control Plane (Claude/Codex/Gemini)",
5
5
  "main": "lib/server/server.js",
6
6
  "bin": {