@mmmbuto/nexuscli 0.9.6 → 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 +21 -4
- package/lib/cli/engines.js +51 -2
- package/lib/config/manager.js +5 -0
- package/lib/config/models.js +28 -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
|
@@ -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
|
|
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;
|
|
@@ -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;
|