@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.
- 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 +240 -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 +251 -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/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,251 @@
|
|
|
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
|
+
onStatus,
|
|
87
|
+
processId: processIdOverride
|
|
88
|
+
}) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const parser = new QwenOutputParser();
|
|
91
|
+
let promiseSettled = false;
|
|
92
|
+
|
|
93
|
+
const cwd = workspacePath || this.workspaceDir;
|
|
94
|
+
|
|
95
|
+
const args = [
|
|
96
|
+
'-y',
|
|
97
|
+
'-m', model,
|
|
98
|
+
'-o', 'stream-json',
|
|
99
|
+
'--include-partial-messages',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
if (threadId) {
|
|
103
|
+
args.push('--resume', threadId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
args.push(prompt);
|
|
107
|
+
|
|
108
|
+
console.log(`[QwenWrapper] Model: ${model}`);
|
|
109
|
+
console.log(`[QwenWrapper] ThreadId: ${threadId || '(new session)'}`);
|
|
110
|
+
console.log(`[QwenWrapper] CWD: ${cwd}`);
|
|
111
|
+
console.log(`[QwenWrapper] Prompt length: ${prompt.length}`);
|
|
112
|
+
|
|
113
|
+
let ptyProcess;
|
|
114
|
+
try {
|
|
115
|
+
ptyProcess = pty.spawn(this.qwenPath, args, {
|
|
116
|
+
name: 'xterm-color',
|
|
117
|
+
cols: 120,
|
|
118
|
+
rows: 40,
|
|
119
|
+
cwd,
|
|
120
|
+
env: {
|
|
121
|
+
...process.env,
|
|
122
|
+
TERM: 'xterm-256color',
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
} catch (spawnError) {
|
|
126
|
+
return reject(new Error(`Failed to spawn Qwen CLI: ${spawnError.message}`));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const processId = processIdOverride || threadId || `qwen-${Date.now()}`;
|
|
130
|
+
this.registerProcess(processId, ptyProcess, 'pty');
|
|
131
|
+
|
|
132
|
+
let stdout = '';
|
|
133
|
+
|
|
134
|
+
ptyProcess.onData((data) => {
|
|
135
|
+
stdout += data;
|
|
136
|
+
|
|
137
|
+
const cleanData = data
|
|
138
|
+
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
|
|
139
|
+
.replace(/\x1B\[\?[0-9;]*[a-zA-Z]/g, '')
|
|
140
|
+
.replace(/\r/g, '');
|
|
141
|
+
|
|
142
|
+
if (onStatus && cleanData.trim()) {
|
|
143
|
+
try {
|
|
144
|
+
const events = parser.parse(cleanData);
|
|
145
|
+
events.forEach(event => {
|
|
146
|
+
if (event.type === 'status' && event.message) {
|
|
147
|
+
const msg = event.message;
|
|
148
|
+
if (msg.startsWith('Shell:') || msg.includes('Shell:')) {
|
|
149
|
+
const shellCmd = msg.replace(/^Shell:\s*/, '');
|
|
150
|
+
if (this._isDangerousCommand(shellCmd)) {
|
|
151
|
+
console.warn(`[QwenWrapper] ⚠️ DANGEROUS COMMAND DETECTED: ${shellCmd.substring(0, 100)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
onStatus(event);
|
|
156
|
+
});
|
|
157
|
+
} catch (parseError) {
|
|
158
|
+
console.error('[QwenWrapper] Parser error:', parseError.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
164
|
+
this.unregisterProcess(processId);
|
|
165
|
+
|
|
166
|
+
if (promiseSettled) return;
|
|
167
|
+
promiseSettled = true;
|
|
168
|
+
|
|
169
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
170
|
+
reject(new Error(`Qwen CLI error (exit ${exitCode}): ${stdout.substring(0, 200)}`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const finalResponse = parser.getFinalResponse();
|
|
175
|
+
const usage = parser.getUsage();
|
|
176
|
+
|
|
177
|
+
const promptTokens = usage?.input_tokens || Math.ceil(prompt.length / 4);
|
|
178
|
+
const completionTokens = usage?.output_tokens || Math.ceil(finalResponse.length / 4);
|
|
179
|
+
|
|
180
|
+
if (!finalResponse.trim()) {
|
|
181
|
+
return reject(new Error('Qwen CLI returned empty response. Check CLI logs.'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
resolve({
|
|
185
|
+
text: finalResponse,
|
|
186
|
+
sessionId: parser.getSessionId(),
|
|
187
|
+
usage: {
|
|
188
|
+
prompt_tokens: promptTokens,
|
|
189
|
+
completion_tokens: completionTokens,
|
|
190
|
+
total_tokens: promptTokens + completionTokens,
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (ptyProcess.onError) {
|
|
196
|
+
ptyProcess.onError((error) => {
|
|
197
|
+
if (promiseSettled) return;
|
|
198
|
+
promiseSettled = true;
|
|
199
|
+
reject(new Error(`Qwen CLI PTY error: ${error.message}`));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const timeout = setTimeout(() => {
|
|
204
|
+
if (!promiseSettled) {
|
|
205
|
+
promiseSettled = true;
|
|
206
|
+
try { ptyProcess.kill(); } catch (_) {}
|
|
207
|
+
reject(new Error('Qwen CLI timeout (10 minutes)'));
|
|
208
|
+
}
|
|
209
|
+
}, CLI_TIMEOUT_MS);
|
|
210
|
+
|
|
211
|
+
ptyProcess.onExit(() => clearTimeout(timeout));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async isAvailable() {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
const { exec } = require('child_process');
|
|
218
|
+
exec(`${this.qwenPath} --version`, { timeout: 5000 }, (error, stdout) => {
|
|
219
|
+
if (error) {
|
|
220
|
+
console.log('[QwenWrapper] CLI not available:', error.message);
|
|
221
|
+
resolve(false);
|
|
222
|
+
} else {
|
|
223
|
+
console.log('[QwenWrapper] CLI available:', stdout.trim().substring(0, 50));
|
|
224
|
+
resolve(true);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
getDefaultModel() {
|
|
231
|
+
return DEFAULT_MODEL;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
getAvailableModels() {
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
id: 'coder-model',
|
|
238
|
+
name: 'coder-model',
|
|
239
|
+
description: '🔧 Qwen Coder (default)',
|
|
240
|
+
default: true
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 'vision-model',
|
|
244
|
+
name: 'vision-model',
|
|
245
|
+
description: '👁️ Qwen Vision'
|
|
246
|
+
}
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
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
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SessionManager - Session Sync Pattern Implementation (TRI CLI v0.4.0)
|
|
3
3
|
*
|
|
4
|
-
* Simplified session management for Claude/Codex/Gemini engines.
|
|
4
|
+
* Simplified session management for Claude/Codex/Gemini/Qwen engines.
|
|
5
5
|
* Principle: FILESYSTEM = SOURCE OF TRUTH
|
|
6
6
|
*
|
|
7
7
|
* Flow:
|
|
@@ -22,6 +22,7 @@ const SESSION_DIRS = {
|
|
|
22
22
|
claude: path.join(process.env.HOME || '', '.claude', 'projects'),
|
|
23
23
|
codex: path.join(process.env.HOME || '', '.codex', 'sessions'),
|
|
24
24
|
gemini: path.join(process.env.HOME || '', '.gemini', 'sessions'),
|
|
25
|
+
qwen: path.join(process.env.HOME || '', '.qwen', 'projects'),
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
class SessionManager {
|
|
@@ -52,9 +53,9 @@ class SessionManager {
|
|
|
52
53
|
sessionFileExists(sessionId, engine, workspacePath) {
|
|
53
54
|
const normalizedEngine = this._normalizeEngine(engine);
|
|
54
55
|
|
|
55
|
-
// Codex/Gemini exec mode doesn't
|
|
56
|
+
// Codex/Gemini/Qwen exec mode doesn't require filesystem checks - trust DB mapping
|
|
56
57
|
// Session continuity is managed via NexusCLI's message DB + contextBridge
|
|
57
|
-
if (normalizedEngine === 'codex' || normalizedEngine === 'gemini') {
|
|
58
|
+
if (normalizedEngine === 'codex' || normalizedEngine === 'gemini' || normalizedEngine === 'qwen') {
|
|
58
59
|
return true; // Always trust DB for exec-mode CLI sessions
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -108,6 +109,11 @@ class SessionManager {
|
|
|
108
109
|
case 'gemini':
|
|
109
110
|
// Gemini sessions: ~/.gemini/sessions/<sessionId>.jsonl
|
|
110
111
|
return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
|
|
112
|
+
case 'qwen': {
|
|
113
|
+
// Qwen sessions: ~/.qwen/projects/<sanitized-cwd>/chats/<sessionId>.jsonl
|
|
114
|
+
const project = this._pathToQwenProject(workspacePath);
|
|
115
|
+
return path.join(SESSION_DIRS.qwen, project, 'chats', `${sessionId}.jsonl`);
|
|
116
|
+
}
|
|
111
117
|
|
|
112
118
|
default:
|
|
113
119
|
console.warn(`[SessionManager] Unknown engine: ${engine}`);
|
|
@@ -124,6 +130,7 @@ class SessionManager {
|
|
|
124
130
|
if (lower.includes('claude')) return 'claude';
|
|
125
131
|
if (lower.includes('codex') || lower.includes('openai')) return 'codex';
|
|
126
132
|
if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
|
|
133
|
+
if (lower.includes('qwen')) return 'qwen';
|
|
127
134
|
return lower;
|
|
128
135
|
}
|
|
129
136
|
|
|
@@ -138,6 +145,15 @@ class SessionManager {
|
|
|
138
145
|
return workspacePath.replace(/\//g, '-');
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Convert workspace path to Qwen project dir (matches Qwen Storage.sanitizeCwd)
|
|
150
|
+
* Replaces non-alphanumeric characters with '-'
|
|
151
|
+
*/
|
|
152
|
+
_pathToQwenProject(workspacePath) {
|
|
153
|
+
if (!workspacePath) return 'default';
|
|
154
|
+
return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
155
|
+
}
|
|
156
|
+
|
|
141
157
|
/**
|
|
142
158
|
* Generate workspace hash (legacy method, kept for compatibility)
|
|
143
159
|
* @deprecated Use _pathToSlug instead (matches Claude Code behavior)
|
|
@@ -325,7 +341,12 @@ class SessionManager {
|
|
|
325
341
|
this.lastAccess.delete(cacheKey);
|
|
326
342
|
|
|
327
343
|
// Delete the original .jsonl file (SYNC DELETE)
|
|
328
|
-
const sessionFile = this._getSessionFilePath(
|
|
344
|
+
const sessionFile = this._getSessionFilePath(
|
|
345
|
+
session.id,
|
|
346
|
+
session.engine,
|
|
347
|
+
session.workspace_path,
|
|
348
|
+
session.session_path
|
|
349
|
+
);
|
|
329
350
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
330
351
|
try {
|
|
331
352
|
fs.unlinkSync(sessionFile);
|
|
@@ -353,10 +374,11 @@ class SessionManager {
|
|
|
353
374
|
/**
|
|
354
375
|
* Get the filesystem path for a session file
|
|
355
376
|
*/
|
|
356
|
-
_getSessionFilePath(sessionId, engine, workspacePath) {
|
|
377
|
+
_getSessionFilePath(sessionId, engine, workspacePath, sessionPath) {
|
|
357
378
|
const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
|
|
358
379
|
: engine?.toLowerCase().includes('codex') ? 'codex'
|
|
359
380
|
: engine?.toLowerCase().includes('gemini') ? 'gemini'
|
|
381
|
+
: engine?.toLowerCase().includes('qwen') ? 'qwen'
|
|
360
382
|
: 'claude';
|
|
361
383
|
|
|
362
384
|
switch (normalizedEngine) {
|
|
@@ -367,6 +389,11 @@ class SessionManager {
|
|
|
367
389
|
return path.join(SESSION_DIRS.codex, `${sessionId}.jsonl`);
|
|
368
390
|
case 'gemini':
|
|
369
391
|
return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
|
|
392
|
+
case 'qwen': {
|
|
393
|
+
const project = this._pathToQwenProject(workspacePath);
|
|
394
|
+
const fileId = sessionPath || sessionId;
|
|
395
|
+
return path.join(SESSION_DIRS.qwen, project, 'chats', `${fileId}.jsonl`);
|
|
396
|
+
}
|
|
370
397
|
default:
|
|
371
398
|
return null;
|
|
372
399
|
}
|