@semalt-ai/code 1.8.1 → 1.8.3

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/lib/tools.js CHANGED
@@ -1,17 +1,19 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const fsp = require('fs/promises');
4
5
  const http = require('http');
5
6
  const https = require('https');
6
7
  const os = require('os');
7
8
  const path = require('path');
8
- const { spawnSync } = require('child_process');
9
+ const { spawn } = require('child_process');
9
10
 
10
11
  const { logToolCall } = require('./audit');
11
12
 
12
13
  const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
13
14
 
14
15
  const _dryRun = process.argv.includes('--dry-run');
16
+ const _allowAnywhere = process.argv.includes('--allow-anywhere');
15
17
  const _skippedOps = [];
16
18
  function getSkippedOps() { return _skippedOps.slice(); }
17
19
 
@@ -19,15 +21,62 @@ function getSkippedOps() { return _skippedOps.slice(); }
19
21
  // handles all tool-status display via onToolEnd callbacks instead.
20
22
  let _uiActive = false;
21
23
  function setUIActive(v) { _uiActive = v; }
24
+ function isUIActive() { return _uiActive; }
22
25
  function _log(...args) { if (!_uiActive) console.log(...args); }
23
26
 
24
- // Chunk store for large http_get responses.
25
- // Maps URL { chunks: string[], total: number, delivered: number }
26
- const _httpChunkStore = new Map();
27
- const HTTP_CHUNK_CHARS = 10000;
27
+ // Reject writes outside the project CWD and in sensitive system/home dirs
28
+ // (~/.ssh, ~/.aws, ~/.gnupg, /etc, /boot, /sys, /proc). Override with
29
+ // --allow-anywhere when an agent genuinely needs to touch outside paths.
30
+ function isPathSafe(filePath) {
31
+ if (_allowAnywhere) return true;
32
+ if (typeof filePath !== 'string' || !filePath) return false;
33
+ const resolved = path.resolve(filePath);
34
+ const home = os.homedir();
35
+ const banned = [
36
+ path.join(home, '.ssh') + path.sep,
37
+ path.join(home, '.aws') + path.sep,
38
+ path.join(home, '.gnupg') + path.sep,
39
+ '/etc/',
40
+ '/boot/',
41
+ '/sys/',
42
+ '/proc/',
43
+ ];
44
+ for (const b of banned) {
45
+ if (resolved === b.slice(0, -1) || resolved.startsWith(b)) return false;
46
+ }
47
+ const cwd = process.cwd();
48
+ const cwdPrefix = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
49
+ return resolved === cwd || resolved.startsWith(cwdPrefix);
50
+ }
51
+
52
+ function _sandboxError(filePath) {
53
+ return { error: `Path outside allowed area: ${filePath}. Use --allow-anywhere to override.` };
54
+ }
55
+
56
+ // Cheap ReDoS guard. Rejects pathologically long patterns, common
57
+ // catastrophic-backtracking anti-patterns, and pattern×data sizes large
58
+ // enough to hang the regex engine.
59
+ function _checkRegexSafety(pattern, data) {
60
+ if (typeof pattern !== 'string') return null;
61
+ if (pattern.length > 1000) {
62
+ return { error: 'Pattern rejected: length exceeds 1000 chars' };
63
+ }
64
+ if (/(\(.*[+*].*\).*[+*])|(\[.*\].*[+*].*[+*])/.test(pattern)) {
65
+ return { error: 'Pattern rejected: potentially catastrophic backtracking' };
66
+ }
67
+ const dataLen = typeof data === 'string' ? data.length : 0;
68
+ if (dataLen * pattern.length > 10_000_000) {
69
+ return { error: 'Pattern too complex for input size' };
70
+ }
71
+ return null;
72
+ }
28
73
 
