@mmmbuto/nexuscli 0.9.7-termux → 0.9.8

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.
@@ -59,8 +59,8 @@
59
59
 
60
60
  <!-- Prevent Scaling on iOS -->
61
61
  <meta name="format-detection" content="telephone=no" />
62
- <script type="module" crossorigin src="/assets/index-CvOYN8ds.js"></script>
63
- <link rel="stylesheet" crossorigin href="/assets/index-BAfxoAUN.css">
62
+ <script type="module" crossorigin src="/assets/index-D8XkscmI.js"></script>
63
+ <link rel="stylesheet" crossorigin href="/assets/index-CoLEGBO4.css">
64
64
  </head>
65
65
  <body>
66
66
  <div id="root"></div>
@@ -1,5 +1,5 @@
1
1
  // NexusCLI Service Worker
2
- const CACHE_VERSION = 'nexuscli-v1766701518522';
2
+ const CACHE_VERSION = 'nexuscli-v1766931086050';
3
3
  const STATIC_CACHE = `${CACHE_VERSION}-static`;
4
4
  const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`;
5
5
 
package/lib/cli/api.js CHANGED
@@ -35,7 +35,7 @@ const SUPPORTED_PROVIDERS = {
35
35
  },
36
36
  zai: {
37
37
  name: 'Z.ai',
38
- description: 'GLM-4.6 (Chinese/English Multilingual)',
38
+ description: 'GLM-4.7 (Chinese/English Multilingual)',
39
39
  keyFormat: 'starts with alphanumeric + dot',
40
40
  url: 'https://z.ai'
41
41
  }
@@ -60,11 +60,11 @@ function getCliTools() {
60
60
  description: '💬 Fast Chat',
61
61
  category: 'claude'
62
62
  },
63
- // === GLM-4.6 (Z.ai) ===
63
+ // === GLM-4.7 (Z.ai) ===
64
64
  {
65
- id: 'glm-4-6',
66
- name: 'glm-4-6',
67
- label: 'GLM 4.6',
65
+ id: 'glm-4-7',
66
+ name: 'glm-4-7',
67
+ label: 'GLM 4.7',
68
68
  description: '🌍 Advanced Chinese/English Multilingual',
69
69
  category: 'claude'
70
70
  }
@@ -64,6 +64,7 @@ function initSchema() {
64
64
  content TEXT NOT NULL,
65
65
  created_at INTEGER NOT NULL,
66
66
  metadata TEXT,
67
+ engine TEXT NOT NULL DEFAULT 'claude',
67
68
  FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
68
69
  );
69
70
 
@@ -0,0 +1,45 @@
1
+ -- ============================================================
2
+ -- MIGRATION 005: Fix engine column to NOT NULL
3
+ -- Ensures all messages have a valid engine field for proper avatar display
4
+ -- ============================================================
5
+
6
+ -- Step 1: Backfill any NULL values to 'claude' (safe default)
7
+ UPDATE messages SET engine = 'claude' WHERE engine IS NULL OR engine = '';
8
+
9
+ -- Step 2: Recreate messages table with NOT NULL constraint
10
+ -- SQLite doesn't support ALTER COLUMN directly, so we recreate
11
+
12
+ -- Create new table with proper schema
13
+ CREATE TABLE IF NOT EXISTS messages_new (
14
+ id TEXT PRIMARY KEY,
15
+ conversation_id TEXT NOT NULL,
16
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
17
+ content TEXT NOT NULL,
18
+ created_at INTEGER NOT NULL,
19
+ metadata TEXT,
20
+ engine TEXT NOT NULL DEFAULT 'claude',
21
+ FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
22
+ );
23
+
24
+ -- Copy all data from old table to new table
25
+ INSERT INTO messages_new
26
+ SELECT id, conversation_id, role, content, created_at, metadata,
27
+ COALESCE(engine, 'claude') AS engine
28
+ FROM messages;
29
+
30
+ -- Drop old table
31
+ DROP TABLE messages;
32
+
33
+ -- Rename new table to original name
34
+ ALTER TABLE messages_new RENAME TO messages;
35
+
36
+ -- Step 3: Recreate all indexes
37
+ CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
38
+ CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at ASC);
39
+ CREATE INDEX IF NOT EXISTS idx_messages_engine ON messages(engine);
40
+ CREATE INDEX IF NOT EXISTS idx_messages_conversation_engine ON messages(conversation_id, engine);
41
+
42
+ -- Verification query ( informational only )
43
+ SELECT 'Messages with NULL engine (should be 0):' as check_name,
44
+ COUNT(*) as count
45
+ FROM messages WHERE engine IS NULL;
@@ -11,6 +11,7 @@ const { v4: uuidv4 } = require('uuid');
11
11
  const sessionManager = require('../services/session-manager');
12
12
  const contextBridge = require('../services/context-bridge');
13
13
  const { resolveWorkspacePath } = require('../../utils/workspace');
14
+ const { normalizeAttachments } = require('../../utils/attachments');
14
15
 
15
16
  const router = express.Router();
16
17
  const qwenWrapper = new QwenWrapper();
@@ -40,11 +41,12 @@ function ensureConversation(conversationId, workspacePath) {
40
41
  * }
41
42
  */
42
43
  router.post('/', async (req, res) => {
44
+ const fallbackModel = req.body?.model || 'coder-model';
43
45
  try {
44
46
  console.log('[Qwen] === NEW QWEN REQUEST ===');
45
47
  console.log('[Qwen] Body:', JSON.stringify(req.body, null, 2));
46
48
 
47
- const { conversationId, message, model = 'coder-model', workspace } = req.body;
49
+ const { conversationId, message, model = 'coder-model', workspace, attachments } = req.body;
48
50
 
49
51
  if (!message) {
50
52
  return res.status(400).json({ error: 'message required' });
@@ -63,6 +65,13 @@ router.post('/', async (req, res) => {
63
65
  console.warn(`[Qwen] Workspace corrected: ${workspace} → ${workspacePath}`);
64
66
  }
65
67
 
68
+ const attachmentInfo = normalizeAttachments({
69
+ message,
70
+ attachments,
71
+ workspacePath
72
+ });
73
+ const userMessage = attachmentInfo.promptMessage || message;
74
+
66
75
  const frontendConversationId = conversationId || uuidv4();
67
76
  ensureConversation(frontendConversationId, workspacePath);
68
77
 
@@ -91,7 +100,7 @@ router.post('/', async (req, res) => {
91
100
  const lastEngine = Message.getLastEngine(frontendConversationId);
92
101
  const isEngineBridge = lastEngine && lastEngine !== 'qwen';
93
102
 
94
- let promptToSend = message;
103
+ let promptToSend = userMessage;
95
104
 
96
105
  if (isEngineBridge) {
97
106
  const contextResult = await contextBridge.buildContext({
@@ -99,7 +108,7 @@ router.post('/', async (req, res) => {
99
108
  sessionId,
100
109
  fromEngine: lastEngine,
101
110
  toEngine: 'qwen',
102
- userMessage: message
111
+ userMessage: userMessage
103
112
  });
104
113
  promptToSend = contextResult.prompt;
105
114
 
@@ -113,6 +122,15 @@ router.post('/', async (req, res) => {
113
122
  console.log(`[Qwen] Native resume: qwen --resume ${nativeSessionId}`);
114
123
  }
115
124
 
125
+ const attachmentRefs = attachmentInfo.attachmentPaths.map((filePath) => {
126
+ const escapedPath = filePath.replace(/([\\\s,;!?()[\]{}])/g, '\\$1');
127
+ return `@${escapedPath}`;
128
+ });
129
+ if (attachmentRefs.length > 0) {
130
+ promptToSend = `${attachmentRefs.join('\n')}\n\n${promptToSend}`;
131
+ console.log(`[Qwen] Attached ${attachmentRefs.length} file(s) to prompt`);
132
+ }
133
+
116
134
  // Save user message
117
135
  try {
118
136
  Message.create(
@@ -138,6 +156,7 @@ router.post('/', async (req, res) => {
138
156
  threadId: nativeSessionId,
139
157
  model,
140
158
  workspacePath,
159
+ includeDirectories: attachmentInfo.includeDirectories,
141
160
  processId: sessionId,
142
161
  onStatus: (event) => {
143
162
  res.write(`data: ${JSON.stringify(event)}\n\n`);
@@ -185,7 +204,9 @@ router.post('/', async (req, res) => {
185
204
  } else {
186
205
  res.write(`data: ${JSON.stringify({
187
206
  type: 'error',
188
- error: error.message
207
+ error: error.message,
208
+ engine: 'qwen',
209
+ model: fallbackModel
189
210
  })}\n\n`);
190
211
  res.end();
191
212
  }
@@ -165,7 +165,7 @@ class ClaudeWrapper extends BaseCliWrapper {
165
165
 
166
166
  // Detect alternative models early (needed for args construction)
167
167
  const isDeepSeek = model.startsWith('deepseek-');
168
- const isGLM = model === 'glm-4-6';
168
+ const isGLM = model === 'glm-4-7';
169
169
  const isAlternativeModel = isDeepSeek || isGLM;
170
170
 
171
171
  // Build Claude Code CLI args
@@ -233,7 +233,7 @@ class ClaudeWrapper extends BaseCliWrapper {
233
233
  const glmKey = getApiKey('zai') || process.env.ZAI_API_KEY;
234
234
 
235
235
  if (!glmKey) {
236
- const errorMsg = `Z.ai API key not configured for GLM-4.6!\n\n` +
236
+ const errorMsg = `Z.ai API key not configured for GLM-4.7!\n\n` +
237
237
  `Run this command to add your API key:\n` +
238
238
  ` nexuscli api set zai YOUR_API_KEY\n\n` +
239
239
  `Get your key at: https://z.ai`;
