@mmmbuto/nexuscli 0.9.5 → 0.9.7-001-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,289 @@
1
+ /**
2
+ * QwenOutputParser - Parse Qwen CLI stream-json output
3
+ *
4
+ * Qwen Code stream-json emits JSONL lines with message envelopes:
5
+ * - system (subtype: init)
6
+ * - assistant (message.content blocks)
7
+ * - user (tool_result blocks)
8
+ * - stream_event (partial deltas when enabled)
9
+ * - result (usage + status)
10
+ *
11
+ * Emits normalized events for SSE streaming:
12
+ * - status: { type: 'status', category: 'tool'|'system', message, icon }
13
+ * - response_chunk: { type: 'response_chunk', text, isIncremental }
14
+ * - response_done: { type: 'response_done', fullText }
15
+ * - done: { type: 'done', usage, status }
16
+ * - error: { type: 'error', message }
17
+ */
18
+
19
+ class QwenOutputParser {
20
+ constructor() {
21
+ this.buffer = '';
22
+ this.finalResponse = '';
23
+ this.usage = null;
24
+ this.sessionId = null;
25
+ this.model = null;
26
+ this.receivedPartial = false;
27
+ }
28
+
29
+ /**
30
+ * Parse stdout chunk (may contain multiple JSON lines)
31
+ * @param {string} chunk
32
+ * @returns {Array}
33
+ */
34
+ parse(chunk) {
35
+ const events = [];
36
+
37
+ this.buffer += chunk;
38
+ const lines = this.buffer.split('\n');
39
+ this.buffer = lines.pop() || '';
40
+
41
+ for (const line of lines) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed) continue;
44
+
45
+ if (!trimmed.startsWith('{')) {
46
+ continue;
47
+ }
48
+
49
+ try {
50
+ const json = JSON.parse(trimmed);
51
+ const lineEvents = this._parseJsonEvent(json);
52
+ events.push(...lineEvents);
53
+ } catch (e) {
54
+ console.warn('[QwenOutputParser] JSON parse error:', e.message, '- Line:', trimmed.substring(0, 80));
55
+ }
56
+ }
57
+
58
+ return events;
59
+ }
60
+
61
+ _parseJsonEvent(event) {
62
+ const events = [];
63
+
64
+ switch (event.type) {
65
+ case 'system': {
66
+ if (event.subtype === 'init') {
67
+ this.sessionId = event.session_id || event.sessionId || this.sessionId;
68
+ this.model = event.model || event.data?.model || this.model;
69
+ events.push({
70
+ type: 'status',
71
+ category: 'system',
72
+ message: 'Session initialized',
73
+ icon: '🚀',
74
+ sessionId: this.sessionId,
75
+ model: this.model,
76
+ timestamp: event.timestamp || new Date().toISOString(),
77
+ });
78
+ }
79
+ break;
80
+ }
81
+
82
+ case 'assistant': {
83
+ const contentBlocks = event.message?.content;
84
+ const text = this._extractText(contentBlocks);
85
+ if (text) {
86
+ if (!this.receivedPartial) {
87
+ this.finalResponse += text;
88
+ events.push({
89
+ type: 'response_chunk',
90
+ text,
91
+ isIncremental: false,
92
+ });
93
+ } else if (!this.finalResponse) {
94
+ // Fallback if partials were emitted but response was empty
95
+ this.finalResponse = text;
96
+ }
97
+ }
98
+ this._emitToolUseFromBlocks(contentBlocks, events);
99
+ break;
100
+ }
101
+
102
+ case 'user': {
103
+ const contentBlocks = event.message?.content;
104
+ this._emitToolResultFromBlocks(contentBlocks, events);
105
+ break;
106
+ }
107
+
108
+ case 'stream_event': {
109
+ const stream = event.event || {};
110
+ if (stream.type === 'content_block_delta' && stream.delta?.type === 'text_delta') {
111
+ const text = stream.delta.text || '';
112
+ if (text) {
113
+ this.receivedPartial = true;
114
+ this.finalResponse += text;
115
+ events.push({
116
+ type: 'response_chunk',
117
+ text,
118
+ isIncremental: true,
119
+ });
120
+ }
121
+ }
122
+ if (stream.type === 'content_block_start' && stream.content_block?.type === 'tool_use') {
123
+ events.push(this._formatToolUseEvent(stream.content_block));
124
+ }
125
+ break;
126
+ }
127
+
128
+ case 'result': {
129
+ if (event.is_error) {
130
+ const message = event.error?.message || event.error || 'Unknown error';
131
+ events.push({ type: 'error', message });
132
+ break;
133
+ }
134
+
135
+ this.usage = event.usage || null;
136
+ const fullText = this.finalResponse || event.result || '';
137
+
138
+ events.push({
139
+ type: 'response_done',
140
+ fullText,
141
+ });
142
+
143
+ const promptTokens = this.usage?.input_tokens || 0;
144
+ const completionTokens = this.usage?.output_tokens || 0;
145
+ const totalTokens = this.usage?.total_tokens || (promptTokens + completionTokens);
146
+
147
+ events.push({
148
+ type: 'done',
149
+ status: 'success',
150
+ usage: {
151
+ prompt_tokens: promptTokens,
152
+ completion_tokens: completionTokens,
153
+ total_tokens: totalTokens,
154
+ },
155
+ duration_ms: event.duration_ms || 0,
156
+ sessionId: this.sessionId,
157
+ });
158
+ break;
159
+ }
160
+
161
+ default:
162
+ // Ignore other event types
163
+ break;
164
+ }
165
+
166
+ return events;
167
+ }
168
+
169
+ _extractText(contentBlocks) {
170
+ if (!contentBlocks) return '';
171
+ if (typeof contentBlocks === 'string') return contentBlocks;
172
+ if (!Array.isArray(contentBlocks)) return '';
173
+
174
+ return contentBlocks
175
+ .filter((block) => block?.type === 'text' && block.text)
176
+ .map((block) => block.text)
177
+ .join('');
178
+ }
179
+
180
+ _emitToolUseFromBlocks(contentBlocks, events) {
181
+ if (!Array.isArray(contentBlocks)) return;
182
+ for (const block of contentBlocks) {
183
+ if (block?.type === 'tool_use') {
184
+ events.push(this._formatToolUseEvent(block));
185
+ }
186
+ }
187
+ }
188
+
189
+ _emitToolResultFromBlocks(contentBlocks, events) {
190
+ if (!Array.isArray(contentBlocks)) return;
191
+ for (const block of contentBlocks) {
192
+ if (block?.type === 'tool_result') {
193
+ const success = !block.is_error;
194
+ events.push({
195
+ type: 'status',
196
+ category: 'tool',
197
+ message: success ? 'Tool completed' : 'Tool failed',
198
+ icon: success ? '✅' : '❌',
199
+ toolOutput: this._truncateOutput(block.content),
200
+ timestamp: new Date().toISOString(),
201
+ });
202
+ }
203
+ }
204
+ }
205
+
206
+ _formatToolUseEvent(block) {
207
+ const tool = block?.name || block?.tool || block?.function?.name || 'Tool';
208
+ const input = block?.input || block?.parameters || block?.args || block?.function?.arguments || {};
209
+ let message = tool;
210
+
211
+ switch (tool) {
212
+ case 'shell':
213
+ case 'run_shell_command':
214
+ case 'execute_command':
215
+ message = `Shell: ${this._truncate(block.command || input.command || '', 60)}`;
216
+ break;
217
+ case 'read_file':
218
+ case 'read_many_files':
219
+ case 'read':
220
+ message = `Reading: ${this._truncate(block.path || input.path || input.file_path || '', 50)}`;
221
+ break;
222
+ case 'write_file':
223
+ case 'write':
224
+ message = `Writing: ${this._truncate(block.path || input.path || input.file_path || '', 50)}`;
225
+ break;
226
+ case 'edit_file':
227
+ case 'edit':
228
+ message = `Editing: ${this._truncate(block.path || input.path || input.file_path || '', 50)}`;
229
+ break;
230
+ case 'search_files':
231
+ case 'grep':
232
+ case 'find_files':
233
+ message = `Searching: ${this._truncate(block.pattern || input.pattern || input.query || '', 40)}`;
234
+ break;
235
+ case 'list_directory':
236
+ case 'list_dir':
237
+ case 'ls':
238
+ message = `Listing: ${this._truncate(block.path || input.path || input.dir_path || '.', 50)}`;
239
+ break;
240
+ case 'web_search':
241
+ case 'google_search':
242
+ case 'search':
243
+ message = `Web search: ${this._truncate(block.query || input.query || '', 40)}`;
244
+ break;
245
+ case 'web_fetch':
246
+ case 'fetch_url':
247
+ message = `Fetch: ${this._truncate(block.url || input.url || '', 60)}`;
248
+ break;
249
+ default:
250
+ message = `Tool: ${tool}`;
251
+ break;
252
+ }
253
+
254
+ return {
255
+ type: 'status',
256
+ category: 'tool',
257
+ message,
258
+ icon: '🛠️',
259
+ };
260
+ }
261
+
262
+ _truncate(text, maxLen = 60) {
263
+ if (!text) return '';
264
+ const str = String(text);
265
+ if (str.length <= maxLen) return str;
266
+ return str.substring(0, maxLen) + '...';
267
+ }
268
+
269
+ _truncateOutput(output, maxLen = 200) {
270
+ if (!output) return '';
271
+ const text = typeof output === 'string' ? output : JSON.stringify(output);
272
+ if (text.length <= maxLen) return text;
273
+ return text.substring(0, maxLen) + '...';
274
+ }
275
+
276
+ getFinalResponse() {
277
+ return this.finalResponse || '';
278
+ }
279
+
280
+ getUsage() {
281
+ return this.usage;
282
+ }
283
+
284
+ getSessionId() {
285
+ return this.sessionId;
286
+ }
287
+ }
288
+
289
+ module.exports = QwenOutputParser;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * QwenWrapper - Wrapper for Qwen Code CLI (qwen command)
3
+ *
4
+ * Executes Qwen CLI with PTY adapter for Termux.
5
+ * Uses stream-json output for structured parsing.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const pty = require('../lib/pty-adapter');
11
+ const QwenOutputParser = require('./qwen-output-parser');
12
+ const BaseCliWrapper = require('./base-cli-wrapper');
13
+
14
+ const DEFAULT_MODEL = 'coder-model';
15
+ const CLI_TIMEOUT_MS = 600000; // 10 minutes
16
+
17
+ const DANGEROUS_PATTERNS = [
18
+ /pkill\s+(-\d+\s+)?node/i,
19
+ /killall\s+(-\d+\s+)?node/i,
20
+ /kill\s+-9/i,
21
+ /rm\s+-rf\s+[\/~]/i,
22
+ /shutdown/i,
23
+ /reboot/i,
24
+ /systemctl\s+(stop|restart|disable)/i,
25
+ /service\s+\w+\s+(stop|restart)/i,
26
+ />\s*\/dev\/sd/i,
27
+ /mkfs/i,
28
+ /dd\s+if=.*of=\/dev/i,
29
+ ];
30
+
31
+ class QwenWrapper extends BaseCliWrapper {
32
+ constructor(options = {}) {
33
+ super();
34
+ this.qwenPath = this._resolveQwenPath(options.qwenPath);
35
+ this.workspaceDir = options.workspaceDir || process.cwd();
36
+
37
+ console.log(`[QwenWrapper] Initialized with binary: ${this.qwenPath}`);
38
+ }
39
+
40
+ _isDangerousCommand(command) {
41
+ if (!command) return false;
42
+ for (const pattern of DANGEROUS_PATTERNS) {
43
+ if (pattern.test(command)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ _resolveQwenPath(overridePath) {
49
+ if (overridePath && fs.existsSync(overridePath)) return overridePath;
50
+
51
+ const candidates = [
52
+ path.join(process.env.HOME || '', '.local/bin/qwen'),
53
+ path.join(process.env.PREFIX || '', 'bin/qwen'),
54
+ '/usr/local/bin/qwen',
55
+ '/usr/bin/qwen',
56
+ 'qwen',
57
+ ];
58
+
59
+ for (const candidate of candidates) {
60
+ try {
61
+ if (candidate === 'qwen' || fs.existsSync(candidate)) {
62
+ return candidate;
63
+ }
64
+ } catch (_) {
65
+ // ignore
66
+ }
67
+ }
68
+
69
+ return 'qwen';
70
+ }
71
+
72
+ /**
73
+ * Send a message to Qwen CLI
74
+ * @param {Object} params
75
+ * @param {string} params.prompt
76
+ * @param {string} params.threadId - Native Qwen session ID for resume
77
+ * @param {string} params.model
78
+ * @param {string} params.workspacePath
79
+ * @param {Function} params.onStatus
80
+ */
81
+ async sendMessage({
82
+ prompt,
83
+ threadId,
84
+ model = DEFAULT_MODEL,
85
+ workspacePath,
86
+ includeDirectories = [],
87
+ onStatus,
88
+ processId: processIdOverride
89
+ }) {
90
+ return new Promise((resolve, reject) => {
91
+ const parser = new QwenOutputParser();
92
+ let promiseSettled = false;
93
+
94
+ const cwd = workspacePath || this.workspaceDir;
95
+
96
+ const args = [
97
+ '-y',
98
+ '-m', model,
99
+ '-o', 'stream-json',
100
+ '--include-partial-messages',
101
+ ];
102
+
103
+ if (includeDirectories && includeDirectories.length > 0) {
104
+ includeDirectories.forEach((dir) => {
105
+ if (dir) {
106
+ args.push('--include-directories', dir);
107
+ }
108
+ });
109
+ }
110
+
111
+ if (threadId) {
112
+ args.push('--resume', threadId);
113
+ }
114
+
115
+ args.push(prompt);
116
+
117
+ console.log(`[QwenWrapper] Model: ${model}`);
118
+ console.log(`[QwenWrapper] ThreadId: ${threadId || '(new session)'}`);
119
+ console.log(`[QwenWrapper] CWD: ${cwd}`);
120
+ console.log(`[QwenWrapper] Prompt length: ${prompt.length}`);
121
+ if (includeDirectories && includeDirectories.length > 0) {
122
+ console.log(`[QwenWrapper] Include dirs: ${includeDirectories.join(', ')}`);
123
+ }
124
+
125
+ let ptyProcess;
126
+ try {
127
+ ptyProcess = pty.spawn(this.qwenPath, args, {
128
+ name: 'xterm-color',
129
+ cols: 120,
130
+ rows: 40,
131
+ cwd,
132
+ env: {
133
+ ...process.env,
134
+ TERM: 'xterm-256color',
135
+ }
136
+ });
137
+ } catch (spawnError) {
138
+ return reject(new Error(`Failed to spawn Qwen CLI: ${spawnError.message}`));
139
+ }
140
+
141
+ const processId = processIdOverride || threadId || `qwen-${Date.now()}`;
142
+ this.registerProcess(processId, ptyProcess, 'pty');
143
+
144
+ let stdout = '';
145
+
146
+ ptyProcess.onData((data) => {
147
+ stdout += data;
148
+
149
+ const cleanData = data
150
+ .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
151
+ .replace(/\x1B\[\?[0-9;]*[a-zA-Z]/g, '')
152
+ .replace(/\r/g, '');
153
+
154
+ if (onStatus && cleanData.trim()) {
155
+ try {
156
+ const events = parser.parse(cleanData);
157
+ events.forEach(event => {
158
+ if (event.type === 'status' && event.message) {
159
+ const msg = event.message;
160
+ if (msg.startsWith('Shell:') || msg.includes('Shell:')) {
161
+ const shellCmd = msg.replace(/^Shell:\s*/, '');
162
+ if (this._isDangerousCommand(shellCmd)) {
163
+ console.warn(`[QwenWrapper] ⚠️ DANGEROUS COMMAND DETECTED: ${shellCmd.substring(0, 100)}`);
164
+ }
165
+ }
166
+ }
167
+ onStatus(event);
168
+ });
169
+ } catch (parseError) {
170
+ console.error('[QwenWrapper] Parser error:', parseError.message);
171
+ }
172
+ }
173
+ });
174
+
175
+ ptyProcess.onExit(({ exitCode }) => {
176
+ this.unregisterProcess(processId);
177
+
178
+ if (promiseSettled) return;
179
+ promiseSettled = true;
180
+
181
+ if (exitCode !== 0 && exitCode !== null) {
182
+ reject(new Error(`Qwen CLI error (exit ${exitCode}): ${stdout.substring(0, 200)}`));
183
+ return;
184
+ }
185
+
186
+ const finalResponse = parser.getFinalResponse();
187
+ const usage = parser.getUsage();
188
+
189
+ const promptTokens = usage?.input_tokens || Math.ceil(prompt.length / 4);
190
+ const completionTokens = usage?.output_tokens || Math.ceil(finalResponse.length / 4);
191
+
192
+ if (!finalResponse.trim()) {
193
+ return reject(new Error('Qwen CLI returned empty response. Check CLI logs.'));
194
+ }
195
+
196
+ resolve({
197
+ text: finalResponse,
198
+ sessionId: parser.getSessionId(),
199
+ usage: {
200
+ prompt_tokens: promptTokens,
201
+ completion_tokens: completionTokens,
202
+ total_tokens: promptTokens + completionTokens,
203
+ }
204
+ });
205
+ });
206
+
207
+ if (ptyProcess.onError) {
208
+ ptyProcess.onError((error) => {
209
+ if (promiseSettled) return;
210
+ promiseSettled = true;
211
+ reject(new Error(`Qwen CLI PTY error: ${error.message}`));
212
+ });
213
+ }
214
+
215
+ const timeout = setTimeout(() => {
216
+ if (!promiseSettled) {
217
+ promiseSettled = true;
218
+ try { ptyProcess.kill(); } catch (_) {}
219
+ reject(new Error('Qwen CLI timeout (10 minutes)'));
220
+ }
221
+ }, CLI_TIMEOUT_MS);
222
+
223
+ ptyProcess.onExit(() => clearTimeout(timeout));
224
+ });
225
+ }
226
+
227
+ async isAvailable() {
228
+ return new Promise((resolve) => {
229
+ const { exec } = require('child_process');
230
+ exec(`${this.qwenPath} --version`, { timeout: 5000 }, (error, stdout) => {
231
+ if (error) {
232
+ console.log('[QwenWrapper] CLI not available:', error.message);
233
+ resolve(false);
234
+ } else {
235
+ console.log('[QwenWrapper] CLI available:', stdout.trim().substring(0, 50));
236
+ resolve(true);
237
+ }
238
+ });
239
+ });
240
+ }
241
+
242
+ getDefaultModel() {
243
+ return DEFAULT_MODEL;
244
+ }
245
+
246
+ getAvailableModels() {
247
+ return [
248
+ {
249
+ id: 'coder-model',
250
+ name: 'coder-model',
251
+ description: '🔧 Qwen Coder (default)',
252
+ default: true
253
+ },
254
+ {
255
+ id: 'vision-model',
256
+ name: 'vision-model',
257
+ description: '👁️ Qwen Vision'
258
+ }
259
+ ];
260
+ }
261
+ }
262
+
263
+ module.exports = QwenWrapper;
@@ -5,6 +5,7 @@
5
5
  * - Claude: ~/.claude/projects/<slug>/<sessionId>.jsonl
6
6
  * - Codex : ~/.codex/sessions/<sessionId>.jsonl
7
7
  * - Gemini: ~/.gemini/sessions/<sessionId>.jsonl
8
+ * - Qwen : ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
8
9
  *
9
10
  * Note:
10
11
  * - Usa FILESYSTEM come source of truth: non legge contenuti, solo metadati.
@@ -22,19 +23,21 @@ const HOME = process.env.HOME || '';
22
23
  const CLAUDE_PROJECTS = path.join(HOME, '.claude', 'projects');
23
24
  const CODEX_SESSIONS = path.join(HOME, '.codex', 'sessions');
24
25
  const GEMINI_SESSIONS = path.join(HOME, '.gemini', 'sessions');
26
+ const QWEN_PROJECTS = path.join(HOME, '.qwen', 'projects');
25
27
 
26
28
  class SessionImporter {
27
29
  constructor() {}
28
30
 
29
31
  /**
30
32
  * Importa tutte le sessioni per tutti gli engine
31
- * @returns {{claude:number, codex:number, gemini:number}}
33
+ * @returns {{claude:number, codex:number, gemini:number, qwen:number}}
32
34
  */
33
35
  importAll() {
34
36
  const claude = this.importClaudeSessions();
35
37
  const codex = this.importCodexSessions();
36
38
  const gemini = this.importGeminiSessions();
37
- return { claude, codex, gemini };
39
+ const qwen = this.importQwenSessions();
40
+ return { claude, codex, gemini, qwen };
38
41
  }
39
42
 
40
43
  /**
@@ -110,6 +113,36 @@ class SessionImporter {
110
113
  return imported;
111
114
  }
112
115
 
116
+ /**
117
+ * Qwen: ~/.qwen/projects/<sanitized>/chats/<sessionId>.jsonl
118
+ */
119
+ importQwenSessions() {
120
+ let imported = 0;
121
+ if (!fs.existsSync(QWEN_PROJECTS)) return imported;
122
+
123
+ const projects = fs.readdirSync(QWEN_PROJECTS);
124
+ for (const project of projects) {
125
+ const projectDir = path.join(QWEN_PROJECTS, project);
126
+ if (!fs.statSync(projectDir).isDirectory()) continue;
127
+
128
+ const chatsDir = path.join(projectDir, 'chats');
129
+ if (!fs.existsSync(chatsDir)) continue;
130
+
131
+ const files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.jsonl'));
132
+ for (const file of files) {
133
+ const sessionId = file.replace('.jsonl', '');
134
+ if (this.sessionExists(sessionId)) continue;
135
+
136
+ this.insertSession(sessionId, 'qwen', '', null);
137
+ imported++;
138
+ }
139
+ }
140
+
141
+ if (imported > 0) saveDb();
142
+ console.log(`[SessionImporter] Qwen imported: ${imported}`);
143
+ return imported;
144
+ }
145
+
113
146
  /**
114
147
  * Inserisce riga minima in sessions
115
148
  */