@mmmbuto/nexuscli 0.5.0

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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/nexuscli.js +117 -0
  4. package/frontend/dist/apple-touch-icon.png +0 -0
  5. package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/frontend/dist/assets/index-Bn_l1e6e.css +1 -0
  65. package/frontend/dist/assets/index-CikJbUR5.js +8617 -0
  66. package/frontend/dist/browserconfig.xml +12 -0
  67. package/frontend/dist/favicon-16x16.png +0 -0
  68. package/frontend/dist/favicon-32x32.png +0 -0
  69. package/frontend/dist/favicon-48x48.png +0 -0
  70. package/frontend/dist/favicon.ico +0 -0
  71. package/frontend/dist/icon-192.png +0 -0
  72. package/frontend/dist/icon-512.png +0 -0
  73. package/frontend/dist/icon-maskable-192.png +0 -0
  74. package/frontend/dist/icon-maskable-512.png +0 -0
  75. package/frontend/dist/index.html +79 -0
  76. package/frontend/dist/manifest.json +75 -0
  77. package/frontend/dist/sw.js +122 -0
  78. package/frontend/package.json +28 -0
  79. package/lib/cli/api.js +156 -0
  80. package/lib/cli/boot.js +172 -0
  81. package/lib/cli/config.js +185 -0
  82. package/lib/cli/engines.js +257 -0
  83. package/lib/cli/init.js +660 -0
  84. package/lib/cli/logs.js +72 -0
  85. package/lib/cli/start.js +220 -0
  86. package/lib/cli/status.js +187 -0
  87. package/lib/cli/stop.js +64 -0
  88. package/lib/cli/uninstall.js +194 -0
  89. package/lib/cli/users.js +295 -0
  90. package/lib/cli/workspaces.js +337 -0
  91. package/lib/config/manager.js +233 -0
  92. package/lib/server/.env.example +20 -0
  93. package/lib/server/db/adapter.js +314 -0
  94. package/lib/server/db/drivers/better-sqlite3.js +38 -0
  95. package/lib/server/db/drivers/sql-js.js +75 -0
  96. package/lib/server/db/migrate.js +174 -0
  97. package/lib/server/db/migrations/001_ultra_light_schema.sql +96 -0
  98. package/lib/server/db/migrations/002_session_conversation_mapping.sql +19 -0
  99. package/lib/server/db/migrations/003_message_engine_tracking.sql +18 -0
  100. package/lib/server/db/migrations/004_performance_indexes.sql +16 -0
  101. package/lib/server/db.js +2 -0
  102. package/lib/server/lib/cli-wrapper.js +164 -0
  103. package/lib/server/lib/output-parser.js +132 -0
  104. package/lib/server/lib/pty-adapter.js +57 -0
  105. package/lib/server/middleware/auth.js +103 -0
  106. package/lib/server/models/Conversation.js +259 -0
  107. package/lib/server/models/Message.js +228 -0
  108. package/lib/server/models/User.js +115 -0
  109. package/lib/server/package-lock.json +5895 -0
  110. package/lib/server/routes/auth.js +168 -0
  111. package/lib/server/routes/chat.js +206 -0
  112. package/lib/server/routes/codex.js +205 -0
  113. package/lib/server/routes/conversations.js +224 -0
  114. package/lib/server/routes/gemini.js +228 -0
  115. package/lib/server/routes/jobs.js +317 -0
  116. package/lib/server/routes/messages.js +60 -0
  117. package/lib/server/routes/models.js +198 -0
  118. package/lib/server/routes/sessions.js +285 -0
  119. package/lib/server/routes/upload.js +134 -0
  120. package/lib/server/routes/wake-lock.js +95 -0
  121. package/lib/server/routes/workspace.js +80 -0
  122. package/lib/server/routes/workspaces.js +142 -0
  123. package/lib/server/scripts/cleanup-ghost-sessions.js +71 -0
  124. package/lib/server/scripts/seed-users.js +37 -0
  125. package/lib/server/scripts/test-history-access.js +50 -0
  126. package/lib/server/server.js +227 -0
  127. package/lib/server/services/cache.js +85 -0
  128. package/lib/server/services/claude-wrapper.js +312 -0
  129. package/lib/server/services/cli-loader.js +384 -0
  130. package/lib/server/services/codex-output-parser.js +277 -0
  131. package/lib/server/services/codex-wrapper.js +224 -0
  132. package/lib/server/services/context-bridge.js +289 -0
  133. package/lib/server/services/gemini-output-parser.js +398 -0
  134. package/lib/server/services/gemini-wrapper.js +249 -0
  135. package/lib/server/services/history-sync.js +407 -0
  136. package/lib/server/services/output-parser.js +415 -0
  137. package/lib/server/services/session-manager.js +465 -0
  138. package/lib/server/services/summary-generator.js +259 -0
  139. package/lib/server/services/workspace-manager.js +516 -0
  140. package/lib/server/tests/history-sync.test.js +90 -0
  141. package/lib/server/tests/integration-session-sync.test.js +151 -0
  142. package/lib/server/tests/integration.test.js +76 -0
  143. package/lib/server/tests/performance.test.js +118 -0
  144. package/lib/server/tests/services.test.js +160 -0
  145. package/lib/setup/postinstall.js +216 -0
  146. package/lib/utils/paths.js +107 -0
  147. package/lib/utils/termux.js +145 -0
  148. package/package.json +82 -0
