@mmmbuto/nexuscli 0.9.5 → 0.9.7-termux

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Qwen Route - /api/v1/qwen
3
+ *
4
+ * Send messages to Qwen Code CLI with SSE streaming.
5
+ */
6
+
7
+ const express = require('express');
8
+ const QwenWrapper = require('../services/qwen-wrapper');
9
+ const Message = require('../models/Message');
10
+ const { v4: uuidv4 } = require('uuid');
11
+ const sessionManager = require('../services/session-manager');
12
+ const contextBridge = require('../services/context-bridge');
13
+ const { resolveWorkspacePath } = require('../../utils/workspace');
14
+
15
+ const router = express.Router();
16
+ const qwenWrapper = new QwenWrapper();
17
+
18
+ function ensureConversation(conversationId, workspacePath) {
19
+ try {
20
+ const stmt = require('../db').prepare(`
21
+ INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at, metadata)
22
+ VALUES (?, ?, ?, ?, ?)
23
+ `);
24
+ const now = Date.now();
25
+ const metadata = workspacePath ? JSON.stringify({ workspace: workspacePath }) : null;
26
+ stmt.run(conversationId, 'New Chat', now, now, metadata);
27
+ } catch (err) {
28
+ console.warn('[Qwen] Failed to ensure conversation exists:', err.message);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * POST /api/v1/qwen
34
+ * Body:
35
+ * {
36
+ * conversationId?: string,
37
+ * message: string,
38
+ * model?: string,
39
+ * workspace?: string
40
+ * }
41
+ */
42
+ router.post('/', async (req, res) => {
43
+ try {
44
+ console.log('[Qwen] === NEW QWEN REQUEST ===');
45
+ console.log('[Qwen] Body:', JSON.stringify(req.body, null, 2));
46
+
47
+ const { conversationId, message, model = 'coder-model', workspace } = req.body;
48
+
49
+ if (!message) {
50
+ return res.status(400).json({ error: 'message required' });
51
+ }
52
+
53
+ const isAvailable = await qwenWrapper.isAvailable();
54
+ if (!isAvailable) {
55
+ return res.status(503).json({
56
+ error: 'Qwen CLI not available',
57
+ details: 'Please install Qwen CLI: npm install -g @mmmbuto/qwen-code-termux'
58
+ });
59
+ }
60
+
61
+ const workspacePath = resolveWorkspacePath(workspace, process.cwd());
62
+ if (workspace && workspacePath !== workspace) {
63
+ console.warn(`[Qwen] Workspace corrected: ${workspace} → ${workspacePath}`);
64
+ }
65
+
66
+ const frontendConversationId = conversationId || uuidv4();
67
+ ensureConversation(frontendConversationId, workspacePath);
68
+
69
+ const { sessionId, isNew: isNewSession } = await sessionManager.getOrCreateSession(
70
+ frontendConversationId,
71
+ 'qwen',
72
+ workspacePath
73
+ );
74
+
75
+ const nativeSessionId = isNewSession ? null : sessionManager.getNativeThreadId(sessionId);
76
+
77
+ // SSE headers
78
+ res.setHeader('Content-Type', 'text/event-stream');
79
+ res.setHeader('Cache-Control', 'no-cache');
80
+ res.setHeader('Connection', 'keep-alive');
81
+ res.setHeader('X-Accel-Buffering', 'no');
82
+
83
+ res.write(`data: ${JSON.stringify({
84
+ type: 'message_start',
85
+ messageId: `user-${Date.now()}`,
86
+ sessionId,
87
+ conversationId: frontendConversationId,
88
+ engine: 'qwen'
89
+ })}\n\n`);
90
+
91
+ const lastEngine = Message.getLastEngine(frontendConversationId);
92
+ const isEngineBridge = lastEngine && lastEngine !== 'qwen';
93
+
94
+ let promptToSend = message;
95
+
96
+ if (isEngineBridge) {
97
+ const contextResult = await contextBridge.buildContext({
98
+ conversationId: frontendConversationId,
99
+ sessionId,
100
+ fromEngine: lastEngine,
101
+ toEngine: 'qwen',
102
+ userMessage: message
103
+ });
104
+ promptToSend = contextResult.prompt;
105
+
106
+ res.write(`data: ${JSON.stringify({
107
+ type: 'status',
108
+ category: 'system',
109
+ message: `Context bridged from ${lastEngine}`,
110
+ icon: '🔄'
111
+ })}\n\n`);
112
+ } else if (nativeSessionId) {
113
+ console.log(`[Qwen] Native resume: qwen --resume ${nativeSessionId}`);
114
+ }
115
+
116
+ // Save user message
117
+ try {
118
+ Message.create(
119
+ frontendConversationId,
120
+ 'user',
121
+ message,
122
+ { workspace: workspacePath, model },
123
+ Date.now(),
124
+ 'qwen'
125
+ );
126
+ sessionManager.bumpSessionActivity(sessionId, 1);
127
+ } catch (dbErr) {
128
+ console.warn('[Qwen] Failed to save user message:', dbErr.message);
129
+ }
130
+
131
+ if (isNewSession) {
132
+ const title = sessionManager.extractTitle(message);
133
+ sessionManager.updateSessionTitle(sessionId, title);
134
+ }
135
+
136
+ const result = await qwenWrapper.sendMessage({
137
+ prompt: promptToSend,
138
+ threadId: nativeSessionId,
139
+ model,
140
+ workspacePath,
141
+ processId: sessionId,
142
+ onStatus: (event) => {
143
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
144
+ }
145
+ });
146
+
147
+ if (result.sessionId) {
148
+ sessionManager.setNativeThreadId(sessionId, result.sessionId);
149
+ }
150
+
151
+ try {
152
+ Message.create(
153
+ frontendConversationId,
154
+ 'assistant',
155
+ result.text,
156
+ { model, usage: result.usage },
157
+ Date.now(),
158
+ 'qwen'
159
+ );
160
+ sessionManager.bumpSessionActivity(sessionId, 1);
161
+ } catch (dbErr) {
162
+ console.warn('[Qwen] Failed to save assistant message:', dbErr.message);
163
+ }
164
+
165
+ if (contextBridge.shouldTriggerSummary(frontendConversationId, isEngineBridge)) {
166
+ contextBridge.triggerSummaryGeneration(frontendConversationId, '[Qwen]');
167
+ }
168
+
169
+ res.write(`data: ${JSON.stringify({
170
+ type: 'message_done',
171
+ content: result.text,
172
+ usage: result.usage,
173
+ sessionId,
174
+ conversationId: frontendConversationId,
175
+ engine: 'qwen',
176
+ model
177
+ })}\n\n`);
178
+
179
+ res.end();
180
+ console.log('[Qwen] === REQUEST COMPLETE ===');
181
+ } catch (error) {
182
+ console.error('[Qwen] Error:', error);
183
+ if (!res.headersSent) {
184
+ res.status(500).json({ error: error.message });
185
+ } else {
186
+ res.write(`data: ${JSON.stringify({
187
+ type: 'error',
188
+ error: error.message
189
+ })}\n\n`);
190
+ res.end();
191
+ }
192
+ }
193
+ });
194
+
195
+ /**
196
+ * GET /api/v1/qwen/status
197
+ */
198
+ router.get('/status', async (_req, res) => {
199
+ try {
200
+ const isAvailable = await qwenWrapper.isAvailable();
201
+ res.json({
202
+ available: isAvailable,
203
+ defaultModel: qwenWrapper.getDefaultModel(),
204
+ models: qwenWrapper.getAvailableModels()
205
+ });
206
+ } catch (error) {
207
+ console.error('[Qwen] Status check error:', error);
208
+ res.status(500).json({ error: error.message });
209
+ }
210
+ });
211
+
212
+ /**
213
+ * GET /api/v1/qwen/models
214
+ */
215
+ router.get('/models', (_req, res) => {
216
+ res.json({
217
+ models: qwenWrapper.getAvailableModels(),
218
+ default: qwenWrapper.getDefaultModel()
219
+ });
220
+ });
221
+
222
+ /**
223
+ * POST /api/v1/qwen/interrupt
224
+ */
225
+ router.post('/interrupt', async (req, res) => {
226
+ try {
227
+ const { sessionId } = req.body;
228
+ if (!sessionId) {
229
+ return res.status(400).json({ error: 'sessionId required' });
230
+ }
231
+
232
+ const result = qwenWrapper.interrupt(sessionId);
233
+ res.json(result);
234
+ } catch (error) {
235
+ console.error('[Qwen] Interrupt error:', error);
236
+ res.status(500).json({ error: error.message });
237
+ }
238
+ });
239
+
240
+ module.exports = router;
@@ -15,11 +15,12 @@ const SESSION_DIRS = {
15
15
  claude: path.join(process.env.HOME || '', '.claude', 'projects'),
16
16
  codex: path.join(process.env.HOME || '', '.codex', 'sessions'),
17
17
  gemini: path.join(process.env.HOME || '', '.gemini', 'sessions'),
18
+ qwen: path.join(process.env.HOME || '', '.qwen', 'projects'),
18
19
  };
19
20
 
20
21
  /**
21
22
  * POST /api/v1/sessions/import
22
- * Importa tutte le sessioni native (Claude/Codex/Gemini) nel DB
23
+ * Importa tutte le sessioni native (Claude/Codex/Gemini/Qwen) nel DB
23
24
  */
24
25
  router.post('/import', async (_req, res) => {
25
26
  try {
@@ -280,6 +281,11 @@ function pathToSlug(workspacePath) {
280
281
  return workspacePath.replace(/[\/\.]/g, '-');
281
282
  }
282
283
 
284
+ function qwenProjectDir(workspacePath) {
285
+ if (!workspacePath) return 'default';
286
+ return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
287
+ }
288
+
283
289
  /**
284
290
  * Helper: Get the filesystem path for a session file
285
291
  */
@@ -287,6 +293,7 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
287
293
  const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
288
294
  : engine?.toLowerCase().includes('codex') ? 'codex'
289
295
  : engine?.toLowerCase().includes('gemini') ? 'gemini'
296
+ : engine?.toLowerCase().includes('qwen') ? 'qwen'
290
297
  : 'claude';
291
298
 
292
299
  switch (normalizedEngine) {
@@ -303,6 +310,11 @@ function getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
303
310
  return findCodexSessionFile(baseDir, nativeId);
304
311
  case 'gemini':
305
312
  return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
313
+ case 'qwen': {
314
+ const project = qwenProjectDir(workspacePath);
315
+ const fileId = sessionPath || sessionId;
316
+ return path.join(SESSION_DIRS.qwen, project, 'chats', `${fileId}.jsonl`);
317
+ }
306
318
  default:
307
319
  return null;
308
320
  }
@@ -22,6 +22,7 @@ const jobsRouter = require('./routes/jobs');
22
22
  const chatRouter = require('./routes/chat');
23
23
  const codexRouter = require('./routes/codex');
24
24
  const geminiRouter = require('./routes/gemini');
25
+ const qwenRouter = require('./routes/qwen');
25
26
  const modelsRouter = require('./routes/models');
26
27
  const workspaceRouter = require('./routes/workspace');
27
28
  const workspacesRouter = require('./routes/workspaces');
@@ -51,7 +52,7 @@ app.get('/health', (req, res) => {
51
52
  status: 'ok',
52
53
  service: 'nexuscli-backend',
53
54
  version: pkg.version,
54
- engines: ['claude', 'codex', 'gemini'],
55
+ engines: ['claude', 'codex', 'gemini', 'qwen'],
55
56
  port: PORT,
56
57
  timestamp: new Date().toISOString()
57
58
  });
@@ -78,6 +79,7 @@ app.use('/api/v1/jobs', authMiddleware, jobsRouter);
78
79
  app.use('/api/v1/chat', authMiddleware, chatRateLimiter, chatRouter);
79
80
  app.use('/api/v1/codex', authMiddleware, chatRateLimiter, codexRouter);
80
81
  app.use('/api/v1/gemini', authMiddleware, chatRateLimiter, geminiRouter);
82
+ app.use('/api/v1/qwen', authMiddleware, chatRateLimiter, qwenRouter);
81
83
  app.use('/api/v1/upload', authMiddleware, uploadRouter); // File upload
82
84
 
83
85
  // STT routes
@@ -89,13 +91,14 @@ app.get('/', (req, res) => {
89
91
  res.json({
90
92
  service: 'NexusCLI Backend',
91
93
  version: pkg.version,
92
- engines: ['claude', 'codex', 'gemini'],
94
+ engines: ['claude', 'codex', 'gemini', 'qwen'],
93
95
  endpoints: {
94
96
  health: '/health',
95
97
  models: '/api/v1/models',
96
98
  chat: '/api/v1/chat (Claude)',
97
99
  codex: '/api/v1/codex (OpenAI)',
98
100
  gemini: '/api/v1/gemini (Google)',
101
+ qwen: '/api/v1/qwen (Qwen)',
99
102
  conversations: '/api/v1/conversations',
100
103
  jobs: '/api/v1/jobs'
101
104
  }
@@ -154,10 +157,10 @@ async function start() {
154
157
  // Continue anyway - database will sync on first workspace mount
155
158
  }
156
159
 
157
- // Import native sessions from all engines (Claude/Codex/Gemini)
160
+ // Import native sessions from all engines (Claude/Codex/Gemini/Qwen)
158
161
  try {
159
162
  const imported = sessionImporter.importAll();
160
- console.log(`[Startup] Imported sessions → Claude:${imported.claude} Codex:${imported.codex} Gemini:${imported.gemini}`);
163
+ console.log(`[Startup] Imported sessions → Claude:${imported.claude} Codex:${imported.codex} Gemini:${imported.gemini} Qwen:${imported.qwen}`);
161
164
  } catch (error) {
162
165
  console.error('[Startup] ⚠️ Session import failed:', error.message);
163
166
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CliLoader - Unified message loader for TRI CLI (Claude/Codex/Gemini)
2
+ * CliLoader - Unified message loader for TRI CLI (Claude/Codex/Gemini/Qwen)
3
3
  *
4
4
  * Loads messages on-demand from CLI history files (lazy loading).
5
5
  * Filesystem is the source of truth - no DB caching of messages.
@@ -8,6 +8,7 @@
8
8
  * - Claude: ~/.claude/projects/<workspace-slug>/<sessionId>.jsonl
9
9
  * - Codex: ~/.codex/sessions/<sessionId>.jsonl (if available)
10
10
  * - Gemini: ~/.gemini/sessions/<sessionId>.jsonl (if available)
11
+ * - Qwen : ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
11
12
  *
12
13
  * @version 0.4.0 - TRI CLI Support
13
14
  */
@@ -23,6 +24,7 @@ const ENGINE_PATHS = {
23
24
  claude: path.join(process.env.HOME || '', '.claude'),
24
25
  codex: path.join(process.env.HOME || '', '.codex'),
25
26
  gemini: path.join(process.env.HOME || '', '.gemini'),
27
+ qwen: path.join(process.env.HOME || '', '.qwen'),
26
28
  };
27
29
 
28
30
  class CliLoader {
@@ -30,15 +32,16 @@ class CliLoader {
30
32
  this.claudePath = ENGINE_PATHS.claude;
31
33
  this.codexPath = ENGINE_PATHS.codex;
32
34
  this.geminiPath = ENGINE_PATHS.gemini;
35
+ this.qwenPath = ENGINE_PATHS.qwen;
33
36
  }
34
37
 
35
38
  /**
36
39
  * Load messages from CLI history by session.
37
- * Supports all three engines: Claude, Codex, Gemini.
40
+ * Supports all engines: Claude, Codex, Gemini, Qwen.
38
41
  *
39
42
  * @param {Object} params
40
43
  * @param {string} params.sessionId - Session UUID
41
- * @param {string} params.engine - 'claude'|'claude-code'|'codex'|'gemini'
44
+ * @param {string} params.engine - 'claude'|'claude-code'|'codex'|'gemini'|'qwen'
42
45
  * @param {string} params.workspacePath - Workspace directory (required for Claude)
43
46
  * @param {number} [params.limit=30] - Max messages to return
44
47
  * @param {number} [params.before] - Timestamp cursor for pagination (ms)
@@ -76,6 +79,9 @@ class CliLoader {
76
79
  case 'gemini':
77
80
  result = await this.loadGeminiMessages({ sessionId, nativeId, limit, before, mode });
78
81
  break;
82
+ case 'qwen':
83
+ result = await this.loadQwenMessages({ sessionId, nativeId, workspacePath, limit, before, mode });
84
+ break;
79
85
 
80
86
  default:
81
87
  throw new Error(`Unsupported engine: ${engine}`);
@@ -94,6 +100,7 @@ class CliLoader {
94
100
  if (lower.includes('claude')) return 'claude';
95
101
  if (lower.includes('codex') || lower.includes('openai')) return 'codex';
96
102
  if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
103
+ if (lower.includes('qwen')) return 'qwen';
97
104
  return lower;
98
105
  }
99
106
 
@@ -108,6 +115,14 @@ class CliLoader {
108
115
  return workspacePath.replace(/[\/\.]/g, '-');
109
116
  }
110
117
 
118
+ /**
119
+ * Convert workspace path to Qwen project dir (matches Qwen Storage.sanitizeCwd)
120
+ */
121
+ qwenPathToProject(workspacePath) {
122
+ if (!workspacePath) return 'default';
123
+ return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
124
+ }
125
+
111
126
  // ============================================================
112
127
  // CLAUDE - Load from ~/.claude/projects/<slug>/<sessionId>.jsonl
113
128
  // ============================================================
@@ -368,6 +383,67 @@ class CliLoader {
368
383
  return this._paginateMessages(messages, limit, before, mode);
369
384
  }
370
385
 
386
+ // ============================================================
387
+ // QWEN - Load from ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
388
+ // ============================================================
389
+
390
+ async loadQwenMessages({ sessionId, nativeId, workspacePath, limit, before, mode }) {
391
+ if (!workspacePath) {
392
+ console.warn('[CliLoader] No workspacePath for Qwen, using cwd');
393
+ workspacePath = process.cwd();
394
+ }
395
+
396
+ const project = this.qwenPathToProject(workspacePath);
397
+ const fileId = nativeId || sessionId;
398
+ const sessionFile = path.join(this.qwenPath, 'projects', project, 'chats', `${fileId}.jsonl`);
399
+
400
+ if (!fs.existsSync(sessionFile)) {
401
+ console.log(`[CliLoader] Qwen session file not found: ${sessionFile}`);
402
+ return this._emptyResult();
403
+ }
404
+
405
+ const rawMessages = await this._parseJsonlFile(sessionFile);
406
+
407
+ const messages = rawMessages
408
+ .filter(entry => entry.type === 'user' || entry.type === 'assistant')
409
+ .map(entry => this._normalizeQwenEntry(entry));
410
+
411
+ return this._paginateMessages(messages, limit, before, mode);
412
+ }
413
+
414
+ /**
415
+ * Normalize Qwen session entry to message shape
416
+ */
417
+ _normalizeQwenEntry(entry) {
418
+ const role = entry.type || 'assistant';
419
+ const created_at = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
420
+
421
+ let content = '';
422
+ const parts = entry.message?.parts;
423
+ if (Array.isArray(parts)) {
424
+ content = parts
425
+ .filter(p => p && p.text)
426
+ .map(p => p.text)
427
+ .join('\\n');
428
+ } else if (typeof entry.message?.content === 'string') {
429
+ content = entry.message.content;
430
+ } else if (entry.text) {
431
+ content = entry.text;
432
+ }
433
+
434
+ return {
435
+ id: entry.uuid || `qwen-${created_at}`,
436
+ role,
437
+ content,
438
+ engine: 'qwen',
439
+ created_at,
440
+ metadata: {
441
+ model: entry.model,
442
+ usage: entry.usageMetadata
443
+ }
444
+ };
445
+ }
446
+
371
447
  /**
372
448
  * Normalize Gemini session entry to message shape
373
449
  */
@@ -500,6 +576,10 @@ class CliLoader {
500
576
 
501
577
  case 'gemini':
502
578
  return path.join(this.geminiPath, 'sessions', `${sessionId}.jsonl`);
579
+ case 'qwen': {
580
+ const project = this.qwenPathToProject(workspacePath);
581
+ return path.join(this.qwenPath, 'projects', project, 'chats', `${sessionId}.jsonl`);
582
+ }
503
583
 
504
584
  default:
505
585
  return null;
@@ -21,7 +21,8 @@ const ENGINE_LIMITS = {
21
21
  'claude': { maxTokens: 4000, preferSummary: true },
22
22
  'codex': { maxTokens: 3000, preferSummary: true, codeOnly: true },
23
23
  'deepseek': { maxTokens: 3000, preferSummary: true },
24
- 'gemini': { maxTokens: 6000, preferSummary: false } // Gemini has large context
24
+ 'gemini': { maxTokens: 6000, preferSummary: false }, // Gemini has large context
25
+ 'qwen': { maxTokens: 6000, preferSummary: false } // Qwen Coder large context
25
26
  };
26
27
 
27
28
  class ContextBridge {
@@ -152,7 +153,8 @@ class ContextBridge {
152
153
  'claude': 'Claude Code (Anthropic)',
153
154
  'codex': 'Codex (OpenAI)',
154
155
  'gemini': 'Gemini (Google)',
155
- 'deepseek': 'DeepSeek'
156
+ 'deepseek': 'DeepSeek',
157
+ 'qwen': 'Qwen Code (Alibaba)'
156
158
  };
157
159
 
158
160
  const fromName = engineNames[fromEngine] || fromEngine;