@@ -251,12 +251,12 @@ class ClaudeWrapper extends BaseCliWrapper {
251
251
  return reject(new Error(errorMsg));
252
252
  }
253
253
 
254
- // GLM-4.6 uses Z.ai Anthropic-compatible API
254
+ // GLM-4.7 uses Z.ai Anthropic-compatible API
255
255
  spawnEnv.ANTHROPIC_BASE_URL = 'https://api.z.ai/api/anthropic';
256
256
  spawnEnv.ANTHROPIC_AUTH_TOKEN = glmKey;
257
- spawnEnv.ANTHROPIC_MODEL = 'GLM-4.6'; // Z.ai model name
257
+ spawnEnv.ANTHROPIC_MODEL = 'GLM-4.7'; // Z.ai model name
258
258
  spawnEnv.API_TIMEOUT_MS = '3000000'; // 50 minutes timeout
259
- console.log(`[ClaudeWrapper] GLM-4.6 detected - using Z.ai API with extended timeout`);
259
+ console.log(`[ClaudeWrapper] GLM-4.7 detected - using Z.ai API with extended timeout`);
260
260
  }
261
261
 
262
262
  console.log(`[ClaudeWrapper] Model: ${model}${isDeepSeek ? ' (DeepSeek API)' : isGLM ? ' (Z.ai API)' : ''}`);