29
74
  function createToolExecutor(permissionManager, ui, getConfig) {
30
75
  const { BOLD, DIM, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, renderDiff } = ui;
76
+ // Continuation lines in a system-message bubble (chat-history.js else branch)
77
+ // are indented by 5 spaces. Let the diff renderer reserve those columns so
78
+ // its lines don't auto-wrap inside the bubble.
79
+ const DIFF_BUBBLE_INSET = 5;
31
80
 
32
81
  function _parseNumberedOptions(text) {
33
82
  const options = [];
@@ -52,20 +101,43 @@ function createToolExecutor(permissionManager, ui, getConfig) {
52
101
  return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
53
102
  }
54
103
 
55
- try {
56
- const cfg = getConfig ? getConfig() : {};
57
- const timeout = cfg.command_timeout_ms || 30000;
58
- const result = spawnSync(command, { shell: true, encoding: 'utf8', timeout });
59
- const stdout = result.stdout || '';
60
- const stderr = result.stderr || '';
61
- const exitCode = result.status ?? 0;
62
- logToolCall('exec', { command }, true, exitCode === 0 ? 'ok' : 'error');
63
- return { exit_code: exitCode, stdout, stderr };
64
- } catch (error) {
65
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
66
- logToolCall('exec', { command }, true, 'error');
67
- return { exit_code: -1, stdout: '', stderr: error.message };
68
- }
104
+ const cfg = getConfig ? getConfig() : {};
105
+ const timeout = cfg.command_timeout_ms || 30000;
106
+
107
+ return new Promise((resolve) => {
108
+ let child;
109
+ try {
110
+ child = spawn(command, { shell: true });
111
+ } catch (error) {
112
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
113
+ logToolCall('exec', { command }, true, 'error');
114
+ return resolve({ exit_code: -1, stdout: '', stderr: error.message });
115
+ }
116
+ let stdout = '';
117
+ let stderr = '';
118
+ let killed = false;
119
+ const timer = setTimeout(() => {
120
+ killed = true;
121
+ try { child.kill('SIGTERM'); } catch {}
122
+ }, timeout);
123
+ child.stdout.setEncoding('utf8');
124
+ child.stderr.setEncoding('utf8');
125
+ child.stdout.on('data', (c) => { stdout += c; });
126
+ child.stderr.on('data', (c) => { stderr += c; });
127
+ child.on('error', (error) => {
128
+ clearTimeout(timer);
129
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
130
+ logToolCall('exec', { command }, true, 'error');
131
+ resolve({ exit_code: -1, stdout, stderr: stderr || error.message });
132
+ });
133
+ child.on('close', (code, signal) => {
134
+ clearTimeout(timer);
135
+ if (killed) stderr += (stderr ? '\n' : '') + `[timed out after ${timeout}ms]`;
136
+ const exit_code = killed ? -1 : (code != null ? code : (signal ? -1 : 0));
137
+ logToolCall('exec', { command }, true, exit_code === 0 ? 'ok' : 'error');
138
+ resolve({ exit_code, stdout, stderr });
139
+ });
140
+ });
69
141
  }
70
142
 
71
143
  async function agentExecFile(action, ...args) {
@@ -73,8 +145,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
73
145
 
74
146
  if (action === 'read') {
75
147
  const filePath = arg0;
76
- try {
77
- const stat = fs.statSync(filePath);
148
+ const stat = await fsp.stat(filePath).catch(() => null);
149
+ if (stat) {
78
150
  const cfg = getConfig ? getConfig() : {};
79
151
  const maxBytes = (cfg.max_file_size_kb || 512) * 1024;
80
152
  if (stat.size > maxBytes) {
@@ -82,16 +154,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
82
154
  logToolCall('read_file', { path: filePath }, false, 'error');
83
155
  return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
84
156
  }
85
- } catch {
86
- // file doesn't exist yet — readFileSync will report it
87
- }
88
- const approved = await permissionManager.askPermission('file', `Read ${filePath}`, 'read_file');
89
- if (!approved) {
90
- logToolCall('read_file', { path: filePath }, false, 'denied');
91
- return { error: 'Permission denied' };
92
157
  }
93
158
  try {
94
- const data = fs.readFileSync(filePath, 'utf8');
159
+ const data = await fsp.readFile(filePath, 'utf8');
95
160
  const lines = data.split('\n').length;
96
161
  if (lines > 10) {
97
162
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
@@ -118,9 +183,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
118
183
  return blocked;
119
184
  }
120
185
 
186
+ if (!isPathSafe(filePath)) {
187
+ logToolCall(tag, { path: filePath }, false, 'denied');
188
+ return _sandboxError(filePath);
189
+ }
190
+
121
191
  // Read existing content for diff display
122
192
  let existing = '';
123
- try { existing = fs.readFileSync(filePath, 'utf8'); } catch {}
193
+ try { existing = await fsp.readFile(filePath, 'utf8'); } catch {}
124
194
 
125
195
  // For append the final state is existing + new content
126
196
  const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
@@ -129,7 +199,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
129
199
  // collide with the live chat-history/status-bar redraw, so we route the
130
200
  // diff into the permission description instead (where it renders inside
131
201
  // the permission bubble and is safely truncated by MAX_DESC_LINES).
132
- const diffOutput = renderDiff(existing, finalContent, filePath);
202
+ const diffOutput = _uiActive
203
+ ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
204
+ : renderDiff(existing, finalContent, filePath);
133
205
  if (!_uiActive) process.stdout.write(diffOutput + '\n');
134
206
 
135
207
  // Dry-run: record the skipped op and return without writing
@@ -151,9 +223,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
151
223
  }
152
224
  try {
153
225
  const dir = path.dirname(filePath);
154
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
155
- if (action === 'write') fs.writeFileSync(filePath, content || '');
156
- else fs.appendFileSync(filePath, content || '');
226
+ if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
227
+ if (action === 'write') await fsp.writeFile(filePath, content || '');
228
+ else await fsp.appendFile(filePath, content || '');
157
229
  const verb = action === 'write' ? 'Wrote' : 'Appended to';
158
230
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
159
231
  logToolCall(tag, { path: filePath, content }, true, 'ok');
@@ -167,13 +239,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
167
239
 
168
240
  if (action === 'list_dir') {
169
241
  const dirPath = arg0;
170
- const approved = await permissionManager.askPermission('file', `List ${dirPath}`, 'list_dir');
171
- if (!approved) {
172
- logToolCall('list_dir', { path: dirPath }, false, 'denied');
173
- return { error: 'Permission denied' };
174
- }
175
242
  try {
176
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
243
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
177
244
  const items = entries.map((e) => {
178
245
  if (e.isSymbolicLink()) return `[L] ${e.name}`;
179
246
  if (e.isDirectory()) return `[D] ${e.name}`;
@@ -198,6 +265,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
198
265
  return blocked;
199
266
  }
200
267
 
268
+ if (!isPathSafe(filePath)) {
269
+ logToolCall('delete_file', { path: filePath }, false, 'denied');
270
+ return _sandboxError(filePath);
271
+ }
272
+
201
273
  _log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
202
274
 
203
275
  const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
@@ -206,7 +278,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
206
278
  return { error: 'Permission denied' };
207
279
  }
208
280
  try {
209
- fs.unlinkSync(filePath);
281
+ await fsp.unlink(filePath);
210
282
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
211
283
  logToolCall('delete_file', { path: filePath }, true, 'ok');
212
284
  return { status: 'ok', path: filePath };
@@ -219,13 +291,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
219
291
 
220
292
  if (action === 'make_dir') {
221
293
  const dirPath = arg0;
294
+ if (!isPathSafe(dirPath)) {
295
+ logToolCall('make_dir', { path: dirPath }, false, 'denied');
296
+ return _sandboxError(dirPath);
297
+ }
222
298
  const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
223
299
  if (!approved) {
224
300
  logToolCall('make_dir', { path: dirPath }, false, 'denied');
225
301
  return { error: 'Permission denied' };
226
302
  }
227
303
  try {
228
- fs.mkdirSync(dirPath, { recursive: true });
304
+ await fsp.mkdir(dirPath, { recursive: true });
229
305
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
230
306
  logToolCall('make_dir', { path: dirPath }, true, 'ok');
231
307
  return { status: 'ok', path: dirPath };
@@ -238,13 +314,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
238
314
 
239
315
  if (action === 'remove_dir') {
240
316
  const dirPath = arg0;
317
+ if (!isPathSafe(dirPath)) {
318
+ logToolCall('remove_dir', { path: dirPath }, false, 'denied');
319
+ return _sandboxError(dirPath);
320
+ }
241
321
  const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
242
322
  if (!approved) {
243
323
  logToolCall('remove_dir', { path: dirPath }, false, 'denied');
244
324
  return { error: 'Permission denied' };
245
325
  }
246
326
  try {
247
- fs.rmSync(dirPath, { recursive: true, force: true });
327
+ await fsp.rm(dirPath, { recursive: true, force: true });
248
328
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
249
329
  logToolCall('remove_dir', { path: dirPath }, true, 'ok');
250
330
  return { status: 'ok', path: dirPath };
@@ -265,6 +345,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
265
345
  return blocked;
266
346
  }
267
347
 
348
+ if (!isPathSafe(dst)) {
349
+ logToolCall('move_file', { src, dst }, false, 'denied');
350
+ return _sandboxError(dst);
351
+ }
352
+
268
353
  _log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
269
354
 
270
355
  const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
@@ -274,14 +359,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
274
359
  }
275
360
  try {
276
361
  const dstDir = path.dirname(dst);
277
- if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
362
+ if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
278
363
  try {
279
- fs.renameSync(src, dst);
364
+ await fsp.rename(src, dst);
280
365
  } catch (renameErr) {
281
366
  if (renameErr.code !== 'EXDEV') throw renameErr;
282
367
  // Cross-device rename not supported — copy then remove
283
- fs.cpSync(src, dst, { recursive: true });
284
- fs.rmSync(src, { recursive: true, force: true });
368
+ await fsp.cp(src, dst, { recursive: true });
369
+ await fsp.rm(src, { recursive: true, force: true });
285
370
  }
286
371
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
287
372
  logToolCall('move_file', { src, dst }, true, 'ok');
@@ -303,6 +388,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
303
388
  return blocked;
304
389
  }
305
390
 
391
+ if (!isPathSafe(dst)) {
392
+ logToolCall('copy_file', { src, dst }, false, 'denied');
393
+ return _sandboxError(dst);
394
+ }
395
+
306
396
  const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
307
397
  if (!approved) {
308
398
  logToolCall('copy_file', { src, dst }, false, 'denied');
@@ -310,8 +400,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
310
400
  }
311
401
  try {
312
402
  const dstDir = path.dirname(dst);
313
- if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
314
- fs.cpSync(src, dst, { recursive: true });
403
+ if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
404
+ await fsp.cp(src, dst, { recursive: true });
315
405
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Copied ${src} → ${dst}${RST}`);
316
406
  logToolCall('copy_file', { src, dst }, true, 'ok');
317
407
  return { status: 'ok', src, dst };
@@ -332,14 +422,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
332
422
  return { error: 'Permission denied' };
333
423
  }
334
424
  try {
335
- const data = fs.readFileSync(filePath, 'utf8');
425
+ const data = await fsp.readFile(filePath, 'utf8');
336
426
  const lines = data.split('\n');
337
427
  if (lineNum < 1 || lineNum > lines.length) {
338
428
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
339
429
  return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
340
430
  }
341
431
  lines[lineNum - 1] = newContent;
342
- fs.writeFileSync(filePath, lines.join('\n'));
432
+ await fsp.writeFile(filePath, lines.join('\n'));
343
433
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
344
434
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
345
435
  return { status: 'ok', path: filePath, line: lineNum };
@@ -353,13 +443,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
353
443
  if (action === 'search_in_file') {
354
444
  const filePath = arg0;
355
445
  const pattern = arg1;
356
- const approved = await permissionManager.askPermission('file', `Search in ${filePath}`, 'search_in_file');
357
- if (!approved) {
358
- logToolCall('search_in_file', { path: filePath, pattern }, false, 'denied');
359
- return { error: 'Permission denied' };
360
- }
361
446
  try {
362
- const data = fs.readFileSync(filePath, 'utf8');
447
+ const data = await fsp.readFile(filePath, 'utf8');
448
+ const guardErr = _checkRegexSafety(pattern, data);
449
+ if (guardErr) {
450
+ logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
451
+ return guardErr;
452
+ }
363
453
  const regex = new RegExp(pattern);
364
454
  const matches = data.split('\n')
365
455
  .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
@@ -385,13 +475,18 @@ function createToolExecutor(permissionManager, ui, getConfig) {
385
475
  return { error: 'Permission denied' };
386
476
  }
387
477
  try {
388
- const data = fs.readFileSync(filePath, 'utf8');
478
+ const data = await fsp.readFile(filePath, 'utf8');
479
+ const guardErr = _checkRegexSafety(searchStr, data);
480
+ if (guardErr) {
481
+ logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
482
+ return guardErr;
483
+ }
389
484
  const safeFlags = flags.replace(/[^gimsuy]/g, '');
390
485
  const countFlags = safeFlags.includes('g') ? safeFlags : safeFlags + 'g';
391
486
  const count = (data.match(new RegExp(searchStr, countFlags)) || []).length;
392
487
  const regex = new RegExp(searchStr, safeFlags || undefined);
393
488
  const newData = data.replace(regex, replaceStr);
394
- fs.writeFileSync(filePath, newData);
489
+ await fsp.writeFile(filePath, newData);
395
490
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
396
491
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
397
492
  return { status: 'ok', path: filePath, count };
@@ -405,11 +500,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
405
500
  if (action === 'search_files') {
406
501
  const pattern = arg0;
407
502
  const searchDir = arg1 || '.';
408
- const approved = await permissionManager.askPermission('file', `Search files: ${pattern} in ${searchDir}`, 'search_files');
409
- if (!approved) {
410
- logToolCall('search_files', { pattern, dir: searchDir }, false, 'denied');
411
- return { error: 'Permission denied' };
412
- }
413
503
  try {
414
504
  let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
415
505
  regStr = regStr.replace(/\*\*/g, '\x00');
@@ -419,16 +509,16 @@ function createToolExecutor(permissionManager, ui, getConfig) {
419
509
  const regex = new RegExp(`^${regStr}$`);
420
510
  const matchName = !pattern.includes('/');
421
511
  const files = [];
422
- function walk(dir, rel) {
512
+ async function walk(dir, rel) {
423
513
  let entries;
424
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
514
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
425
515
  for (const entry of entries) {
426
516
  const relPath = rel ? `${rel}/${entry.name}` : entry.name;
427
517
  if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
428
- if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
518
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
429
519
  }
430
520
  }
431
- walk(searchDir, '');
521
+ await walk(searchDir, '');
432
522
  files.sort();
433
523
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
434
524
  logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
@@ -442,13 +532,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
442
532
 
443
533
  if (action === 'file_stat') {
444
534
  const filePath = arg0;
445
- const approved = await permissionManager.askPermission('file', `Stat ${filePath}`, 'file_stat');
446
- if (!approved) {
447
- logToolCall('file_stat', { path: filePath }, false, 'denied');
448
- return { error: 'Permission denied' };
449
- }
450
535
  try {
451
- const stat = fs.statSync(filePath);
536
+ const stat = await fsp.stat(filePath);
452
537
  const type = stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file';
453
538
  const size_kb = (stat.size / 1024).toFixed(2);
454
539
  const mode = '0o' + stat.mode.toString(8);
@@ -474,7 +559,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
474
559
  if (action === 'set_env') {
475
560
  const varName = arg0;
476
561
  const value = arg1 || '';
477
- const approved = await permissionManager.askPermission('shell', `Set env ${varName}=${value}`, 'set_env');
562
+ const approved = await permissionManager.askPermission('env', `Set env ${varName}=${value}`, 'set_env');
478
563
  if (!approved) {
479
564
  logToolCall('set_env', { name: varName }, false, 'denied');
480
565
  return { error: 'Permission denied' };
@@ -499,7 +584,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
499
584
  fileName = 'download';
500
585
  }
501
586
  const outPath = path.join(process.cwd(), fileName);
502
- const approved = await permissionManager.askPermission('shell', `Download ${url}`, 'download');
587
+ const approved = await permissionManager.askPermission('net', `Download ${url}`, 'download');
503
588
  if (!approved) {
504
589
  logToolCall('download', { url }, false, 'denied');
505
590
  return { error: 'Permission denied' };
@@ -554,6 +639,10 @@ function createToolExecutor(permissionManager, ui, getConfig) {
554
639
  if (action === 'upload') {
555
640
  const filePath = arg0;
556
641
  const encodedContent = arg1 || '';
642
+ if (!isPathSafe(filePath)) {
643
+ logToolCall('upload', { path: filePath }, false, 'denied');
644
+ return _sandboxError(filePath);
645
+ }
557
646
  const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
558
647
  if (!approved) {
559
648
  logToolCall('upload', { path: filePath }, false, 'denied');
@@ -561,9 +650,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
561
650
  }
562
651
  try {
563
652
  const dir = path.dirname(filePath);
564
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
653
+ if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
565
654
  const buffer = Buffer.from(encodedContent.trim(), 'base64');
566
- fs.writeFileSync(filePath, buffer);
655
+ await fsp.writeFile(filePath, buffer);
567
656
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Uploaded ${buffer.length} bytes to ${filePath}${RST}`);
568
657
  logToolCall('upload', { path: filePath }, true, 'ok');
569
658
  return { status: 'ok', path: filePath, bytes: buffer.length };
@@ -574,69 +663,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
574
663
  }
575
664
  }
576
665
 
577
- function buildHttpResult(url, statusCode, body, raw) {
578
- // Strip HTML markup so the LLM receives readable text instead of raw HTML.
579
- // Reduces a typical 150k-char page to ~25-40k chars, cutting chunk count
580
- // from ~16 to ~3 and preventing context-overflow re-fetch loops.
581
- // Pass raw=true to skip stripping (e.g. when the agent needs to parse markup).
582
- const looksLikeHtml = !raw && (/^\s*<!doctype\s+html/i.test(body) || /^\s*<html[\s>]/i.test(body));
583
- const processedBody = looksLikeHtml
584
- ? body
585
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
586
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
587
- .replace(/<[^>]+>/g, ' ')
588
- .replace(/&nbsp;/gi, ' ')
589
- .replace(/&amp;/gi, '&')
590
- .replace(/&lt;/gi, '<')
591
- .replace(/&gt;/gi, '>')
592
- .replace(/&quot;/gi, '"')
593
- .replace(/&#x?[\da-f]+;/gi, ' ')
594
- .replace(/\s{2,}/g, ' ')
595
- .trim()
596
- : body;
597
-
598
- if (processedBody.length > HTTP_CHUNK_CHARS) {
599
- const chunks = [];
600
- for (let i = 0; i < processedBody.length; i += HTTP_CHUNK_CHARS) {
601
- chunks.push(processedBody.slice(i, i + HTTP_CHUNK_CHARS));
602
- }
603
- _httpChunkStore.set(url, { chunks: chunks.slice(1), total: chunks.length, delivered: 1 });
604
- return { status_code: statusCode, body: chunks[0], chunked: true, part: 1, total_parts: chunks.length, key: url };
605
- }
606
- _httpChunkStore.delete(url);
607
- return { status_code: statusCode, body: processedBody };
608
- }
609
-
610
- if (action === 'http_get_next') {
611
- const key = arg0;
612
- const store = _httpChunkStore.get(key);
613
- if (!store || store.chunks.length === 0) {
614
- _httpChunkStore.delete(key);
615
- return { key, body: '', part: null, total_parts: null, done: true };
616
- }
617
- const nextChunk = store.chunks[0];
618
- store.chunks = store.chunks.slice(1);
619
- store.delivered += 1;
620
- const done = store.chunks.length === 0;
621
- if (done) _httpChunkStore.delete(key);
622
- return { key, body: nextChunk, part: store.delivered, total_parts: store.total, done };
623
- }
624
-
625
666
  if (action === 'http_get') {
626
667
  const url = arg0;
627
- const rawHtml = arg1 === 'true';
628
668
  if (_dryRun) {
629
669
  _skippedOps.push({ category: 'net', symbol: '↓', desc: `GET ${url}` });
630
670
  logToolCall('http_get', { url }, false, 'dry-run');
631
671
  return { status: 'dry-run', message: 'dry-run: network call skipped' };
632
672
  }
633
- const approved = await permissionManager.askPermission('shell', `HTTP GET ${url}`, 'http_get');
673
+ const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
634
674
  if (!approved) {
635
675
  logToolCall('http_get', { url }, false, 'denied');
636
676
  return { error: 'Permission denied' };
637
677
  }
638
678
  const httpCfg = getConfig ? getConfig() : {};
639
679
  const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
680
+ const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
640
681
  return new Promise((resolve) => {
641
682
  function doGet(target, redirectsLeft) {
642
683
  const proto = target.startsWith('https') ? https : http;
@@ -645,13 +686,36 @@ function createToolExecutor(permissionManager, ui, getConfig) {
645
686
  res.resume();
646
687
  return doGet(res.headers.location, redirectsLeft - 1);
647
688
  }
648
- let data = '';
649
- res.setEncoding('utf8');
650
- res.on('data', (chunk) => { data += chunk; });
689
+ const bufs = [];
690
+ let totalBytes = 0;
691
+ let capped = false;
692
+ res.on('data', (chunk) => {
693
+ totalBytes += chunk.length;
694
+ if (!capped) {
695
+ if (totalBytes <= maxBytes) {
696
+ bufs.push(chunk);
697
+ } else {
698
+ const keep = maxBytes - (totalBytes - chunk.length);
699
+ if (keep > 0) bufs.push(chunk.slice(0, keep));
700
+ capped = true;
701
+ // Keep the connection draining so totalBytes reflects reality,
702
+ // but stop buffering further bytes.
703
+ }
704
+ }
705
+ });
651
706
  res.on('end', () => {
652
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${data.length} chars)${RST}`);
707
+ const kept = Buffer.concat(bufs);
708
+ const keptBytes = kept.length;
709
+ let body = kept.toString('utf8');
710
+ if (capped) {
711
+ const origKb = (totalBytes / 1024).toFixed(0);
712
+ const keptKb = (keptBytes / 1024).toFixed(0);
713
+ const droppedKb = ((totalBytes - keptBytes) / 1024).toFixed(0);
714
+ body += `\n\n[... truncated: original was ${origKb}KB, showing first ${keptKb}KB. The remaining ${droppedKb}KB was discarded. If you need the rest, narrow your request (e.g. fetch a specific subpage) rather than retrying this URL.]`;
715
+ }
716
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${totalBytes} bytes${capped ? `, truncated to ${keptBytes}` : ''})${RST}`);
653
717
  logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
654
- resolve(buildHttpResult(url, res.statusCode, data, rawHtml));
718
+ resolve({ status_code: res.statusCode, body });
655
719
  });
656
720
  });
657
721
  req.on('error', (err) => {
@@ -671,7 +735,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
671
735
 
672
736
  if (action === 'ask_user') {
673
737
  const question = arg0;
674
- const approved = await permissionManager.askPermission('shell', `Ask user: ${question}`, 'ask_user');
738
+ const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
675
739
  if (!approved) {
676
740
  logToolCall('ask_user', { question }, false, 'denied');
677
741
  return { error: 'Permission denied' };
@@ -706,17 +770,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
706
770
  if (action === 'store_memory') {
707
771
  const key = arg0;
708
772
  const value = arg1 || '';
709
- const approved = await permissionManager.askPermission('file', `Store memory: ${key}`, 'store_memory');
773
+ const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
710
774
  if (!approved) {
711
775
  logToolCall('store_memory', { key }, false, 'denied');
712
776
  return { error: 'Permission denied' };
713
777
  }
714
778
  try {
715
779
  let mem = {};
716
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
780
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
717
781
  mem[key] = value;
718
- fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
719
- fs.writeFileSync(MEMORY_PATH, JSON.stringify(mem, null, 2));
782
+ await fsp.mkdir(path.dirname(MEMORY_PATH), { recursive: true });
783
+ await fsp.writeFile(MEMORY_PATH, JSON.stringify(mem, null, 2));
720
784
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Stored memory: ${key}${RST}`);
721
785
  logToolCall('store_memory', { key }, true, 'ok');
722
786
  return { status: 'ok', key };
@@ -729,14 +793,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
729
793
 
730
794
  if (action === 'recall_memory') {
731
795
  const key = arg0;
732
- const approved = await permissionManager.askPermission('file', `Recall memory: ${key}`, 'recall_memory');
733
- if (!approved) {
734
- logToolCall('recall_memory', { key }, false, 'denied');
735
- return { error: 'Permission denied' };
736
- }
737
796
  try {
738
797
  let mem = {};
739
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
798
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
740
799
  const found = key in mem;
741
800
  const value = found ? mem[key] : null;
742
801
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Recalled memory: ${key}${RST}`);
@@ -750,14 +809,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
750
809
  }
751
810
 
752
811
  if (action === 'list_memories') {
753
- const approved = await permissionManager.askPermission('file', 'List memories', 'list_memories');
754
- if (!approved) {
755
- logToolCall('list_memories', {}, false, 'denied');
756
- return { error: 'Permission denied' };
757
- }
758
812
  try {
759
813
  let mem = {};
760
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
814
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
761
815
  const keys = Object.keys(mem);
762
816
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Listed ${keys.length} memory key(s)${RST}`);
763
817
  logToolCall('list_memories', {}, true, 'ok');
@@ -770,11 +824,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
770
824
  }
771
825
 
772
826
  if (action === 'system_info') {
773
- const approved = await permissionManager.askPermission('shell', 'System info', 'system_info');
774
- if (!approved) {
775
- logToolCall('system_info', {}, false, 'denied');
776
- return { error: 'Permission denied' };
777
- }
778
827
  const info = {
779
828
  platform: os.platform(),
780
829
  arch: os.arch(),
@@ -848,9 +897,7 @@ function mapInvokeToCall(toolName, params) {
848
897
  case 'upload':
849
898
  return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
850
899
  case 'http_get':
851
- return p.url ? ['http_get', p.url, p.raw || ''] : null;
852
- case 'http_get_next':
853
- return p.key ? ['http_get_next', p.key] : null;
900
+ return p.url ? ['http_get', p.url] : null;
854
901
  case 'ask_user':
855
902
  return p.question ? ['ask_user', p.question] : null;
856
903
  case 'store_memory':
@@ -872,7 +919,74 @@ function mapInvokeToCall(toolName, params) {
872
919
  }
873
920
  }
874
921
 
875
- function extractToolCalls(text) {
922
+ // Compile a regex twice — once with double quotes, once with single — from a
923
+ // template where `Q` stands for the quote char. Matches from both variants
924
+ // are returned in a single iterable.
925
+ function _matchDual(text, template) {
926
+ const results = [];
927
+ for (const q of ['"', "'"]) {
928
+ const re = new RegExp(template.replace(/Q/g, q), 'g');
929
+ for (const m of text.matchAll(re)) results.push(m);
930
+ }
931
+ return results;
932
+ }
933
+
934
+ // Models sometimes wrap the inline body of a single-value tool tag in a nested
935
+ // pseudo-tag, e.g. `<list_dir><path>/tmp/foo</path></list_dir>` instead of the
936
+ // documented `<list_dir>/tmp/foo</list_dir>`. When the body is exactly one
937
+ // wrapper element (no siblings, no surrounding text), unwrap it once so the
938
+ // parser recovers the intended value. Safe to call on any inline-content body
939
+ // — a plain path/command/URL won't match the regex and is returned as-is.
940
+ function _unwrapInnerTag(inner) {
941
+ if (inner == null) return inner;
942
+ const trimmed = String(inner).trim();
943
+ const m = trimmed.match(/^<(\w+)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/);
944
+ if (!m) return inner;
945
+ return m[2].trim();
946
+ }
947
+
948
+ // MiniMax-M2 tool-call XML repair. Some inference backends — notably mlx-lm
949
+ // on Apple Silicon clusters (see ml-explore/mlx-lm#1145) — strip the leading
950
+ // `<` (or `</`) from `<invoke>` and `<parameter>` tags when sampling this
951
+ // model, producing malformed output like:
952
+ //
953
+ // <minimax:tool_call>
954
+ // invoke name="get_weather"> <-- `<` missing
955
+ // parameter name="x">v</parameter>
956
+ // invoke> <-- `</` missing
957
+ // </minimax:tool_call>
958
+ //
959
+ // Conservative repair: anchor each rewrite to the start of a line so parameter
960
+ // values that happen to contain the substring `parameter>` or `invoke>` mid-
961
+ // line are not corrupted. Limitation: a parameter value whose content begins a
962
+ // new line with exactly `invoke>` or `parameter>` at column 0 will still be
963
+ // rewritten — accepted as unfixable without full XML parsing. Opt-in via
964
+ // `repair_malformed_tool_xml`; silent text mutation is dangerous when wrong.
965
+ function repairMinimaxMalformedXml(text) {
966
+ if (typeof text !== 'string' || !text) return text;
967
+ return text
968
+ .replace(/^(\s*)invoke(\s+name=)/gm, '$1<invoke$2')
969
+ .replace(/^(\s*)parameter(\s+name=)/gm, '$1<parameter$2')
970
+ .replace(/^(\s*)invoke>/gm, '$1</invoke>')
971
+ .replace(/^(\s*)parameter>/gm, '$1</parameter>');
972
+ }
973
+
974
+ /**
975
+ * Parse tool-call tags out of assistant text.
976
+ *
977
+ * @param {string} text Assistant reply text to scan.
978
+ * @param {object} [options]
979
+ * @param {boolean} [options.repairMalformedXml] Run the MiniMax XML repair
980
+ * pass before parsing.
981
+ * @param {string} [options.model] Active model name. Accepted but unused
982
+ * today — reserved for future per-model preprocess hooks and per-model
983
+ * format prioritization (e.g. preferring JSON over XML for models that
984
+ * emit both).
985
+ * @param {object} [options.config] Resolved config. Same rationale as
986
+ * `model`: accepted for forward-compat, not consumed here yet.
987
+ */
988
+ function extractToolCalls(text, options = {}) {
989
+ if (options.repairMalformedXml) text = repairMinimaxMalformedXml(text);
876
990
  const calls = [];
877
991
 
878
992
  // MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
@@ -902,6 +1016,48 @@ function extractToolCalls(text) {
902
1016
  }
903
1017
  }
904
1018
 
1019
+ // Qwen3-Coder / Qwen3.5 XML tool-call format. Distinct from MiniMax and
1020
+ // Hermes: the tool name lives on the opening tag as an `=name` suffix
1021
+ // rather than a `name="..."` attribute, and parameters use the same `=key`
1022
+ // shape:
1023
+ //
1024
+ // <function=write_file>
1025
+ // <parameter=path>a.json</parameter>
1026
+ // <parameter=content>{"k":1}</parameter>
1027
+ // </function>
1028
+ //
1029
+ // Values are kept as raw strings. The vLLM reference parser consults the
1030
+ // tool's JSON schema to decide per-parameter whether to string/int/
1031
+ // literal_eval a value; `mapInvokeToCall` has no schema, so schema-guided
1032
+ // decoding isn't available here. The existing string-typed tools match the
1033
+ // MiniMax/Hermes convention (content for write_file, command for shell,
1034
+ // etc.) arrive as raw strings, which is also the vLLM default when a param
1035
+ // is declared string-typed — the dominant case.
1036
+ //
1037
+ // TODO: if a tool ever takes an object/array param, revisit and port the
1038
+ // schema-guided typing logic from
1039
+ // https://github.com/vllm-project/vllm/tree/main/vllm/tool_parsers
1040
+ // (qwen3xml_tool_parser.py → find_tool_properties / repair_param_type).
1041
+ //
1042
+ // Limitation: the non-greedy `[\s\S]*?` anchors on the first `</parameter>`
1043
+ // inside the value. A parameter whose value legitimately contains the
1044
+ // literal substring `</parameter>` — possible for a code-writing tool
1045
+ // emitting docs about this very format — will be truncated. We accept
1046
+ // this rather than attempt balanced/escaped matching.
1047
+ const QWEN3_FN_BLOCK_RE = /<function=([^\s>]+)\s*>([\s\S]*?)<\/function>/g;
1048
+ const QWEN3_PARAM_RE = /<parameter=([^\s>]+)\s*>([\s\S]*?)<\/parameter>/g;
1049
+ for (const fnMatch of text.matchAll(QWEN3_FN_BLOCK_RE)) {
1050
+ const params = {};
1051
+ for (const pMatch of fnMatch[2].matchAll(QWEN3_PARAM_RE)) {
1052
+ let val = pMatch[2];
1053
+ if (val.startsWith('\n')) val = val.slice(1);
1054
+ if (val.endsWith('\n')) val = val.slice(0, -1);
1055
+ params[pMatch[1]] = val;
1056
+ }
1057
+ const call = mapInvokeToCall(fnMatch[1], params);
1058
+ if (call) calls.push(call);
1059
+ }
1060
+
905
1061
  // Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
906
1062
  // and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
907
1063
  //
@@ -935,10 +1091,39 @@ function extractToolCalls(text) {
935
1091
  let parsed = null;
936
1092
  try { parsed = JSON.parse(inner); } catch {}
937
1093
  if (!parsed) {
1094
+ // Walk the string tracking quote/escape state and brace depth. Slice
1095
+ // the first balanced {...} block we find. Falls back to lastIndexOf
1096
+ // if the walker can't lock onto a balanced pair.
938
1097
  const firstBrace = inner.indexOf('{');
939
- const lastBrace = inner.lastIndexOf('}');
940
- if (firstBrace !== -1 && lastBrace > firstBrace) {
941
- try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
1098
+ if (firstBrace !== -1) {
1099
+ let depth = 0;
1100
+ let inString = false;
1101
+ let escaped = false;
1102
+ let endIdx = -1;
1103
+ for (let i = firstBrace; i < inner.length; i++) {
1104
+ const ch = inner[i];
1105
+ if (inString) {
1106
+ if (escaped) { escaped = false; continue; }
1107
+ if (ch === '\\') { escaped = true; continue; }
1108
+ if (ch === '"') { inString = false; }
1109
+ continue;
1110
+ }
1111
+ if (ch === '"') { inString = true; continue; }
1112
+ if (ch === '{') depth++;
1113
+ else if (ch === '}') {
1114
+ depth--;
1115
+ if (depth === 0) { endIdx = i; break; }
1116
+ }
1117
+ }
1118
+ if (endIdx !== -1) {
1119
+ try { parsed = JSON.parse(inner.slice(firstBrace, endIdx + 1)); } catch {}
1120
+ }
1121
+ if (!parsed) {
1122
+ const lastBrace = inner.lastIndexOf('}');
1123
+ if (lastBrace > firstBrace) {
1124
+ try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
1125
+ }
1126
+ }
942
1127
  }
943
1128
  }
944
1129
  if (!parsed) continue;
@@ -964,113 +1149,120 @@ function extractToolCalls(text) {
964
1149
  }
965
1150
 
966
1151
  for (const match of text.matchAll(/<(?:shell|exec|run_command|run)>([\s\S]*?)<\/(?:shell|exec|run_command|run)>/g)) {
967
- calls.push(['shell', match[1].trim()]);
1152
+ calls.push(['shell', _unwrapInnerTag(match[1]).trim()]);
968
1153
  }
969
1154
 
970
1155
  for (const match of text.matchAll(/<read_file>([\s\S]*?)<\/read_file>/g)) {
971
- calls.push(['read', match[1].trim()]);
1156
+ calls.push(['read', _unwrapInnerTag(match[1]).trim()]);
972
1157
  }
973
1158
 
974
- for (const match of text.matchAll(/<read_file\s+path="([^"]+)"\s*\/?>/g)) {
1159
+ for (const match of _matchDual(text, '<read_file\\s+path=Q([^Q]+)Q\\s*\\/?>')) {
975
1160
  calls.push(['read', match[1]]);
976
1161
  }
977
1162
 
978
- for (const match of text.matchAll(/<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g)) {
1163
+ for (const match of _matchDual(text, '<write_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/write_file>')) {
979
1164
  calls.push(['write', match[1], match[2]]);
980
1165
  }
981
1166
 
982
- for (const match of text.matchAll(/<create_file\s+path="([^"]+)">([\s\S]*?)<\/create_file>/g)) {
1167
+ for (const match of _matchDual(text, '<create_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/create_file>')) {
983
1168
  calls.push(['write', match[1], match[2]]);
984
1169
  }
985
1170
 
986
- for (const match of text.matchAll(/<append_file\s+path="([^"]+)">([\s\S]*?)<\/append_file>/g)) {
1171
+ for (const match of _matchDual(text, '<append_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/append_file>')) {
987
1172
  calls.push(['append', match[1], match[2]]);
988
1173
  }
989
1174
 
990
1175
  for (const match of text.matchAll(/<list_dir>([\s\S]*?)<\/list_dir>/g)) {
991
- calls.push(['list_dir', match[1].trim()]);
1176
+ calls.push(['list_dir', _unwrapInnerTag(match[1]).trim()]);
992
1177
  }
993
1178
 
994
1179
  for (const match of text.matchAll(/<search_files>([\s\S]*?)<\/search_files>/g)) {
995
- calls.push(['search_files', match[1].trim(), '.']);
1180
+ calls.push(['search_files', _unwrapInnerTag(match[1]).trim(), '.']);
996
1181
  }
997
1182
 
998
- for (const match of text.matchAll(/<search_files\s+pattern="([^"]+)"(?:\s+dir="([^"]*)")?\s*(?:><\/search_files>|\/>)/g)) {
1183
+ for (const match of _matchDual(text, '<search_files\\s+pattern=Q([^Q]+)Q(?:\\s+dir=Q([^Q]*)Q)?\\s*(?:><\\/search_files>|\\/>)')) {
999
1184
  calls.push(['search_files', match[1], match[2] || '.']);
1000
1185
  }
1001
1186
 
1002
1187
  for (const match of text.matchAll(/<delete_file>([\s\S]*?)<\/delete_file>/g)) {
1003
- calls.push(['delete_file', match[1].trim()]);
1188
+ calls.push(['delete_file', _unwrapInnerTag(match[1]).trim()]);
1004
1189
  }
1005
1190
 
1006
1191
  for (const match of text.matchAll(/<make_dir>([\s\S]*?)<\/make_dir>/g)) {
1007
- calls.push(['make_dir', match[1].trim()]);
1192
+ calls.push(['make_dir', _unwrapInnerTag(match[1]).trim()]);
1008
1193
  }
1009
1194
 
1010
1195
  for (const match of text.matchAll(/<remove_dir>([\s\S]*?)<\/remove_dir>/g)) {
1011
- calls.push(['remove_dir', match[1].trim()]);
1196
+ calls.push(['remove_dir', _unwrapInnerTag(match[1]).trim()]);
1012
1197
  }
1013
1198
 
1014
1199
  for (const match of text.matchAll(/<get_env>([\s\S]*?)<\/get_env>/g)) {
1015
- calls.push(['get_env', match[1].trim()]);
1200
+ calls.push(['get_env', _unwrapInnerTag(match[1]).trim()]);
1016
1201
  }
1017
1202
 
1018
- for (const match of text.matchAll(/<set_env\s+name="([^"]+)"\s+value="([^"]*)"\s*(?:><\/set_env>|\/>)/g)) {
1203
+ for (const match of _matchDual(text, '<set_env\\s+name=Q([^Q]+)Q\\s+value=Q([^Q]*)Q\\s*(?:><\\/set_env>|\\/>)')) {
1019
1204
  calls.push(['set_env', match[1], match[2]]);
1020
1205
  }
1021
1206
 
1022
- for (const match of text.matchAll(/<move_file\s+src="([^"]+)"\s+dst="([^"]+)"\s*(?:><\/move_file>|\/>)/g)) {
1207
+ for (const match of _matchDual(text, '<move_file\\s+src=Q([^Q]+)Q\\s+dst=Q([^Q]+)Q\\s*(?:><\\/move_file>|\\/>)')) {
1023
1208
  calls.push(['move_file', match[1], match[2]]);
1024
1209
  }
1025
1210
 
1026
- for (const match of text.matchAll(/<copy_file\s+src="([^"]+)"\s+dst="([^"]+)"\s*(?:><\/copy_file>|\/>)/g)) {
1211
+ for (const match of _matchDual(text, '<copy_file\\s+src=Q([^Q]+)Q\\s+dst=Q([^Q]+)Q\\s*(?:><\\/copy_file>|\\/>)')) {
1027
1212
  calls.push(['copy_file', match[1], match[2]]);
1028
1213
  }
1029
1214
 
1030
- for (const match of text.matchAll(/<edit_file\s+path="([^"]+)"\s+line="(\d+)">([\s\S]*?)<\/edit_file>/g)) {
1215
+ for (const match of _matchDual(text, '<edit_file\\s+path=Q([^Q]+)Q\\s+line=Q(\\d+)Q>([\\s\\S]*?)<\\/edit_file>')) {
1031
1216
  calls.push(['edit_file', match[1], parseInt(match[2], 10), match[3]]);
1032
1217
  }
1033
1218
 
1034
- for (const match of text.matchAll(/<search_in_file\s+path="([^"]+)">([\s\S]*?)<\/search_in_file>/g)) {
1219
+ for (const match of _matchDual(text, '<search_in_file\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/search_in_file>')) {
1035
1220
  calls.push(['search_in_file', match[1], match[2].trim()]);
1036
1221
  }
1037
1222
 
1038
- for (const match of text.matchAll(/<replace_in_file\s+path="([^"]+)"\s+search="([^"]*)"\s+replace="([^"]*)">([\s\S]*?)<\/replace_in_file>/g)) {
1223
+ for (const match of _matchDual(text, '<replace_in_file\\s+path=Q([^Q]+)Q\\s+search=Q([^Q]*)Q\\s+replace=Q([^Q]*)Q>([\\s\\S]*?)<\\/replace_in_file>')) {
1039
1224
  calls.push(['replace_in_file', match[1], match[2], match[3], match[4].trim()]);
1040
1225
  }
1041
1226
 
1042
1227
  for (const match of text.matchAll(/<download>([\s\S]*?)<\/download>/g)) {
1043
- calls.push(['download', match[1].trim()]);
1228
+ calls.push(['download', _unwrapInnerTag(match[1]).trim()]);
1044
1229
  }
1045
1230
 
1046
- for (const match of text.matchAll(/<upload\s+path="([^"]+)">([\s\S]*?)<\/upload>/g)) {
1231
+ for (const match of _matchDual(text, '<upload\\s+path=Q([^Q]+)Q>([\\s\\S]*?)<\\/upload>')) {
1047
1232
  calls.push(['upload', match[1], match[2]]);
1048
1233
  }
1049
1234
 
1050
1235
  for (const match of text.matchAll(/<file_stat>([\s\S]*?)<\/file_stat>/g)) {
1051
- calls.push(['file_stat', match[1].trim()]);
1236
+ calls.push(['file_stat', _unwrapInnerTag(match[1]).trim()]);
1052
1237
  }
1053
1238
 
1054
1239
  for (const match of text.matchAll(/<http_get\b([^>]*?)(?:><\/http_get>|\/>)/g)) {
1055
1240
  const attrStr = match[1];
1056
- const urlMatch = attrStr.match(/url="([^"]+)"/);
1057
- const rawMatch = attrStr.match(/raw="([^"]+)"/);
1058
- if (urlMatch) calls.push(['http_get', urlMatch[1], rawMatch ? rawMatch[1] : '']);
1241
+ const urlMatch = attrStr.match(/url="([^"]+)"/) || attrStr.match(/url='([^']+)'/);
1242
+ if (urlMatch) calls.push(['http_get', urlMatch[1]]);
1059
1243
  }
1060
1244
 
1061
- for (const match of text.matchAll(/<http_get_next\s+key="([^"]+)"\s*(?:><\/http_get_next>|\/>)/g)) {
1062
- calls.push(['http_get_next', match[1]]);
1245
+ // Inline-content form: <http_get>URL</http_get>. Models mirror the style of
1246
+ // <list_dir>, <download>, etc. even though the system prompt advertises the
1247
+ // attribute form — accept both so the second tag in a multi-call response
1248
+ // isn't silently dropped. Also tolerate `<http_get>url="URL"</http_get>` where
1249
+ // the model put the attribute syntax in the body.
1250
+ for (const match of text.matchAll(/<http_get>([\s\S]*?)<\/http_get>/g)) {
1251
+ const inner = match[1].trim();
1252
+ if (!inner) continue;
1253
+ const urlAttr = inner.match(/url="([^"]+)"/) || inner.match(/url='([^']+)'/);
1254
+ calls.push(['http_get', urlAttr ? urlAttr[1] : _unwrapInnerTag(inner).trim()]);
1063
1255
  }
1064
1256
 
1065
- for (const match of text.matchAll(/<ask_user\s+question="([^"]+)"\s*(?:><\/ask_user>|\/>)/g)) {
1257
+ for (const match of _matchDual(text, '<ask_user\\s+question=Q([^Q]+)Q\\s*(?:><\\/ask_user>|\\/>)')) {
1066
1258
  calls.push(['ask_user', match[1]]);
1067
1259
  }
1068
1260
 
1069
- for (const match of text.matchAll(/<store_memory\s+key="([^"]+)">([\s\S]*?)<\/store_memory>/g)) {
1261
+ for (const match of _matchDual(text, '<store_memory\\s+key=Q([^Q]+)Q>([\\s\\S]*?)<\\/store_memory>')) {
1070
1262
  calls.push(['store_memory', match[1], match[2]]);
1071
1263
  }
1072
1264
 
1073
- for (const match of text.matchAll(/<recall_memory\s+key="([^"]+)"\s*(?:><\/recall_memory>|\/>)/g)) {
1265
+ for (const match of _matchDual(text, '<recall_memory\\s+key=Q([^Q]+)Q\\s*(?:><\\/recall_memory>|\\/>)')) {
1074
1266
  calls.push(['recall_memory', match[1]]);
1075
1267
  }
1076
1268
 
@@ -1085,10 +1277,31 @@ function extractToolCalls(text) {
1085
1277
  return calls;
1086
1278
  }
1087
1279
 
1280
+ // Transform a TOOL_SPECS-shaped object into an OpenAI-format `tools` array
1281
+ // suitable for the `tools` field of a chat/completions request. Pure: no
1282
+ // filtering, no caching, no validation. Insertion order is preserved.
1283
+ function buildToolsSchema(toolSpecs) {
1284
+ const tools = [];
1285
+ for (const [name, spec] of Object.entries(toolSpecs)) {
1286
+ tools.push({
1287
+ type: 'function',
1288
+ function: {
1289
+ name,
1290
+ description: spec.description,
1291
+ parameters: spec.parameters,
1292
+ },
1293
+ });
1294
+ }
1295
+ return tools;
1296
+ }
1297
+
1088
1298
  module.exports = {
1299
+ buildToolsSchema,
1089
1300
  createToolExecutor,
1090
1301
  extractToolCalls,
1091
1302
  getSkippedOps,
1303
+ isUIActive,
1092
1304
  mapInvokeToCall,
1305
+ repairMinimaxMalformedXml,
1093
1306
  setUIActive,
1094
1307
  };