@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.
- package/README.md +28 -5
- package/lib/cli/engines.js +51 -2
- package/lib/config/manager.js +5 -0
- package/lib/config/models.js +28 -0
- package/lib/server/lib/cli-wrapper.js +167 -0
- package/lib/server/lib/output-parser.js +132 -0
- package/lib/server/lib/pty-adapter.js +81 -0
- package/lib/server/middleware/rate-limit.js +1 -1
- package/lib/server/models/Message.js +1 -1
- package/lib/server/routes/models.js +2 -0
- package/lib/server/routes/qwen.js +258 -0
- package/lib/server/routes/sessions.js +13 -1
- package/lib/server/server.js +7 -4
- package/lib/server/services/cli-loader.js +83 -3
- package/lib/server/services/context-bridge.js +4 -2
- package/lib/server/services/qwen-output-parser.js +289 -0
- package/lib/server/services/qwen-wrapper.js +263 -0
- package/lib/server/services/session-importer.js +35 -2
- package/lib/server/services/session-manager.js +32 -5
- package/lib/server/tests/history-sync.test.js +11 -2
- package/lib/server/tests/integration-session-sync.test.js +40 -8
- package/lib/server/tests/integration.test.js +33 -16
- package/lib/server/tests/performance.test.js +16 -9
- package/lib/server/tests/services.test.js +17 -10
- package/lib/utils/attachments.js +100 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
*/
|