@mmmbuto/nexuscli 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,8 @@ class CliLoader {
47
47
  */
48
48
  async loadMessagesFromCLI({
49
49
  sessionId,
50
+ threadId, // optional native thread id (e.g., Codex exec thread)
51
+ sessionPath, // alias for compatibility
50
52
  engine = 'claude',
51
53
  workspacePath,
52
54
  limit = DEFAULT_LIMIT,
@@ -59,6 +61,7 @@ class CliLoader {
59
61
 
60
62
  const startedAt = Date.now();
61
63
  const normalizedEngine = this._normalizeEngine(engine);
64
+ const nativeId = threadId || sessionPath || sessionId;
62
65
 
63
66
  let result;
64
67
  switch (normalizedEngine) {
@@ -67,7 +70,7 @@ class CliLoader {
67
70
  break;
68
71
 
69
72
  case 'codex':
70
- result = await this.loadCodexMessages({ sessionId, limit, before, mode });
73
+ result = await this.loadCodexMessages({ sessionId, nativeId, limit, before, mode });
71
74
  break;
72
75
 
73
76
  case 'gemini':
@@ -174,22 +177,27 @@ class CliLoader {
174
177
  // CODEX - Load from ~/.codex/sessions/<sessionId>.jsonl
175
178
  // ============================================================
176
179
 
177
- async loadCodexMessages({ sessionId, limit, before, mode }) {
178
- const sessionFile = path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
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`);
179
183
 
180
- // Codex may not persist sessions locally - check if file exists
184
+ // If flat file missing, search nested rollout-* files by threadId
181
185
  if (!fs.existsSync(sessionFile)) {
182
- // This is expected - Codex exec mode doesn't save history locally
183
- console.log(`[CliLoader] Codex session file not found (expected): ${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})`);
184
192
  return this._emptyResult();
185
193
  }
186
194
 
187
195
  const rawMessages = await this._parseJsonlFile(sessionFile);
188
196
 
189
- // Filter and normalize
197
+ // Normalize then filter only chat messages
190
198
  const messages = rawMessages
191
- .filter(entry => entry.role === 'user' || entry.role === 'assistant')
192
- .map(entry => this._normalizeCodexEntry(entry));
199
+ .map(entry => this._normalizeCodexEntry(entry))
200
+ .filter(msg => msg && (msg.role === 'user' || msg.role === 'assistant'));
193
201
 
194
202
  return this._paginateMessages(messages, limit, before, mode);
195
203
  }
@@ -198,13 +206,34 @@ class CliLoader {
198
206
  * Normalize Codex session entry to message shape
199
207
  */
200
208
  _normalizeCodexEntry(entry) {
201
- const role = entry.role || 'assistant';
202
- const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
203
-
204
- // Codex may store content as string or object
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
205
225
  let content = '';
206
226
  if (typeof entry.content === 'string') {
207
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;
208
237
  } else if (entry.message) {
209
238
  content = typeof entry.message === 'string' ? entry.message : JSON.stringify(entry.message);
210
239
  }
@@ -222,6 +251,39 @@ class CliLoader {
222
251
  };
223
252
  }
224
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
+
225
287
  // ============================================================
226
288
  // GEMINI - Load from ~/.gemini/sessions/<sessionId>.jsonl
227
289
  // ============================================================
@@ -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
  }
@@ -139,13 +139,11 @@ class ContextBridge {
139
139
  for (let i = messages.length - 1; i >= 0; i--) {
140
140
  const msg = messages[i];
141
141
 
142
- // For code-focused engines, compress assistant responses to code only
143
- // BUT always keep user messages for context continuity
142
+ // For code-focused engines, filter out non-code content
144
143
  let content = msg.content;
145
- if (config.codeOnly && msg.role === 'assistant') {
146
- const codeContent = this.extractCodeContent(content);
147
- // Only use code-only if there's actual code, otherwise keep truncated original
148
- content = codeContent || (content.length > 500 ? content.substring(0, 500) + '...' : content);
144
+ if (config.codeOnly) {
145
+ content = this.extractCodeContent(content);
146
+ if (!content) continue; // Skip if no code
149
147
  }
150
148
 
151
149
  // Truncate long messages
@@ -67,15 +67,15 @@ class GeminiWrapper {
67
67
  *
68
68
  * @param {Object} params
69
69
  * @param {string} params.prompt - User message/prompt
70
- * @param {string} params.threadId - Native Gemini session ID for resume
70
+ * @param {string} params.sessionId - Session UUID (for logging)
71
71
  * @param {string} [params.model='gemini-3-pro-preview'] - Model name
72
72
  * @param {string} [params.workspacePath] - Workspace directory
73
73
  * @param {Function} [params.onStatus] - Callback for status events (SSE streaming)
74
- * @returns {Promise<{text: string, usage: Object, sessionId: string}>}
74
+ * @returns {Promise<{text: string, usage: Object}>}
75
75
  */
76
76
  async sendMessage({
77
77
  prompt,
78
- threadId,
78
+ sessionId,
79
79
  model = DEFAULT_MODEL,
80
80
  workspacePath,
81
81
  onStatus
@@ -87,23 +87,16 @@ class GeminiWrapper {
87
87
  const cwd = workspacePath || this.workspaceDir;
88
88
 
89
89
  // Build CLI arguments
90
- // If threadId exists, use --resume to continue native session
90
+ // Note: cwd is set in pty.spawn() options, no need for --include-directories
91
91
  const args = [
92
92
  '-y', // YOLO mode - auto-approve all actions
93
93
  '-m', model, // Model selection
94
94
  '-o', 'stream-json', // JSON streaming for structured events
95
+ prompt // Prompt as positional argument
95
96
  ];
96
97
 
97
- // Add resume flag if continuing existing session
98
- if (threadId) {
99
- args.push('--resume', threadId);
100
- }
101
-
102
- // Add prompt as positional argument
103
- args.push(prompt);
104
-
105
98
  console.log(`[GeminiWrapper] Model: ${model}`);
106
- console.log(`[GeminiWrapper] ThreadId: ${threadId || '(new session)'}`);
99
+ console.log(`[GeminiWrapper] Session: ${sessionId}`);
107
100
  console.log(`[GeminiWrapper] CWD: ${cwd}`);
108
101
  console.log(`[GeminiWrapper] Prompt length: ${prompt.length}`);
109
102
 
@@ -177,7 +170,6 @@ class GeminiWrapper {
177
170
 
178
171
  resolve({
179
172
  text: finalResponse,
180
- sessionId: parser.getSessionId(), // Native Gemini session ID for resume
181
173
  usage: {
182
174
  prompt_tokens: promptTokens,
183
175
  completion_tokens: completionTokens,
@@ -396,7 +396,7 @@ class WorkspaceManager {
396
396
  * @returns {string}
397
397
  */
398
398
  getSessionPath(workspacePath) {
399
- // Convert /var/www/myapp → -var-www-myapp
399
+ // Convert /home/user/myproject → -home-user-myproject
400
400
  const projectDir = workspacePath.replace(/\//g, '-').replace(/^-/, '');
401
401
  return path.join(this.claudePath, 'projects', projectDir);
402
402
  }
@@ -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.0",
3
+ "version": "0.7.2",
4
4
  "description": "NexusCLI - TRI CLI Control Plane (Claude/Codex/Gemini)",
5
5
  "main": "lib/server/server.js",
6
6
  "bin": {
@@ -1,225 +0,0 @@
1
- const initSqlJs = require('sql.js');
2
- const path = require('path');
3
- const fs = require('fs');
4
-
5
- // Detect environment (Linux vs Termux)
6
- const isTermux = process.env.PREFIX?.includes('com.termux');
7
-
8
- // Database directory
9
- const dbDir = isTermux
10
- ? path.join(process.env.HOME, '.nexuscli')
11
- : process.env.NEXUSCLI_DB_DIR || '/var/lib/nexuscli';
12
-
13
- // Database file path
14
- const dbPath = path.join(dbDir, 'nexuscli.db');
15
-
16
- // Ensure directory exists
17
- if (!fs.existsSync(dbDir)) {
18
- fs.mkdirSync(dbDir, { recursive: true });
19
- console.log(`✅ Created database directory: ${dbDir}`);
20
- }
21
-
22
- let db = null;
23
-
24
- // Initialize database
25
- async function initDb() {
26
- const SQL = await initSqlJs();
27
-
28
- // Load existing database or create new
29
- if (fs.existsSync(dbPath)) {
30
- const buffer = fs.readFileSync(dbPath);
31
- db = new SQL.Database(buffer);
32
- console.log(`✅ Database loaded: ${dbPath}`);
33
- } else {
34
- db = new SQL.Database();
35
- console.log(`✅ Database created: ${dbPath}`);
36
- }
37
-
38
- // Initialize schema
39
- initSchema();
40
-
41
- // Auto-save every 5 seconds
42
- setInterval(saveDb, 5000);
43
-
44
- return db;
45
- }
46
-
47
- // Save database to file
48
- function saveDb() {
49
- if (!db) return;
50
- const data = db.export();
51
- const buffer = Buffer.from(data);
52
- fs.writeFileSync(dbPath, buffer);
53
- }
54
-
55
- // Initialize schema
56
- function initSchema() {
57
- db.run(`
58
- -- Conversations table
59
- CREATE TABLE IF NOT EXISTS conversations (
60
- id TEXT PRIMARY KEY,
61
- title TEXT NOT NULL,
62
- created_at INTEGER NOT NULL,
63
- updated_at INTEGER NOT NULL,
64
- metadata TEXT
65
- );
66
-
67
- CREATE INDEX IF NOT EXISTS idx_conversations_updated_at
68
- ON conversations(updated_at DESC);
69
-
70
- -- Messages table
71
- CREATE TABLE IF NOT EXISTS messages (
72
- id TEXT PRIMARY KEY,
73
- conversation_id TEXT NOT NULL,
74
- role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
75
- content TEXT NOT NULL,
76
- created_at INTEGER NOT NULL,
77
- metadata TEXT,
78
- FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
79
- );
80
-
81
- CREATE INDEX IF NOT EXISTS idx_messages_conversation_id
82
- ON messages(conversation_id);
83
-
84
- CREATE INDEX IF NOT EXISTS idx_messages_created_at
85
- ON messages(created_at ASC);
86
-
87
- -- Jobs table
88
- CREATE TABLE IF NOT EXISTS jobs (
89
- id TEXT PRIMARY KEY,
90
- conversation_id TEXT,
91
- message_id TEXT,
92
- node_id TEXT NOT NULL,
93
- tool TEXT NOT NULL,
94
- command TEXT NOT NULL,
95
- status TEXT NOT NULL CHECK(status IN ('queued', 'executing', 'completed', 'failed', 'cancelled')),
96
- exit_code INTEGER,
97
- stdout TEXT,
98
- stderr TEXT,
99
- duration INTEGER,
100
- created_at INTEGER NOT NULL,
101
- started_at INTEGER,
102
- completed_at INTEGER,
103
- FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL,
104
- FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
105
- );
106
-
107
- CREATE INDEX IF NOT EXISTS idx_jobs_conversation_id
108
- ON jobs(conversation_id);
109
-
110
- CREATE INDEX IF NOT EXISTS idx_jobs_status
111
- ON jobs(status);
112
-
113
- CREATE INDEX IF NOT EXISTS idx_jobs_created_at
114
- ON jobs(created_at DESC);
115
-
116
- -- Users table
117
- CREATE TABLE IF NOT EXISTS users (
118
- id TEXT PRIMARY KEY,
119
- username TEXT UNIQUE NOT NULL,
120
- password_hash TEXT NOT NULL,
121
- role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
122
- is_locked INTEGER NOT NULL DEFAULT 0,
123
- failed_attempts INTEGER NOT NULL DEFAULT 0,
124
- last_failed_attempt INTEGER,
125
- locked_until INTEGER,
126
- created_at INTEGER NOT NULL,
127
- last_login INTEGER
128
- );
129
-
130
- CREATE INDEX IF NOT EXISTS idx_users_username
131
- ON users(username);
132
-
133
- -- Login attempts table (for rate limiting)
134
- CREATE TABLE IF NOT EXISTS login_attempts (
135
- id INTEGER PRIMARY KEY AUTOINCREMENT,
136
- ip_address TEXT NOT NULL,
137
- username TEXT,
138
- success INTEGER NOT NULL DEFAULT 0,
139
- timestamp INTEGER NOT NULL
140
- );
141
-
142
- CREATE INDEX IF NOT EXISTS idx_login_attempts_ip
143
- ON login_attempts(ip_address, timestamp DESC);
144
-
145
- CREATE INDEX IF NOT EXISTS idx_login_attempts_timestamp
146
- ON login_attempts(timestamp DESC);
147
-
148
- -- Nodes table (optional - for multi-node setups)
149
- CREATE TABLE IF NOT EXISTS nodes (
150
- id TEXT PRIMARY KEY,
151
- hostname TEXT NOT NULL,
152
- ip_address TEXT,
153
- status TEXT NOT NULL CHECK(status IN ('online', 'offline', 'error')),
154
- capabilities TEXT,
155
- last_heartbeat INTEGER,
156
- created_at INTEGER NOT NULL
157
- );
158
-
159
- CREATE INDEX IF NOT EXISTS idx_nodes_status
160
- ON nodes(status);
161
- `);
162
-
163
- console.log('✅ Database schema initialized');
164
- }
165
-
166
- // Prepare statement (sql.js uses db.prepare)
167
- function prepare(sql) {
168
- const stmt = db.prepare(sql);
169
- return {
170
- run: (...params) => {
171
- stmt.bind(params);
172
- stmt.step();
173
- stmt.reset();
174
- saveDb(); // Auto-save on write
175
- },
176
- get: (...params) => {
177
- stmt.bind(params);
178
- const result = stmt.step() ? stmt.getAsObject() : null;
179
- stmt.reset();
180
- return result;
181
- },
182
- all: (...params) => {
183
- stmt.bind(params);
184
- const results = [];
185
- while (stmt.step()) {
186
- results.push(stmt.getAsObject());
187
- }
188
- stmt.reset();
189
- return results;
190
- }
191
- };
192
- }
193
-
194
- // Graceful shutdown
195
- process.on('exit', () => {
196
- if (db) {
197
- saveDb();
198
- db.close();
199
- console.log('✅ Database connection closed');
200
- }
201
- });
202
-
203
- process.on('SIGINT', () => {
204
- if (db) {
205
- saveDb();
206
- db.close();
207
- console.log('✅ Database connection closed (SIGINT)');
208
- }
209
- process.exit(0);
210
- });
211
-
212
- process.on('SIGTERM', () => {
213
- if (db) {
214
- saveDb();
215
- db.close();
216
- }
217
- });
218
-
219
- // Export db object (initialized async)
220
- module.exports = {
221
- initDb,
222
- getDb: () => db,
223
- prepare,
224
- saveDb
225
- };