@@ -0,0 +1,312 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const pty = require('../lib/pty-adapter');
4
+ const OutputParser = require('./output-parser');
5
+ const { getApiKey } = require('../db');
6
+
7
+ /**
8
+ * Wrapper for Claude Code CLI (local installation)
9
+ *
10
+ * Features:
11
+ * - Uses local Claude Code installation (/home/dag/.claude/local/claude)
12
+ * - OAuth authentication (handled by CLI)
13
+ * - Real-time status streaming via onStatus callback
14
+ * - Session management with conversation ID
15
+ *
16
+ * Architecture:
17
+ * - Spawns Claude Code CLI with node-pty
18
+ * - Parses stdout for tool use, thinking, file ops
19
+ * - Emits status events → SSE stream
20
+ * - Returns final response text → saved in DB
21
+ */
22
+ class ClaudeWrapper {
23
+ constructor(options = {}) {
24
+ this.claudePath = this.resolveClaudePath(options.claudePath);
25
+ this.workspaceDir = options.workspaceDir || process.env.NEXUSCLI_WORKSPACE || process.cwd();
26
+ }
27
+
28
+ isExistingSession(sessionId, workspacePath) {
29
+ // ONLY check filesystem - Claude CLI's .jsonl files are the source of truth
30
+ // NexusCLI's database (sessions/conversations tables) may have IDs that
31
+ // Claude CLI doesn't recognize, causing "No conversation found" errors
32
+ try {
33
+ const projectsDir = path.join(process.env.HOME || '', '.claude', 'projects');
34
+ if (fs.existsSync(projectsDir)) {
35
+ const dirs = fs.readdirSync(projectsDir);
36
+ for (const dir of dirs) {
37
+ const sessionFile = path.join(projectsDir, dir, `${sessionId}.jsonl`);
38
+ if (fs.existsSync(sessionFile)) {
39
+ console.log(`[ClaudeWrapper] Session ${sessionId} found in filesystem: ${sessionFile}`);
40
+ return true;
41
+ }
42
+ }
43
+ }
44
+ } catch (err) {
45
+ console.warn('[ClaudeWrapper] Filesystem lookup failed:', err.message);
46
+ }
47
+
48
+ console.log(`[ClaudeWrapper] Session ${sessionId} not found in Claude projects - treating as NEW`);
49
+ return false;
50
+ }
51
+
52
+ resolveClaudePath(overridePath) {
53
+ if (overridePath && fs.existsSync(overridePath)) return overridePath;
54
+ const envPath = process.env.NEXUSCLI_CLAUDE_PATH;
55
+ if (envPath && fs.existsSync(envPath)) return envPath;
56
+
57
+ const candidates = [
58
+ path.join(process.env.HOME || '', '.claude', 'local', 'claude'),
59
+ path.join(process.env.PREFIX || '', 'bin', 'claude'),
60
+ path.join(process.env.HOME || '', 'bin', 'claude'),
61
+ '/usr/local/bin/claude',
62
+ '/usr/bin/claude',
63
+ ].filter(Boolean);
64
+
65
+ for (const candidate of candidates) {
66
+ try {
67
+ if (fs.existsSync(candidate) && fs.statSync(candidate).mode & 0o111) {
68
+ return candidate;
69
+ }
70
+ } catch (_) {
71
+ // ignore
72
+ }
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Send message to Claude Code CLI
80
+ *
81
+ * @param {Object} params
82
+ * @param {string} params.prompt - User message
83
+ * @param {string} params.conversationId - Session ID for Claude
84
+ * @param {string} params.model - Model (sonnet, opus, haiku, or full name)
85
+ * @param {string} params.workspacePath - Workspace directory for Claude CLI --cwd
86
+ * @param {Function} params.onStatus - Callback for status events (tool use, thinking)
87
+ * @returns {Promise<{text: string, usage: Object}>}
88
+ */
89
+ async sendMessage({ prompt, conversationId, model = 'sonnet', workspacePath, onStatus }) {
90
+ return new Promise((resolve, reject) => {
91
+ const parser = new OutputParser();
92
+ // Prevent double-settling when PTY fires both error and exit events
93
+ let promiseSettled = false;
94
+
95
+ if (!this.claudePath || !fs.existsSync(this.claudePath)) {
96
+ const msg = `Claude CLI not found (set NEXUSCLI_CLAUDE_PATH)`;
97
+ console.error('[ClaudeWrapper]', msg);
98
+ if (onStatus) {
99
+ onStatus({ type: 'error', category: 'runtime', message: msg });
100
+ }
101
+ return reject(new Error(msg));
102
+ }
103
+
104
+ // Check if this is an existing session (DB is source of truth)
105
+ const isExistingSession = this.isExistingSession(conversationId);
106
+
107
+ // Build Claude Code CLI args
108
+ const args = [
109
+ '--dangerously-skip-permissions', // Auto-approve all tool use
110
+ '--model', model,
111
+ '--print', // Non-interactive mode
112
+ '--verbose', // Enable detailed output
113
+ '--output-format', 'stream-json', // JSON streaming events
114
+ ];
115
+
116
+ // Session management: -r (resume) or --session-id (new)
117
+ if (isExistingSession) {
118
+ args.push('-r', conversationId); // Resume with full history
119
+ } else {
120
+ args.push('--session-id', conversationId); // Create new session
121
+ }
122
+
123
+ args.push(prompt);
124
+
125
+ // Use provided workspace or fallback to default
126
+ const cwd = workspacePath || this.workspaceDir;
127
+
128
+ // Build environment - detect DeepSeek models and configure API accordingly
129
+ const spawnEnv = { ...process.env };
130
+ const isDeepSeek = model.startsWith('deepseek-');
131
+
132
+ if (isDeepSeek) {
133
+ // Get API key from database (priority) or fallback to env var
134
+ const deepseekKey = getApiKey('deepseek') || process.env.DEEPSEEK_API_KEY;
135
+
136
+ if (!deepseekKey) {
137
+ const errorMsg = `DeepSeek API key not configured!\n\n` +
138
+ `Run this command to add your API key:\n` +
139
+ ` nexuscli api set deepseek YOUR_API_KEY\n\n` +
140
+ `Get your key at: https://platform.deepseek.com/api_keys`;
141
+
142
+ console.error(`[ClaudeWrapper] ❌ ${errorMsg}`);
143
+
144
+ if (onStatus) {
145
+ onStatus({
146
+ type: 'error',
147
+ category: 'config',
148
+ message: errorMsg
149
+ });
150
+ }
151
+
152
+ return reject(new Error(errorMsg));
153
+ }
154
+
155
+ // DeepSeek uses Anthropic-compatible API at different endpoint
156
+ spawnEnv.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic';
157
+ spawnEnv.ANTHROPIC_AUTH_TOKEN = deepseekKey;
158
+ console.log(`[ClaudeWrapper] DeepSeek detected - using api.deepseek.com/anthropic`);
159
+ }
160
+
161
+ console.log(`[ClaudeWrapper] Model: ${model}${isDeepSeek ? ' (DeepSeek API)' : ''}`);
162
+ console.log(`[ClaudeWrapper] Session: ${conversationId} (${isExistingSession ? 'RESUME' : 'NEW'})`);
163
+ console.log(`[ClaudeWrapper] Working dir: ${cwd}`);
164
+
165
+ // Spawn Claude Code CLI with PTY
166
+ // On Termux, invoke node directly with the cli.js script for better compatibility
167
+ let command = this.claudePath;
168
+ let spawnArgs = args;
169
+
170
+ if (!pty.isPtyAvailable() && this.claudePath.endsWith('/claude')) {
171
+ // Resolve symlink to actual cli.js and invoke with node
172
+ const fs = require('fs');
173
+ try {
174
+ const realPath = fs.realpathSync(this.claudePath);
175
+ if (realPath.endsWith('.js')) {
176
+ command = process.execPath; // node binary
177
+ spawnArgs = [realPath, ...args];
178
+ console.log('[ClaudeWrapper] Termux mode: invoking node directly with', realPath);
179
+ }
180
+ } catch (err) {
181
+ console.warn('[ClaudeWrapper] Failed to resolve symlink, using path as-is:', err.message);
182
+ }
183
+ }
184
+
185
+ let ptyProcess;
186
+ try {
187
+ ptyProcess = pty.spawn(command, spawnArgs, {
188
+ name: 'xterm-color',
189
+ cols: 80,
190
+ rows: 30,
191
+ cwd: cwd, // Use session-specific workspace
192
+ env: spawnEnv, // Use configured env (includes DeepSeek API if needed)
193
+ });
194
+ } catch (err) {
195
+ const msg = `Failed to spawn Claude CLI: ${err.message}`;
196
+ console.error('[ClaudeWrapper]', msg);
197
+ if (onStatus) {
198
+ onStatus({ type: 'error', category: 'runtime', message: msg });
199
+ }
200
+ return reject(new Error(msg));
201
+ }
202
+
203
+ let stdout = '';
204
+
205
+ // Process output chunks
206
+ ptyProcess.onData((data) => {
207
+ stdout += data;
208
+ console.log(`[ClaudeWrapper] PTY data chunk: ${data.substring(0, 200)}`);
209
+
210
+ // Parse and emit status events
211
+ if (onStatus) {
212
+ try {
213
+ const events = parser.parse(data);
214
+ console.log(`[ClaudeWrapper] Parsed ${events.length} events`);
215
+ events.forEach(event => {
216
+ // Only emit status events (not response chunks)
217
+ if (event.type === 'status' || event.type === 'status_update') {
218
+ console.log(`[ClaudeWrapper] Status: ${event.category} - ${event.message}`);
219
+ onStatus(event);
220
+ }
221
+ });
222
+ } catch (parseError) {
223
+ console.error('[ClaudeWrapper] Parser error:', parseError);
224
+ }
225
+ }
226
+ });
227
+
228
+ // Handle exit
229
+ const handleError = (err) => {
230
+ console.error('[ClaudeWrapper] spawn error:', err);
231
+
232
+ // Ignore spurious EIO errors that occur after process exit (PTY race condition)
233
+ if (err.code === 'EIO' && promiseSettled) {
234
+ console.log('[ClaudeWrapper] Ignoring post-exit EIO error (PTY race condition)');
235
+ return;
236
+ }
237
+
238
+ if (promiseSettled) {
239
+ console.log('[ClaudeWrapper] PTY cleanup error (ignored):', err.code || err.message);
240
+ return;
241
+ }
242
+
243
+ promiseSettled = true;
244
+
245
+ if (onStatus) {
246
+ onStatus({ type: 'error', category: 'runtime', message: err.message });
247
+ }
248
+ reject(new Error(`Claude CLI spawn error: ${err.message}`));
249
+ };
250
+
251
+ if (typeof ptyProcess.on === 'function') {
252
+ ptyProcess.on('error', handleError);
253
+ } else if (typeof ptyProcess.onError === 'function') {
254
+ ptyProcess.onError(handleError);
255
+ }
256
+
257
+ ptyProcess.onExit(({ exitCode }) => {
258
+ console.log(`[ClaudeWrapper] Exit code: ${exitCode}`);
259
+
260
+ if (exitCode !== 0) {
261
+ if (promiseSettled) {
262
+ console.log(`[ClaudeWrapper] Exit after settle (exit ${exitCode}) - ignoring`);
263
+ return;
264
+ }
265
+
266
+ promiseSettled = true;
267
+ console.error('[ClaudeWrapper] Error output:', stdout.substring(0, 500));
268
+ reject(new Error(`Claude CLI error (exit ${exitCode}): ${stdout.substring(0, 200)}`));
269
+ return;
270
+ }
271
+
272
+ if (promiseSettled) {
273
+ console.log('[ClaudeWrapper] Exit already handled - skipping resolve');
274
+ return;
275
+ }
276
+
277
+ promiseSettled = true;
278
+
279
+ // Extract final response from JSON stream
280
+ const finalResponse = parser.extractFinalResponse();
281
+
282
+ // Get real token usage from JSON metadata
283
+ const usageData = parser.getUsage();
284
+ const usage = {
285
+ prompt_tokens: usageData?.input_tokens || 0,
286
+ completion_tokens: usageData?.output_tokens || 0,
287
+ total_tokens: (usageData?.input_tokens || 0) + (usageData?.output_tokens || 0),
288
+ cache_read_tokens: usageData?.cache_read_input_tokens || 0,
289
+ cache_creation_tokens: usageData?.cache_creation_input_tokens || 0,
290
+ };
291
+
292
+ console.log(`[ClaudeWrapper] Response length: ${finalResponse.length} chars`);
293
+ console.log(`[ClaudeWrapper] Token usage:`, usage);
294
+
295
+ resolve({
296
+ text: finalResponse,
297
+ usage,
298
+ });
299
+ });
300
+
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Clean up session tracking when conversation is deleted
306
+ */
307
+ deleteSession(conversationId) {
308
+ console.log(`[ClaudeWrapper] Removed session tracking: ${conversationId}`);
309
+ }
310
+ }
311
+
312
+ module.exports = ClaudeWrapper;
@@ -0,0 +1,384 @@
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
+ engine = 'claude',
51
+ workspacePath,
52
+ limit = DEFAULT_LIMIT,
53
+ before,
54
+ mode = 'asc'
55
+ }) {
56
+ if (!sessionId) {
57
+ throw new Error('sessionId is required');
58
+ }
59
+
60
+ const startedAt = Date.now();
61
+ const normalizedEngine = this._normalizeEngine(engine);
62
+
63
+ let result;
64
+ switch (normalizedEngine) {
65
+ case 'claude':
66
+ result = await this.loadClaudeMessages({ sessionId, workspacePath, limit, before, mode });
67
+ break;
68
+
69
+ case 'codex':
70
+ result = await this.loadCodexMessages({ sessionId, limit, before, mode });
71
+ break;
72
+
73
+ case 'gemini':
74
+ result = await this.loadGeminiMessages({ sessionId, limit, before, mode });
75
+ break;
76
+
77
+ default:
78
+ throw new Error(`Unsupported engine: ${engine}`);
79
+ }
80
+
81
+ console.log(`[CliLoader] ${normalizedEngine} messages loaded in ${Date.now() - startedAt}ms (session ${sessionId}, ${result.messages.length} msgs)`);
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * Normalize engine name variants
87
+ */
88
+ _normalizeEngine(engine) {
89
+ if (!engine) return 'claude';
90
+ const lower = engine.toLowerCase();
91
+ if (lower.includes('claude')) return 'claude';
92
+ if (lower.includes('codex') || lower.includes('openai')) return 'codex';
93
+ if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
94
+ return lower;
95
+ }
96
+
97
+ /**
98
+ * Convert workspace path to slug (for .claude/projects/ directory)
99
+ * Same as Claude Code behavior: /path/to/dir → -path-to-dir
100
+ * Also converts dots to dashes (e.g., com.termux → com-termux)
101
+ */
102
+ pathToSlug(workspacePath) {
103
+ if (!workspacePath) return '-default';
104
+ // Replace slashes AND dots with dashes (matches Claude Code behavior)
105
+ return workspacePath.replace(/[\/\.]/g, '-');
106
+ }
107
+
108
+ // ============================================================
109
+ // CLAUDE - Load from ~/.claude/projects/<slug>/<sessionId>.jsonl
110
+ // ============================================================
111
+
112
+ async loadClaudeMessages({ sessionId, workspacePath, limit, before, mode }) {
113
+ if (!workspacePath) {
114
+ console.warn('[CliLoader] No workspacePath for Claude, using cwd');
115
+ workspacePath = process.cwd();
116
+ }
117
+
118
+ const slug = this.pathToSlug(workspacePath);
119
+ const sessionFile = path.join(this.claudePath, 'projects', slug, `${sessionId}.jsonl`);
120
+
121
+ if (!fs.existsSync(sessionFile)) {
122
+ console.warn(`[CliLoader] Claude session file not found: ${sessionFile}`);
123
+ return this._emptyResult();
124
+ }
125
+
126
+ const rawMessages = await this._parseJsonlFile(sessionFile);
127
+
128
+ // Filter and normalize
129
+ const messages = rawMessages
130
+ .filter(entry => entry.type === 'user' || entry.type === 'assistant')
131
+ .map(entry => this._normalizeClaudeEntry(entry));
132
+
133
+ return this._paginateMessages(messages, limit, before, mode);
134
+ }
135
+
136
+ /**
137
+ * Normalize Claude Code session entry to message shape
138
+ */
139
+ _normalizeClaudeEntry(entry) {
140
+ // Extract content - handle both string and array of content blocks
141
+ let content = '';
142
+ const rawContent = entry.message?.content;
143
+
144
+ if (typeof rawContent === 'string') {
145
+ content = rawContent;
146
+ } else if (Array.isArray(rawContent)) {
147
+ // Claude Code uses array of content blocks: [{type: 'text', text: '...'}, ...]
148
+ content = rawContent
149
+ .filter(block => block.type === 'text' && block.text)
150
+ .map(block => block.text)
151
+ .join('\n');
152
+ } else if (entry.display || entry.text) {
153
+ // Fallback for older formats
154
+ content = entry.display || entry.text || '';
155
+ }
156
+
157
+ const role = entry.message?.role || entry.type || 'assistant';
158
+ const created_at = new Date(entry.timestamp).getTime() || Date.now();
159
+
160
+ return {
161
+ id: entry.message?.id || `claude-${created_at}`,
162
+ role,
163
+ content,
164
+ engine: 'claude',
165
+ created_at,
166
+ metadata: {
167
+ model: entry.message?.model,
168
+ stop_reason: entry.message?.stop_reason
169
+ }
170
+ };
171
+ }
172
+
173
+ // ============================================================
174
+ // CODEX - Load from ~/.codex/sessions/<sessionId>.jsonl
175
+ // ============================================================
176
+
177
+ async loadCodexMessages({ sessionId, limit, before, mode }) {
178
+ const sessionFile = path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
179
+
180
+ // Codex may not persist sessions locally - check if file exists
181
+ 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}`);
184
+ return this._emptyResult();
185
+ }
186
+
187
+ const rawMessages = await this._parseJsonlFile(sessionFile);
188
+
189
+ // Filter and normalize
190
+ const messages = rawMessages
191
+ .filter(entry => entry.role === 'user' || entry.role === 'assistant')
192
+ .map(entry => this._normalizeCodexEntry(entry));
193
+
194
+ return this._paginateMessages(messages, limit, before, mode);
195
+ }
196
+
197
+ /**
198
+ * Normalize Codex session entry to message shape
199
+ */
200
+ _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
205
+ let content = '';
206
+ if (typeof entry.content === 'string') {
207
+ content = entry.content;
208
+ } else if (entry.message) {
209
+ content = typeof entry.message === 'string' ? entry.message : JSON.stringify(entry.message);
210
+ }
211
+
212
+ return {
213
+ id: entry.id || `codex-${created_at}`,
214
+ role,
215
+ content,
216
+ engine: 'codex',
217
+ created_at,
218
+ metadata: {
219
+ model: entry.model,
220
+ reasoning_effort: entry.reasoning_effort
221
+ }
222
+ };
223
+ }
224
+
225
+ // ============================================================
226
+ // GEMINI - Load from ~/.gemini/sessions/<sessionId>.jsonl
227
+ // ============================================================
228
+
229
+ async loadGeminiMessages({ sessionId, limit, before, mode }) {
230
+ const sessionFile = path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
231
+
232
+ // Gemini CLI may not save sessions - check if file exists
233
+ if (!fs.existsSync(sessionFile)) {
234
+ console.log(`[CliLoader] Gemini session file not found: ${sessionFile}`);
235
+ return this._emptyResult();
236
+ }
237
+
238
+ const rawMessages = await this._parseJsonlFile(sessionFile);
239
+
240
+ // Filter and normalize
241
+ const messages = rawMessages
242
+ .filter(entry => entry.role === 'user' || entry.role === 'model' || entry.role === 'assistant')
243
+ .map(entry => this._normalizeGeminiEntry(entry));
244
+
245
+ return this._paginateMessages(messages, limit, before, mode);
246
+ }
247
+
248
+ /**
249
+ * Normalize Gemini session entry to message shape
250
+ */
251
+ _normalizeGeminiEntry(entry) {
252
+ // Gemini uses 'model' instead of 'assistant'
253
+ const role = entry.role === 'model' ? 'assistant' : (entry.role || 'assistant');
254
+ const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
255
+
256
+ // Gemini content format
257
+ let content = '';
258
+ if (typeof entry.content === 'string') {
259
+ content = entry.content;
260
+ } else if (Array.isArray(entry.parts)) {
261
+ // Gemini uses parts array: [{text: '...'}]
262
+ content = entry.parts
263
+ .filter(p => p.text)
264
+ .map(p => p.text)
265
+ .join('\n');
266
+ } else if (entry.text) {
267
+ content = entry.text;
268
+ }
269
+
270
+ return {
271
+ id: entry.id || `gemini-${created_at}`,
272
+ role,
273
+ content,
274
+ engine: 'gemini',
275
+ created_at,
276
+ metadata: {
277
+ model: entry.model
278
+ }
279
+ };
280
+ }
281
+
282
+ // ============================================================
283
+ // UTILITY METHODS
284
+ // ============================================================
285
+
286
+ /**
287
+ * Parse JSONL file line by line (memory efficient)
288
+ */
289
+ async _parseJsonlFile(filePath) {
290
+ const entries = [];
291
+
292
+ const fileStream = fs.createReadStream(filePath);
293
+ const rl = readline.createInterface({
294
+ input: fileStream,
295
+ crlfDelay: Infinity
296
+ });
297
+
298
+ for await (const line of rl) {
299
+ if (!line.trim()) continue;
300
+
301
+ try {
302
+ const entry = JSON.parse(line);
303
+ entries.push(entry);
304
+ } catch (e) {
305
+ // Skip malformed lines
306
+ console.warn(`[CliLoader] Skipping malformed JSON line in ${filePath}`);
307
+ }
308
+ }
309
+
310
+ return entries;
311
+ }
312
+
313
+ /**
314
+ * Apply pagination to messages array
315
+ */
316
+ _paginateMessages(messages, limit, before, mode) {
317
+ // Filter by timestamp if 'before' cursor provided
318
+ let filtered = messages;
319
+ if (before) {
320
+ filtered = messages.filter(m => m.created_at < Number(before));
321
+ }
322
+
323
+ // Sort newest first for pagination slicing
324
+ filtered.sort((a, b) => b.created_at - a.created_at);
325
+
326
+ // Apply limit
327
+ const page = filtered.slice(0, limit);
328
+ const hasMore = filtered.length > limit;
329
+ const oldestTimestamp = page.length ? page[page.length - 1].created_at : null;
330
+
331
+ // Return in requested order (default asc for UI rendering)
332
+ const ordered = mode === 'desc'
333
+ ? page
334
+ : [...page].sort((a, b) => a.created_at - b.created_at);
335
+
336
+ return {
337
+ messages: ordered,
338
+ pagination: {
339
+ hasMore,
340
+ oldestTimestamp,
341
+ total: messages.length
342
+ }
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Return empty result structure
348
+ */
349
+ _emptyResult() {
350
+ return {
351
+ messages: [],
352
+ pagination: {
353
+ hasMore: false,
354
+ oldestTimestamp: null,
355
+ total: 0
356
+ }
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Get session file path for an engine
362
+ * Useful for external checks
363
+ */
364
+ getSessionFilePath(sessionId, engine, workspacePath) {
365
+ const normalizedEngine = this._normalizeEngine(engine);
366
+
367
+ switch (normalizedEngine) {
368
+ case 'claude':
369
+ const slug = this.pathToSlug(workspacePath);
370
+ return path.join(this.claudePath, 'projects', slug, `${sessionId}.jsonl`);
371
+
372
+ case 'codex':
373
+ return path.join(this.codexPath, 'sessions', `${sessionId}.jsonl`);
374
+
375
+ case 'gemini':
376
+ return path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
377
+
378
+ default:
379
+ return null;
380
+ }
381
+ }
382
+ }
383
+
384
+ module.exports = CliLoader;