@@ -360,7 +360,7 @@ class ClaudeWrapper extends BaseCliWrapper {
360
360
  let timeoutMs = 600000; // 10 minutes default
361
361
  let timeoutLabel = '10 minutes';
362
362
  if (isGLM) {
363
- timeoutMs = 3600000; // 60 minutes for GLM-4.6 (slow responses)
363
+ timeoutMs = 3600000; // 60 minutes for GLM-4.7 (slow responses)
364
364
  timeoutLabel = '60 minutes';
365
365
  } else if (isDeepSeek) {
366
366
  timeoutMs = 900000; // 15 minutes for DeepSeek
@@ -424,7 +424,7 @@ class CliLoader {
424
424
  content = parts
425
425
  .filter(p => p && p.text)
426
426
  .map(p => p.text)
427
- .join('\\n');
427
+ .join('\n');
428
428
  } else if (typeof entry.message?.content === 'string') {
429
429
  content = entry.message.content;
430
430
  } else if (entry.text) {
@@ -79,6 +79,27 @@ class QwenOutputParser {
79
79
  break;
80
80
  }
81
81
 
82
+ case 'tool_use': {
83
+ events.push(this._formatToolUseEvent(event));
84
+ break;
85
+ }
86
+
87
+ case 'tool_result': {
88
+ const toolName = event.tool_name || event.tool || event.name || event.function?.name || 'Tool';
89
+ const success =
90
+ event.status ? event.status !== 'error' && event.status !== 'failure' : !event.is_error;
91
+
92
+ events.push({
93
+ type: 'status',
94
+ category: 'tool',
95
+ message: `${toolName}: ${success ? 'completed' : 'failed'}`,
96
+ icon: success ? '✅' : '❌',
97
+ toolOutput: this._truncateOutput(event.output || event.result || event.content),
98
+ timestamp: event.timestamp || new Date().toISOString(),
99
+ });
100
+ break;
101
+ }
102
+
82
103
  case 'assistant': {
83
104
  const contentBlocks = event.message?.content;
84
105
  const text = this._extractText(contentBlocks);
@@ -122,6 +143,9 @@ class QwenOutputParser {
122
143
  if (stream.type === 'content_block_start' && stream.content_block?.type === 'tool_use') {
123
144
  events.push(this._formatToolUseEvent(stream.content_block));
124
145
  }
146
+ if (stream.type === 'message_start' && stream.message?.model) {
147
+ this.model = stream.message.model;
148
+ }
125
149
  break;
126
150
  }
127
151
 
@@ -158,6 +182,12 @@ class QwenOutputParser {
158
182
  break;
159
183
  }
160
184
 
185
+ case 'error': {
186
+ const message = event.message || event.error || 'Unknown error';
187
+ events.push({ type: 'error', message });
188
+ break;
189
+ }
190
+
161
191
  default:
162
192
  // Ignore other event types
163
193
  break;
@@ -83,6 +83,7 @@ class QwenWrapper extends BaseCliWrapper {
83
83
  threadId,
84
84
  model = DEFAULT_MODEL,
85
85
  workspacePath,
86
+ includeDirectories = [],
86
87
  onStatus,
87
88
  processId: processIdOverride
88
89
  }) {
@@ -91,24 +92,36 @@ class QwenWrapper extends BaseCliWrapper {
91
92
  let promiseSettled = false;
92
93
 
93
94
  const cwd = workspacePath || this.workspaceDir;
95
+ const resolvedModel = model || DEFAULT_MODEL;
94
96
 
95
97
  const args = [
96
98
  '-y',
97
- '-m', model,
99
+ '-m', resolvedModel,
98
100
  '-o', 'stream-json',
99
101
  '--include-partial-messages',
100
102
  ];
101
103
 
104
+ if (includeDirectories && includeDirectories.length > 0) {
105
+ includeDirectories.forEach((dir) => {
106
+ if (dir) {
107
+ args.push('--include-directories', dir);
108
+ }
109
+ });
110
+ }
111
+
102
112
  if (threadId) {
103
113
  args.push('--resume', threadId);
104
114
  }
105
115
 
106
116
  args.push(prompt);
107
117
 
108
- console.log(`[QwenWrapper] Model: ${model}`);
118
+ console.log(`[QwenWrapper] Model: ${resolvedModel}`);
109
119
  console.log(`[QwenWrapper] ThreadId: ${threadId || '(new session)'}`);
110
120
  console.log(`[QwenWrapper] CWD: ${cwd}`);
111
121
  console.log(`[QwenWrapper] Prompt length: ${prompt.length}`);
122
+ if (includeDirectories && includeDirectories.length > 0) {
123
+ console.log(`[QwenWrapper] Include dirs: ${includeDirectories.join(', ')}`);
124
+ }
112
125
 
113
126
  let ptyProcess;
114
127
  try {
@@ -119,6 +132,8 @@ class QwenWrapper extends BaseCliWrapper {
119
132
  cwd,
120
133
  env: {
121
134
  ...process.env,
135
+ QWEN_CODE_MODEL: resolvedModel,
136
+ QWEN_MODEL: resolvedModel,
122
137
  TERM: 'xterm-256color',
123
138
  }
124
139
  });
@@ -161,14 +161,19 @@ class SessionImporter {
161
161
 
162
162
  /**
163
163
  * Controlla se la sessione esiste già
164
+ * Returns false if sessions table doesn't exist (allows import)
164
165
  */
165
166
  sessionExists(sessionId) {
166
167
  try {
167
168
  const stmt = prepare('SELECT 1 FROM sessions WHERE id = ?');
168
169
  return !!stmt.get(sessionId);
169
170
  } catch (err) {
171
+ // If sessions table doesn't exist, return false to allow import
172
+ if (err.message && err.message.includes('no such table')) {
173
+ return false;
174
+ }
170
175
  console.warn(`[SessionImporter] exists check failed: ${err.message}`);
171
- return true; // default to skip to avoid duplicates
176
+ return true; // default to skip to avoid duplicates on other errors
172
177
  }
173
178
  }
174
179
 
@@ -11,6 +11,7 @@ class WorkspaceManager {
11
11
  this.claudePath = path.join(process.env.HOME, '.claude');
12
12
  this.historyPath = path.join(this.claudePath, 'history.jsonl');
13
13
  this.projectsPath = path.join(this.claudePath, 'projects');
14
+ this.qwenPath = path.join(process.env.HOME, '.qwen');
14
15
  this.cacheTtlMs = 5 * 60 * 1000; // 5 minutes
15
16
  this.historyCache = {
16
17
  entries: null,
@@ -38,6 +39,17 @@ class WorkspaceManager {
38
39
  return '/' + slug.replace(/^-/, '').replace(/-/g, '/');
39
40
  }
40
41
 
42
+ /**
43
+ * Convert workspace path to Qwen project directory name
44
+ * Matches Qwen Storage.sanitizeCwd behavior
45
+ * @param {string} workspacePath - Absolute path
46
+ * @returns {string} Qwen project directory name
47
+ */
48
+ qwenPathToProject(workspacePath) {
49
+ if (!workspacePath) return 'default';
50
+ return workspacePath.replace(/[^a-zA-Z0-9]/g, '-');
51
+ }
52
+
41
53
  /**
42
54
  * Discover all workspaces from .claude/projects/
43
55
  * Reads real workspace path from session file 'cwd' field
@@ -169,6 +181,10 @@ class WorkspaceManager {
169
181
  const claudeSessions = await this.indexClaudeCodeSessions(workspacePath);
170
182
  sessions.push(...claudeSessions);
171
183
 
184
+ // Index Qwen sessions
185
+ const qwenSessions = await this.indexQwenSessions(workspacePath);
186
+ sessions.push(...qwenSessions);
187
+
172
188
  // TODO: Index other CLI tools (Codex, Aider, etc.)
173
189
 
174
190
  // Sync sessions to database (batch mode to avoid "Statement closed" errors)
@@ -332,6 +348,111 @@ class WorkspaceManager {
332
348
  return sessions;
333
349
  }
334
350
 
351
+ /**
352
+ * Index Qwen sessions from .qwen/projects/<workspace-sanitized>/chats/
353
+ * @param {string} workspacePath
354
+ * @returns {Promise<Array>}
355
+ */
356
+ async indexQwenSessions(workspacePath) {
357
+ const project = this.qwenPathToProject(workspacePath);
358
+ const projectDir = path.join(this.qwenPath, 'projects', project);
359
+
360
+ if (!fs.existsSync(projectDir)) {
361
+ // Silently skip - Qwen may not be used for this workspace
362
+ return [];
363
+ }
364
+
365
+ const chatsDir = path.join(projectDir, 'chats');
366
+ if (!fs.existsSync(chatsDir)) {
367
+ return [];
368
+ }
369
+
370
+ // List session files
371
+ const sessionFiles = fs.readdirSync(chatsDir)
372
+ .filter(f => f.endsWith('.jsonl'));
373
+
374
+ console.log(`[WorkspaceManager] Found ${sessionFiles.length} Qwen session files in ${project}`);
375
+
376
+ const sessions = [];
377
+
378
+ for (const file of sessionFiles) {
379
+ const sessionId = file.replace('.jsonl', '');
380
+ const sessionPath = path.join(chatsDir, file);
381
+
382
+ // Read session file line by line
383
+ const messages = [];
384
+ let firstTimestamp = null;
385
+ let lastTimestamp = null;
386
+
387
+ try {
388
+ const fileStream = fs.createReadStream(sessionPath);
389
+ const rl = readline.createInterface({
390
+ input: fileStream,
391
+ crlfDelay: Infinity
392
+ });
393
+
394
+ for await (const line of rl) {
395
+ if (!line.trim()) continue;
396
+ try {
397
+ const entry = JSON.parse(line);
398
+
399
+ // Only include user/assistant messages (skip system/ui_telemetry)
400
+ if (entry.type === 'user' || entry.type === 'assistant') {
401
+ const timestamp = new Date(entry.timestamp).getTime();
402
+ if (!firstTimestamp || timestamp < firstTimestamp) firstTimestamp = timestamp;
403
+ if (!lastTimestamp || timestamp > lastTimestamp) lastTimestamp = timestamp;
404
+
405
+ // Extract content from Qwen format
406
+ let content = '';
407
+ const parts = entry.message?.parts;
408
+ if (Array.isArray(parts)) {
409
+ content = parts
410
+ .filter(p => p && p.text)
411
+ .map(p => p.text)
412
+ .join('\n');
413
+ } else if (typeof entry.message?.content === 'string') {
414
+ content = entry.message.content;
415
+ } else if (entry.text) {
416
+ content = entry.text;
417
+ }
418
+
419
+ messages.push({
420
+ role: entry.type,
421
+ content: content,
422
+ timestamp: entry.timestamp
423
+ });
424
+ }
425
+ } catch (e) {
426
+ // Skip malformed lines
427
+ }
428
+ }
429
+ } catch (error) {
430
+ console.error(`[WorkspaceManager] Error reading Qwen session ${sessionId}:`, error.message);
431
+ continue;
432
+ }
433
+
434
+ if (messages.length > 0) {
435
+ sessions.push({
436
+ id: sessionId,
437
+ engine: 'qwen',
438
+ workspace_path: workspacePath,
439
+ session_path: sessionPath,
440
+ title: this.extractTitle(messages),
441
+ message_count: messages.length,
442
+ last_used_at: lastTimestamp,
443
+ created_at: firstTimestamp
444
+ // NOTE: messages NOT included - loaded on-demand by CliLoader (filesystem = source of truth)
445
+ });
446
+ }
447
+ }
448
+
449
+ // Sort by most recent first
450
+ sessions.sort((a, b) => b.last_used_at - a.last_used_at);
451
+
452
+ console.log(`[WorkspaceManager] Loaded ${sessions.length} Qwen sessions (sorted by recency)`);
453
+ return sessions;
454
+ }
455
+
335
456
  /**
336
457
  * Return cached history entries with TTL and fs.watch invalidation.
337
458
  */
@@ -0,0 +1,100 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { fixTermuxPath } = require('./workspace');
4
+
5
+ const ATTACHMENT_LINE_REGEX = /^\s*\[Attached:\s*(.+?)\s*\]\s*$/;
6
+ const IMAGE_EXTS = new Set([
7
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.svg', '.heic'
8
+ ]);
9
+
10
+ function parseAttachmentMarkers(message = '') {
11
+ if (!message) {
12
+ return { cleanMessage: message, paths: [] };
13
+ }
14
+
15
+ const lines = message.split('\n');
16
+ const paths = [];
17
+ const kept = [];
18
+
19
+ for (const line of lines) {
20
+ const match = line.match(ATTACHMENT_LINE_REGEX);
21
+ if (match) {
22
+ paths.push(match[1]);
23
+ continue;
24
+ }
25
+ kept.push(line);
26
+ }
27
+
28
+ const cleanMessage = kept.join('\n').trim();
29
+ return { cleanMessage, paths };
30
+ }
31
+
32
+ function isImageAttachment(attachment) {
33
+ const mimeType = attachment?.mimeType || '';
34
+ if (mimeType.startsWith('image/')) return true;
35
+ const ext = path.extname(attachment?.path || '').toLowerCase();
36
+ return IMAGE_EXTS.has(ext);
37
+ }
38
+
39
+ function normalizeAttachmentPath(rawPath, workspacePath) {
40
+ if (!rawPath || typeof rawPath !== 'string') return null;
41
+ let fixedPath = fixTermuxPath(rawPath);
42
+ if (!path.isAbsolute(fixedPath) && workspacePath) {
43
+ fixedPath = path.resolve(workspacePath, fixedPath);
44
+ }
45
+ return fixedPath;
46
+ }
47
+
48
+ function normalizeAttachments({ message, rawMessage, attachments, workspacePath }) {
49
+ const parsed = parseAttachmentMarkers(message || '');
50
+ const promptMessage = rawMessage != null ? rawMessage : (parsed.cleanMessage || message || '');
51
+
52
+ const incoming = [];
53
+ if (Array.isArray(attachments)) {
54
+ incoming.push(...attachments);
55
+ }
56
+ if (parsed.paths.length > 0) {
57
+ parsed.paths.forEach((p) => incoming.push({ path: p }));
58
+ }
59
+
60
+ const seen = new Set();
61
+ const normalized = [];
62
+
63
+ for (const entry of incoming) {
64
+ if (!entry) continue;
65
+ const rawPath = typeof entry === 'string' ? entry : entry.path;
66
+ const resolvedPath = normalizeAttachmentPath(rawPath, workspacePath);
67
+ if (!resolvedPath) continue;
68
+ if (!fs.existsSync(resolvedPath)) continue;
69
+ if (seen.has(resolvedPath)) continue;
70
+
71
+ seen.add(resolvedPath);
72
+ const record = {
73
+ ...entry,
74
+ path: resolvedPath,
75
+ name: entry.name || path.basename(resolvedPath)
76
+ };
77
+ normalized.push(record);
78
+ }
79
+
80
+ const attachmentPaths = normalized.map((att) => att.path);
81
+ const includeDirectories = Array.from(
82
+ new Set(attachmentPaths.map((p) => path.dirname(p)))
83
+ );
84
+ const imageFiles = normalized.filter(isImageAttachment).map((att) => att.path);
85
+
86
+ return {
87
+ promptMessage,
88
+ attachments: normalized,
89
+ attachmentPaths,
90
+ includeDirectories,
91
+ imageFiles,
92
+ cleanMessage: parsed.cleanMessage
93
+ };
94
+ }
95
+
96
+ module.exports = {
97
+ normalizeAttachments,
98
+ parseAttachmentMarkers,
99
+ isImageAttachment
100
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscli",
3
- "version": "0.9.7-termux",
3
+ "version": "0.9.8",
4
4
  "description": "NexusCLI - TRI CLI Control Plane (Claude/Codex/Gemini/Qwen)",
5
5
  "main": "lib/server/server.js",
6
6
  "bin": {