@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,289 @@
1
+ /**
2
+ * ContextBridge - Unified context management with token optimization
3
+ *
4
+ * Features:
5
+ * - Token-aware context truncation
6
+ * - Engine-specific context compression
7
+ * - Auto-summary triggering
8
+ * - Unified bridging logic for all engines
9
+ */
10
+
11
+ const Message = require('../models/Message');
12
+ const SummaryGenerator = require('./summary-generator');
13
+
14
+ // Token estimation constants (approximate)
15
+ const CHARS_PER_TOKEN = 4; // GPT/Claude average
16
+ const DEFAULT_MAX_TOKENS = 4000; // Context budget for bridging
17
+ const SUMMARY_TRIGGER_THRESHOLD = 15; // Messages before auto-summary
18
+
19
+ // Engine-specific context limits
20
+ const ENGINE_LIMITS = {
21
+ 'claude': { maxTokens: 4000, preferSummary: true },
22
+ 'codex': { maxTokens: 3000, preferSummary: true, codeOnly: true },
23
+ 'deepseek': { maxTokens: 3000, preferSummary: true },
24
+ 'gemini': { maxTokens: 6000, preferSummary: false } // Gemini has large context
25
+ };
26
+
27
+ class ContextBridge {
28
+ constructor() {
29
+ this.summaryGenerator = new SummaryGenerator();
30
+ }
31
+
32
+ /**
33
+ * Estimate token count for text
34
+ * @param {string} text - Text to estimate
35
+ * @returns {number} Estimated token count
36
+ */
37
+ estimateTokens(text) {
38
+ if (!text) return 0;
39
+ // Simple estimation: ~4 chars per token for English/code
40
+ // More accurate would use tiktoken, but this is faster
41
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
42
+ }
43
+
44
+ /**
45
+ * Get engine-specific configuration
46
+ * @param {string} engine - Engine name
47
+ * @returns {Object} Engine config
48
+ */
49
+ getEngineConfig(engine) {
50
+ return ENGINE_LIMITS[engine] || ENGINE_LIMITS['claude'];
51
+ }
52
+
53
+ /**
54
+ * Build optimized context for engine switch
55
+ * @param {Object} params
56
+ * @param {string} params.sessionId - Session ID
57
+ * @param {string} params.fromEngine - Previous engine
58
+ * @param {string} params.toEngine - Target engine
59
+ * @param {string} params.userMessage - Current user message
60
+ * @returns {Object} { prompt, isEngineBridge, contextTokens }
61
+ */
62
+ async buildContext({ sessionId, fromEngine, toEngine, userMessage }) {
63
+ const config = this.getEngineConfig(toEngine);
64
+ const isEngineBridge = fromEngine && fromEngine !== toEngine;
65
+
66
+ // Reserve tokens for user message
67
+ const userTokens = this.estimateTokens(userMessage);
68
+ const availableTokens = config.maxTokens - userTokens - 200; // 200 token buffer
69
+
70
+ let contextText = '';
71
+ let contextTokens = 0;
72
+ let contextSource = 'none';
73
+
74
+ // Try summary first (most efficient)
75
+ if (config.preferSummary) {
76
+ const summaryContext = this.summaryGenerator.getBridgeContext(sessionId);
77
+ if (summaryContext) {
78
+ const summaryTokens = this.estimateTokens(summaryContext);
79
+ if (summaryTokens <= availableTokens) {
80
+ contextText = summaryContext;
81
+ contextTokens = summaryTokens;
82
+ contextSource = 'summary';
83
+ }
84
+ }
85
+ }
86
+
87
+ // Fallback to token-aware message history
88
+ if (!contextText && availableTokens > 200) {
89
+ const historyContext = this.buildTokenAwareHistory(sessionId, availableTokens, config);
90
+ if (historyContext.text) {
91
+ contextText = historyContext.text;
92
+ contextTokens = historyContext.tokens;
93
+ contextSource = 'history';
94
+ }
95
+ }
96
+
97
+ // Build final prompt
98
+ let prompt = userMessage;
99
+
100
+ if (contextText) {
101
+ if (isEngineBridge) {
102
+ prompt = `${contextText}\n\n[Switching from ${fromEngine} to ${toEngine}]\n\nUser message:\n${userMessage}`;
103
+ } else {
104
+ prompt = `${contextText}\n\nUser message:\n${userMessage}`;
105
+ }
106
+ }
107
+
108
+ console.log(`[ContextBridge] Built context: ${contextTokens} tokens from ${contextSource}, bridge: ${isEngineBridge}`);
109
+
110
+ return {
111
+ prompt,
112
+ isEngineBridge,
113
+ contextTokens,
114
+ contextSource,
115
+ totalTokens: contextTokens + userTokens
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Build token-aware history context
121
+ * @param {string} sessionId - Session ID
122
+ * @param {number} maxTokens - Token budget
123
+ * @param {Object} config - Engine config
124
+ * @returns {Object} { text, tokens, messageCount }
125
+ */
126
+ buildTokenAwareHistory(sessionId, maxTokens, config = {}) {
127
+ // Get more messages than we need, we'll trim
128
+ const messages = Message.getContextMessages(sessionId, 20);
129
+
130
+ if (messages.length === 0) {
131
+ return { text: '', tokens: 0, messageCount: 0 };
132
+ }
133
+
134
+ const lines = [];
135
+ let tokenCount = 0;
136
+ let includedCount = 0;
137
+
138
+ // Process from newest to oldest (we got them in chronological order, reverse)
139
+ for (let i = messages.length - 1; i >= 0; i--) {
140
+ const msg = messages[i];
141
+
142
+ // For code-focused engines, filter out non-code content
143
+ let content = msg.content;
144
+ if (config.codeOnly) {
145
+ content = this.extractCodeContent(content);
146
+ if (!content) continue; // Skip if no code
147
+ }
148
+
149
+ // Truncate long messages
150
+ if (content.length > 2000) {
151
+ content = content.substring(0, 2000) + '...';
152
+ }
153
+
154
+ const role = msg.role === 'user' ? 'User' : 'Assistant';
155
+ const engineTag = msg.engine ? ` [${msg.engine}]` : '';
156
+ const line = `${role}${engineTag}: ${content}`;
157
+
158
+ const lineTokens = this.estimateTokens(line);
159
+
160
+ // Check if adding this would exceed budget
161
+ if (tokenCount + lineTokens > maxTokens) {
162
+ break;
163
+ }
164
+
165
+ lines.unshift(line); // Add to beginning (chronological)
166
+ tokenCount += lineTokens;
167
+ includedCount++;
168
+ }
169
+
170
+ if (lines.length === 0) {
171
+ return { text: '', tokens: 0, messageCount: 0 };
172
+ }
173
+
174
+ const text = `[Context from recent messages]\n${lines.join('\n\n')}`;
175
+
176
+ return {
177
+ text,
178
+ tokens: tokenCount,
179
+ messageCount: includedCount
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Extract code blocks and technical content from text
185
+ * @param {string} text - Full text
186
+ * @returns {string} Code-focused content
187
+ */
188
+ extractCodeContent(text) {
189
+ if (!text) return '';
190
+
191
+ // Extract code blocks
192
+ const codeBlocks = [];
193
+ const codeBlockRegex = /```[\s\S]*?```/g;
194
+ let match;
195
+
196
+ while ((match = codeBlockRegex.exec(text)) !== null) {
197
+ codeBlocks.push(match[0]);
198
+ }
199
+
200
+ if (codeBlocks.length > 0) {
201
+ return codeBlocks.join('\n\n');
202
+ }
203
+
204
+ // If no code blocks, check if text contains technical content
205
+ const technicalPatterns = [
206
+ /function\s+\w+/,
207
+ /const\s+\w+\s*=/,
208
+ /class\s+\w+/,
209
+ /import\s+.*from/,
210
+ /require\s*\(/,
211
+ /\/\/|\/\*|\*\//,
212
+ /\.(js|ts|py|java|cpp|go|rs)\b/,
213
+ /npm|git|docker|curl/
214
+ ];
215
+
216
+ for (const pattern of technicalPatterns) {
217
+ if (pattern.test(text)) {
218
+ return text;
219
+ }
220
+ }
221
+
222
+ return ''; // Not code-relevant
223
+ }
224
+
225
+ /**
226
+ * Check if auto-summary should be triggered
227
+ * @param {string} sessionId - Session ID
228
+ * @param {boolean} isEngineBridge - Was this an engine switch
229
+ * @returns {boolean} Should generate summary
230
+ */
231
+ shouldTriggerSummary(sessionId, isEngineBridge = false) {
232
+ // Always trigger on engine bridge
233
+ if (isEngineBridge) return true;
234
+
235
+ const messageCount = Message.countByConversation(sessionId);
236
+
237
+ // Trigger every 10 messages after threshold
238
+ if (messageCount >= SUMMARY_TRIGGER_THRESHOLD && messageCount % 10 === 0) {
239
+ return true;
240
+ }
241
+
242
+ // Check if we have a stale summary (older than 20 messages)
243
+ const existingSummary = this.summaryGenerator.getSummary(sessionId);
244
+ if (!existingSummary && messageCount > SUMMARY_TRIGGER_THRESHOLD) {
245
+ return true;
246
+ }
247
+
248
+ return false;
249
+ }
250
+
251
+ /**
252
+ * Trigger summary generation (async, non-blocking)
253
+ * @param {string} sessionId - Session ID
254
+ * @param {string} logPrefix - Log prefix for debugging
255
+ */
256
+ triggerSummaryGeneration(sessionId, logPrefix = '[ContextBridge]') {
257
+ const messages = Message.getByConversation(sessionId, 40);
258
+
259
+ this.summaryGenerator.generateAndSave(sessionId, messages)
260
+ .then(summary => {
261
+ if (summary) {
262
+ console.log(`${logPrefix} Summary updated: ${summary.summary_short?.substring(0, 50)}...`);
263
+ }
264
+ })
265
+ .catch(err => {
266
+ console.warn(`${logPrefix} Summary generation failed:`, err.message);
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Get context stats for debugging
272
+ * @param {string} sessionId - Session ID
273
+ * @returns {Object} Stats
274
+ */
275
+ getContextStats(sessionId) {
276
+ const messageCount = Message.countByConversation(sessionId);
277
+ const lastEngine = Message.getLastEngine(sessionId);
278
+ const hasSummary = !!this.summaryGenerator.getSummary(sessionId);
279
+
280
+ return {
281
+ messageCount,
282
+ lastEngine,
283
+ hasSummary,
284
+ summaryThreshold: SUMMARY_TRIGGER_THRESHOLD
285
+ };
286
+ }
287
+ }
288
+
289
+ module.exports = new ContextBridge();
@@ -0,0 +1,398 @@
1
+ /**
2
+ * GeminiOutputParser - Parse Gemini CLI JSON stream output
3
+ *
4
+ * Parses `gemini -o stream-json` output format.
5
+ *
6
+ * JSON Event Types from Gemini CLI:
7
+ * - init: Session initialization { type: 'init', session_id, model }
8
+ * - message: Response content { type: 'message', role: 'assistant', content, delta: true }
9
+ * - tool_use: Tool invocation { type: 'tool_use', tool, input }
10
+ * - tool_result: Tool output { type: 'tool_result', tool, output, status }
11
+ * - result: Final stats { type: 'result', status, stats: { input_tokens, output_tokens } }
12
+ * - error: Error event { type: 'error', message }
13
+ *
14
+ * Emits normalized events for SSE streaming:
15
+ * - status: { type: 'status', category: 'tool'|'system', message, icon }
16
+ * - response_chunk: { type: 'response_chunk', text, isIncremental }
17
+ * - response_done: { type: 'response_done', fullText }
18
+ * - done: { type: 'done', usage, status }
19
+ * - error: { type: 'error', message }
20
+ *
21
+ * @version 0.4.0 - TRI CLI Support
22
+ */
23
+
24
+ class GeminiOutputParser {
25
+ constructor() {
26
+ this.buffer = '';
27
+ this.finalResponse = '';
28
+ this.usage = null;
29
+ this.sessionId = null;
30
+ this.model = null;
31
+ this.pendingTools = new Map();
32
+ }
33
+
34
+ /**
35
+ * Parse a chunk of stdout (may contain multiple JSON lines)
36
+ * @param {string} chunk - Raw stdout chunk from node-pty
37
+ * @returns {Array} Array of normalized event objects for SSE
38
+ */
39
+ parse(chunk) {
40
+ const events = [];
41
+
42
+ // Add chunk to buffer
43
+ this.buffer += chunk;
44
+
45
+ // Process complete lines
46
+ const lines = this.buffer.split('\n');
47
+ this.buffer = lines.pop() || ''; // Keep incomplete last line
48
+
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed) continue;
52
+
53
+ // Skip non-JSON lines (CLI status messages, retry messages, etc.)
54
+ if (!trimmed.startsWith('{')) {
55
+ // Log significant non-JSON messages
56
+ if (trimmed.includes('Attempt') || trimmed.includes('failed') || trimmed.includes('Error')) {
57
+ console.log('[GeminiOutputParser] CLI message:', trimmed.substring(0, 100));
58
+ }
59
+ continue;
60
+ }
61
+
62
+ try {
63
+ const json = JSON.parse(trimmed);
64
+ const lineEvents = this._parseJsonEvent(json);
65
+ events.push(...lineEvents);
66
+ } catch (e) {
67
+ console.warn('[GeminiOutputParser] JSON parse error:', e.message, '- Line:', trimmed.substring(0, 80));
68
+ }
69
+ }
70
+
71
+ return events;
72
+ }
73
+
74
+ /**
75
+ * Parse a single JSON event from Gemini CLI
76
+ * @param {Object} event - Parsed JSON object
77
+ * @returns {Array} Events to emit
78
+ */
79
+ _parseJsonEvent(event) {
80
+ const events = [];
81
+
82
+ switch (event.type) {
83
+ case 'init':
84
+ // Session initialization
85
+ this.sessionId = event.session_id;
86
+ this.model = event.model;
87
+ console.log('[GeminiOutputParser] Session initialized:', this.sessionId, 'Model:', this.model);
88
+ events.push({
89
+ type: 'status',
90
+ category: 'system',
91
+ message: 'Session initialized',
92
+ icon: '🚀',
93
+ sessionId: this.sessionId,
94
+ model: this.model,
95
+ timestamp: event.timestamp || new Date().toISOString(),
96
+ });
97
+ break;
98
+
99
+ case 'message':
100
+ if (event.role === 'assistant' || event.role === 'model') {
101
+ // Assistant response - streaming content
102
+ const content = event.content || '';
103
+ if (content) {
104
+ // Accumulate response
105
+ if (event.delta) {
106
+ // Delta mode - append to existing
107
+ this.finalResponse += content;
108
+ } else {
109
+ // Full replacement (rare)
110
+ this.finalResponse = content;
111
+ }
112
+
113
+ events.push({
114
+ type: 'response_chunk',
115
+ text: content,
116
+ isIncremental: !!event.delta,
117
+ });
118
+ }
119
+ }
120
+ break;
121
+
122
+ case 'tool_use':
123
+ // Tool being invoked
124
+ const toolEvent = this._formatToolUseEvent(event);
125
+ events.push(toolEvent);
126
+ this.pendingTools.set(event.tool_id || event.tool_use_id || event.tool_name, event);
127
+ break;
128
+
129
+ case 'tool_result':
130
+ // Tool execution completed
131
+ // Gemini CLI uses 'tool_name'
132
+ const toolName = event.tool_name || event.tool || 'Tool';
133
+ const success = event.status !== 'error' && event.status !== 'failure';
134
+
135
+ events.push({
136
+ type: 'status',
137
+ category: 'tool',
138
+ message: `${toolName}: ${success ? 'completed' : 'failed'}`,
139
+ icon: success ? '✅' : '❌',
140
+ toolOutput: this._truncateOutput(event.output || event.result),
141
+ timestamp: event.timestamp || new Date().toISOString(),
142
+ });
143
+
144
+ this.pendingTools.delete(event.tool_use_id || event.tool);
145
+ break;
146
+
147
+ case 'result':
148
+ // Final result with stats
149
+ this.usage = event.stats || null;
150
+ console.log('[GeminiOutputParser] Result received, status:', event.status, 'stats:', JSON.stringify(this.usage));
151
+
152
+ // Emit response_done
153
+ events.push({
154
+ type: 'response_done',
155
+ fullText: this.finalResponse,
156
+ });
157
+
158
+ // Emit done with usage stats
159
+ events.push({
160
+ type: 'done',
161
+ status: event.status,
162
+ usage: {
163
+ prompt_tokens: this.usage?.input_tokens || 0,
164
+ completion_tokens: this.usage?.output_tokens || 0,
165
+ total_tokens: (this.usage?.input_tokens || 0) + (this.usage?.output_tokens || 0),
166
+ },
167
+ duration_ms: this.usage?.duration_ms || 0,
168
+ tool_calls: this.usage?.tool_calls || 0,
169
+ sessionId: this.sessionId,
170
+ });
171
+ break;
172
+
173
+ case 'error':
174
+ console.error('[GeminiOutputParser] Error event:', event.message || event.error);
175
+ events.push({
176
+ type: 'error',
177
+ message: event.message || event.error || 'Unknown error',
178
+ });
179
+ break;
180
+
181
+ default:
182
+ // Unknown event type - log but don't fail
183
+ console.log('[GeminiOutputParser] Unknown event type:', event.type);
184
+ }
185
+
186
+ return events;
187
+ }
188
+
189
+ /**
190
+ * Format a tool_use event into a status event
191
+ */
192
+ _formatToolUseEvent(event) {
193
+ // Extract tool name from various possible fields
194
+ // Gemini CLI uses 'tool_name' and 'parameters'
195
+ const tool = event.tool_name || event.tool || event.name || event.function?.name || '';
196
+ const input = event.parameters || event.input || event.args || event.function?.arguments || {};
197
+ let message = tool || 'Tool';
198
+
199
+ // Debug: log raw event to understand structure
200
+ if (!tool) {
201
+ console.log('[GeminiOutputParser] Tool event without name:', JSON.stringify(event).substring(0, 200));
202
+ }
203
+
204
+ // Format message based on tool type
205
+ switch (tool) {
206
+ case 'shell':
207
+ case 'run_shell_command':
208
+ case 'execute_command':
209
+ message = `Shell: ${this._truncate(event.command || input.command || '', 60)}`;
210
+ break;
211
+ case 'read_file':
212
+ case 'read_many_files':
213
+ case 'read':
214
+ message = `Reading: ${this._truncate(event.path || input.path || input.file_path || '', 50)}`;
215
+ break;
216
+ case 'write_file':
217
+ case 'write':
218
+ message = `Writing: ${this._truncate(event.path || input.path || input.file_path || '', 50)}`;
219
+ break;
220
+ case 'edit_file':
221
+ case 'edit':
222
+ message = `Editing: ${this._truncate(event.path || input.path || input.file_path || '', 50)}`;
223
+ break;
224
+ case 'search_files':
225
+ case 'grep':
226
+ case 'find_files':
227
+ message = `Searching: ${this._truncate(event.pattern || input.pattern || input.query || '', 40)}`;
228
+ break;
229
+ case 'list_directory':
230
+ case 'list_dir':
231
+ case 'ls':
232
+ message = `Listing: ${this._truncate(event.path || input.path || input.dir_path || '.', 50)}`;
233
+ break;
234
+ case 'web_search':
235
+ case 'google_search':
236
+ case 'search':
237
+ message = `Web search: ${this._truncate(event.query || input.query || '', 40)}`;
238
+ break;
239
+ case 'web_fetch':
240
+ case 'fetch_url':
241
+ message = `Fetching: ${this._truncate(event.url || input.url || '', 50)}`;
242
+ break;
243
+ default:
244
+ // For unknown tools, try to create a meaningful message
245
+ if (tool) {
246
+ message = `${tool}: running...`;
247
+ } else {
248
+ // Try to extract any useful info from the event
249
+ const keys = Object.keys(event).filter(k => !['type', 'timestamp', 'tool_use_id'].includes(k));
250
+ if (keys.length > 0) {
251
+ message = `Tool: ${keys[0]}...`;
252
+ } else {
253
+ message = 'Tool: running...';
254
+ }
255
+ }
256
+ }
257
+
258
+ return {
259
+ type: 'status',
260
+ category: 'tool',
261
+ message,
262
+ icon: this._getToolIcon(tool),
263
+ timestamp: event.timestamp || new Date().toISOString(),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Get emoji icon for tool type
269
+ */
270
+ _getToolIcon(tool) {
271
+ const icons = {
272
+ // Shell commands
273
+ 'shell': '🔧',
274
+ 'run_shell_command': '🔧',
275
+ 'execute_command': '🔧',
276
+ // File operations
277
+ 'read_file': '📖',
278
+ 'read_many_files': '📚',
279
+ 'read': '📖',
280
+ 'write_file': '✍️',
281
+ 'write': '✍️',
282
+ 'edit_file': '📝',
283
+ 'edit': '📝',
284
+ // Search
285
+ 'search_files': '🔍',
286
+ 'grep': '🔍',
287
+ 'find_files': '🔍',
288
+ // Directory
289
+ 'list_directory': '🗂️',
290
+ 'list_dir': '🗂️',
291
+ 'ls': '🗂️',
292
+ // Web
293
+ 'web_search': '🔎',
294
+ 'google_search': '🔎',
295
+ 'search': '🔎',
296
+ 'web_fetch': '🌐',
297
+ 'fetch_url': '🌐',
298
+ };
299
+ return icons[tool] || '⚙️';
300
+ }
301
+
302
+ /**
303
+ * Truncate string to max length
304
+ */
305
+ _truncate(str, maxLen) {
306
+ if (!str) return '';
307
+ if (str.length <= maxLen) return str;
308
+ return str.substring(0, maxLen) + '...';
309
+ }
310
+
311
+ /**
312
+ * Truncate tool output (can be very long)
313
+ */
314
+ _truncateOutput(content) {
315
+ if (!content) return null;
316
+ const str = typeof content === 'string' ? content : JSON.stringify(content);
317
+ if (str.length > 500) {
318
+ return str.substring(0, 500) + '\n... (truncated)';
319
+ }
320
+ return str;
321
+ }
322
+
323
+ /**
324
+ * Filter out internal "thinking" patterns from model output
325
+ * Common in preview models that expose chain-of-thought
326
+ */
327
+ _filterThinkingPatterns(text) {
328
+ if (!text) return text;
329
+
330
+ const thinkingPatterns = [
331
+ /^Wait,?\s+.{0,200}$/gm,
332
+ /^Actually,?\s+.{0,200}$/gm,
333
+ /^Let me\s+.{0,150}$/gm,
334
+ /^I will\s+.{0,150}$/gm,
335
+ /^I should\s+.{0,150}$/gm,
336
+ /^I need to\s+.{0,150}$/gm,
337
+ /^Ready\.?\s*$/gm,
338
+ /^Okay\.?\s*$/gm,
339
+ ];
340
+
341
+ let filtered = text;
342
+ for (const pattern of thinkingPatterns) {
343
+ filtered = filtered.replace(pattern, '');
344
+ }
345
+
346
+ // Remove multiple consecutive newlines
347
+ filtered = filtered.replace(/\n{3,}/g, '\n\n');
348
+ return filtered.trim();
349
+ }
350
+
351
+ /**
352
+ * Get accumulated final response (filtered)
353
+ */
354
+ getFinalResponse() {
355
+ return this._filterThinkingPatterns(this.finalResponse);
356
+ }
357
+
358
+ /**
359
+ * Get raw unfiltered response (for debugging)
360
+ */
361
+ getRawResponse() {
362
+ return this.finalResponse;
363
+ }
364
+
365
+ /**
366
+ * Get usage statistics
367
+ */
368
+ getUsage() {
369
+ return this.usage;
370
+ }
371
+
372
+ /**
373
+ * Get session ID
374
+ */
375
+ getSessionId() {
376
+ return this.sessionId;
377
+ }
378
+
379
+ /**
380
+ * Get model name
381
+ */
382
+ getModel() {
383
+ return this.model;
384
+ }
385
+
386
+ /**
387
+ * Reset parser state for new request
388
+ */
389
+ reset() {
390
+ this.buffer = '';
391
+ this.finalResponse = '';
392
+ this.usage = null;
393
+ this.pendingTools.clear();
394
+ // Keep sessionId and model for session continuity
395
+ }
396
+ }
397
+
398
+ module.exports = GeminiOutputParser;