@semalt-ai/code 1.8.0 → 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,15 +183,26 @@ 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 || ''));
127
197
 
128
- const diffOutput = renderDiff(existing, finalContent, filePath);
129
- process.stdout.write(diffOutput + '\n');
198
+ // In CLI mode, print the diff inline. In TUI mode, direct stdout writes
199
+ // collide with the live chat-history/status-bar redraw, so we route the
200
+ // diff into the permission description instead (where it renders inside
201
+ // the permission bubble and is safely truncated by MAX_DESC_LINES).
202
+ const diffOutput = _uiActive
203
+ ? renderDiff(existing, finalContent, filePath, { inset: DIFF_BUBBLE_INSET })
204
+ : renderDiff(existing, finalContent, filePath);
205
+ if (!_uiActive) process.stdout.write(diffOutput + '\n');
130
206
 
131
207
  // Dry-run: record the skipped op and return without writing
132
208
  if (_dryRun) {
@@ -139,6 +215,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
139
215
  // Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
140
216
  let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
141
217
  if (content) desc += ` (${content.length} chars)`;
218
+ if (_uiActive) desc = `${desc}\n${diffOutput}`;
142
219
  const approved = await permissionManager.askPermission('file', desc, tag);
143
220
  if (!approved) {
144
221
  logToolCall(tag, { path: filePath, content }, false, 'denied');
@@ -146,9 +223,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
146
223
  }
147
224
  try {
148
225
  const dir = path.dirname(filePath);
149
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
150
- if (action === 'write') fs.writeFileSync(filePath, content || '');
151
- 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 || '');
152
229
  const verb = action === 'write' ? 'Wrote' : 'Appended to';
153
230
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
154
231
  logToolCall(tag, { path: filePath, content }, true, 'ok');
@@ -162,13 +239,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
162
239
 
163
240
  if (action === 'list_dir') {
164
241
  const dirPath = arg0;
165
- const approved = await permissionManager.askPermission('file', `List ${dirPath}`, 'list_dir');
166
- if (!approved) {
167
- logToolCall('list_dir', { path: dirPath }, false, 'denied');
168
- return { error: 'Permission denied' };
169
- }
170
242
  try {
171
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
243
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
172
244
  const items = entries.map((e) => {
173
245
  if (e.isSymbolicLink()) return `[L] ${e.name}`;
174
246
  if (e.isDirectory()) return `[D] ${e.name}`;
@@ -193,6 +265,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
193
265
  return blocked;
194
266
  }
195
267
 
268
+ if (!isPathSafe(filePath)) {
269
+ logToolCall('delete_file', { path: filePath }, false, 'denied');
270
+ return _sandboxError(filePath);
271
+ }
272
+
196
273
  _log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
197
274
 
198
275
  const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
@@ -201,7 +278,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
201
278
  return { error: 'Permission denied' };
202
279
  }
203
280
  try {
204
- fs.unlinkSync(filePath);
281
+ await fsp.unlink(filePath);
205
282
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
206
283
  logToolCall('delete_file', { path: filePath }, true, 'ok');
207
284
  return { status: 'ok', path: filePath };
@@ -214,13 +291,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
214
291
 
215
292
  if (action === 'make_dir') {
216
293
  const dirPath = arg0;
294
+ if (!isPathSafe(dirPath)) {
295
+ logToolCall('make_dir', { path: dirPath }, false, 'denied');
296
+ return _sandboxError(dirPath);
297
+ }
217
298
  const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
218
299
  if (!approved) {
219
300
  logToolCall('make_dir', { path: dirPath }, false, 'denied');
220
301
  return { error: 'Permission denied' };
221
302
  }
222
303
  try {
223
- fs.mkdirSync(dirPath, { recursive: true });
304
+ await fsp.mkdir(dirPath, { recursive: true });
224
305
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
225
306
  logToolCall('make_dir', { path: dirPath }, true, 'ok');
226
307
  return { status: 'ok', path: dirPath };
@@ -233,13 +314,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
233
314
 
234
315
  if (action === 'remove_dir') {
235
316
  const dirPath = arg0;
317
+ if (!isPathSafe(dirPath)) {
318
+ logToolCall('remove_dir', { path: dirPath }, false, 'denied');
319
+ return _sandboxError(dirPath);
320
+ }
236
321
  const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
237
322
  if (!approved) {
238
323
  logToolCall('remove_dir', { path: dirPath }, false, 'denied');
239
324
  return { error: 'Permission denied' };
240
325
  }
241
326
  try {
242
- fs.rmSync(dirPath, { recursive: true, force: true });
327
+ await fsp.rm(dirPath, { recursive: true, force: true });
243
328
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
244
329
  logToolCall('remove_dir', { path: dirPath }, true, 'ok');
245
330
  return { status: 'ok', path: dirPath };
@@ -260,6 +345,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
260
345
  return blocked;
261
346
  }
262
347
 
348
+ if (!isPathSafe(dst)) {
349
+ logToolCall('move_file', { src, dst }, false, 'denied');
350
+ return _sandboxError(dst);
351
+ }
352
+
263
353
  _log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
264
354
 
265
355
  const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
@@ -269,28 +359,22 @@ function createToolExecutor(permissionManager, ui, getConfig) {
269
359
  }
270
360
  try {
271
361
  const dstDir = path.dirname(dst);
272
- if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
273
- const cfg = getConfig ? getConfig() : {};
274
- const timeout = cfg.command_timeout_ms || 30000;
275
- const mvResult = spawnSync('mv', [src, dst], { encoding: 'utf8', timeout });
276
- if (mvResult.error && mvResult.error.code === 'ENOENT') throw new Error('mv not available');
277
- if (mvResult.error) throw mvResult.error;
278
- if (mvResult.status !== 0) throw new Error((mvResult.stderr || 'mv failed').trim());
362
+ if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
363
+ try {
364
+ await fsp.rename(src, dst);
365
+ } catch (renameErr) {
366
+ if (renameErr.code !== 'EXDEV') throw renameErr;
367
+ // Cross-device rename not supported — copy then remove
368
+ await fsp.cp(src, dst, { recursive: true });
369
+ await fsp.rm(src, { recursive: true, force: true });
370
+ }
279
371
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
280
372
  logToolCall('move_file', { src, dst }, true, 'ok');
281
373
  return { status: 'ok', src, dst };
282
- } catch (mvErr) {
283
- // Fallback: JS rename (works only within same filesystem)
284
- try {
285
- fs.renameSync(src, dst);
286
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
287
- logToolCall('move_file', { src, dst }, true, 'ok');
288
- return { status: 'ok', src, dst };
289
- } catch (error) {
290
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
291
- logToolCall('move_file', { src, dst }, true, 'error');
292
- return { error: error.message };
293
- }
374
+ } catch (error) {
375
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
376
+ logToolCall('move_file', { src, dst }, true, 'error');
377
+ return { error: error.message };
294
378
  }
295
379
  }
296
380
 
@@ -304,6 +388,11 @@ function createToolExecutor(permissionManager, ui, getConfig) {
304
388
  return blocked;
305
389
  }
306
390
 
391
+ if (!isPathSafe(dst)) {
392
+ logToolCall('copy_file', { src, dst }, false, 'denied');
393
+ return _sandboxError(dst);
394
+ }
395
+
307
396
  const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
308
397
  if (!approved) {
309
398
  logToolCall('copy_file', { src, dst }, false, 'denied');
@@ -311,8 +400,8 @@ function createToolExecutor(permissionManager, ui, getConfig) {
311
400
  }
312
401
  try {
313
402
  const dstDir = path.dirname(dst);
314
- if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
315
- fs.cpSync(src, dst, { recursive: true });
403
+ if (dstDir && dstDir !== '.') await fsp.mkdir(dstDir, { recursive: true });
404
+ await fsp.cp(src, dst, { recursive: true });
316
405
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Copied ${src} → ${dst}${RST}`);
317
406
  logToolCall('copy_file', { src, dst }, true, 'ok');
318
407
  return { status: 'ok', src, dst };
@@ -333,14 +422,14 @@ function createToolExecutor(permissionManager, ui, getConfig) {
333
422
  return { error: 'Permission denied' };
334
423
  }
335
424
  try {
336
- const data = fs.readFileSync(filePath, 'utf8');
425
+ const data = await fsp.readFile(filePath, 'utf8');
337
426
  const lines = data.split('\n');
338
427
  if (lineNum < 1 || lineNum > lines.length) {
339
428
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
340
429
  return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
341
430
  }
342
431
  lines[lineNum - 1] = newContent;
343
- fs.writeFileSync(filePath, lines.join('\n'));
432
+ await fsp.writeFile(filePath, lines.join('\n'));
344
433
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
345
434
  logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
346
435
  return { status: 'ok', path: filePath, line: lineNum };
@@ -354,42 +443,24 @@ function createToolExecutor(permissionManager, ui, getConfig) {
354
443
  if (action === 'search_in_file') {
355
444
  const filePath = arg0;
356
445
  const pattern = arg1;
357
- const approved = await permissionManager.askPermission('file', `Search in ${filePath}`, 'search_in_file');
358
- if (!approved) {
359
- logToolCall('search_in_file', { path: filePath, pattern }, false, 'denied');
360
- return { error: 'Permission denied' };
361
- }
362
446
  try {
363
- const cfg = getConfig ? getConfig() : {};
364
- const timeout = cfg.command_timeout_ms || 30000;
365
- const gr = spawnSync('grep', ['-n', '-E', '--', pattern, filePath], { encoding: 'utf8', timeout });
366
- if (gr.error && gr.error.code === 'ENOENT') throw new Error('grep not available');
367
- if (gr.error) throw gr.error;
368
- // grep exit 1 = no matches (not an error), 2 = real error
369
- if (gr.status === 2) throw new Error((gr.stderr || '').trim() || 'grep error');
370
- const matches = (gr.stdout || '').split('\n').filter(Boolean).map(line => {
371
- const colon = line.indexOf(':');
372
- return colon === -1 ? null : { line: parseInt(line.slice(0, colon), 10), content: line.slice(colon + 1) };
373
- }).filter(Boolean);
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
+ }
453
+ const regex = new RegExp(pattern);
454
+ const matches = data.split('\n')
455
+ .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
456
+ .filter(Boolean);
374
457
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
375
458
  logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
376
459
  return { matches, path: filePath };
377
- } catch (grepErr) {
378
- // Fallback: JS regex search
379
- try {
380
- const data = fs.readFileSync(filePath, 'utf8');
381
- const regex = new RegExp(pattern);
382
- const matches = data.split('\n')
383
- .map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
384
- .filter(Boolean);
385
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
386
- logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
387
- return { matches, path: filePath };
388
- } catch (error) {
389
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
390
- logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
391
- return { error: error.message };
392
- }
460
+ } catch (error) {
461
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
462
+ logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
463
+ return { error: error.message };
393
464
  }
394
465
  }
395
466
 
@@ -404,13 +475,18 @@ function createToolExecutor(permissionManager, ui, getConfig) {
404
475
  return { error: 'Permission denied' };
405
476
  }
406
477
  try {
407
- 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
+ }
408
484
  const safeFlags = flags.replace(/[^gimsuy]/g, '');
409
485
  const countFlags = safeFlags.includes('g') ? safeFlags : safeFlags + 'g';
410
486
  const count = (data.match(new RegExp(searchStr, countFlags)) || []).length;
411
487
  const regex = new RegExp(searchStr, safeFlags || undefined);
412
488
  const newData = data.replace(regex, replaceStr);
413
- fs.writeFileSync(filePath, newData);
489
+ await fsp.writeFile(filePath, newData);
414
490
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
415
491
  logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
416
492
  return { status: 'ok', path: filePath, count };
@@ -424,70 +500,40 @@ function createToolExecutor(permissionManager, ui, getConfig) {
424
500
  if (action === 'search_files') {
425
501
  const pattern = arg0;
426
502
  const searchDir = arg1 || '.';
427
- const approved = await permissionManager.askPermission('file', `Search files: ${pattern} in ${searchDir}`, 'search_files');
428
- if (!approved) {
429
- logToolCall('search_files', { pattern, dir: searchDir }, false, 'denied');
430
- return { error: 'Permission denied' };
431
- }
432
503
  try {
433
- const cfg = getConfig ? getConfig() : {};
434
- const timeout = cfg.command_timeout_ms || 30000;
435
- // Split glob: "src/**/*.js" → root=searchDir/src, nameGlob="*.js"
436
- const parts = pattern.split('/');
437
- const nameGlob = parts[parts.length - 1];
438
- const subDirs = parts.slice(0, -1).filter(p => p !== '**' && p !== '');
439
- const searchRoot = subDirs.length > 0 ? path.join(searchDir, ...subDirs) : searchDir;
440
- const fr = spawnSync('find', [searchRoot, '-type', 'f', '-name', nameGlob], { encoding: 'utf8', timeout });
441
- if (fr.error && fr.error.code === 'ENOENT') throw new Error('find not available');
442
- if (fr.error) throw fr.error;
443
- const files = (fr.stdout || '').split('\n').filter(Boolean)
444
- .map(f => path.relative(searchDir, f))
445
- .filter(Boolean)
446
- .sort();
504
+ let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
505
+ regStr = regStr.replace(/\*\*/g, '\x00');
506
+ regStr = regStr.replace(/\*/g, '[^/]*');
507
+ regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
508
+ regStr = regStr.replace(/\x00/g, '.*');
509
+ const regex = new RegExp(`^${regStr}$`);
510
+ const matchName = !pattern.includes('/');
511
+ const files = [];
512
+ async function walk(dir, rel) {
513
+ let entries;
514
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
515
+ for (const entry of entries) {
516
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
517
+ if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
518
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), relPath);
519
+ }
520
+ }
521
+ await walk(searchDir, '');
522
+ files.sort();
447
523
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
448
524
  logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
449
525
  return { files, pattern, dir: searchDir };
450
- } catch (_findErr) {
451
- // Fallback: JS glob walker
452
- try {
453
- let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
454
- regStr = regStr.replace(/\*\*/g, '\x00');
455
- regStr = regStr.replace(/\*/g, '[^/]*');
456
- regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
457
- regStr = regStr.replace(/\x00/g, '.*');
458
- const regex = new RegExp(`^${regStr}$`);
459
- const matchName = !pattern.includes('/');
460
- const files = [];
461
- function walk(dir, rel) {
462
- let entries;
463
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
464
- for (const entry of entries) {
465
- const relPath = rel ? `${rel}/${entry.name}` : entry.name;
466
- if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
467
- if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
468
- }
469
- }
470
- walk(searchDir, '');
471
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
472
- logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
473
- return { files, pattern, dir: searchDir };
474
- } catch (error) {
475
- _log(` ${FG_RED}✗ ${error.message}${RST}`);
476
- logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
477
- return { error: error.message };
478
- }
526
+ } catch (error) {
527
+ _log(` ${FG_RED}✗ ${error.message}${RST}`);
528
+ logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
529
+ return { error: error.message };
479
530
  }
480
531
  }
481
532
 
482
533
  if (action === 'file_stat') {
483
534
  const filePath = arg0;
484
- const approved = await permissionManager.askPermission('file', `Stat ${filePath}`, 'file_stat');
485
- if (!approved) {
486
- logToolCall('file_stat', { path: filePath }, false, 'denied');
487
- return { error: 'Permission denied' };
488
- }
489
535
  try {
490
- const stat = fs.statSync(filePath);
536
+ const stat = await fsp.stat(filePath);
491
537
  const type = stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file';
492
538
  const size_kb = (stat.size / 1024).toFixed(2);
493
539
  const mode = '0o' + stat.mode.toString(8);
@@ -513,7 +559,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
513
559
  if (action === 'set_env') {
514
560
  const varName = arg0;
515
561
  const value = arg1 || '';
516
- 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');
517
563
  if (!approved) {
518
564
  logToolCall('set_env', { name: varName }, false, 'denied');
519
565
  return { error: 'Permission denied' };
@@ -538,48 +584,65 @@ function createToolExecutor(permissionManager, ui, getConfig) {
538
584
  fileName = 'download';
539
585
  }
540
586
  const outPath = path.join(process.cwd(), fileName);
541
- const approved = await permissionManager.askPermission('shell', `Download ${url}`, 'download');
587
+ const approved = await permissionManager.askPermission('net', `Download ${url}`, 'download');
542
588
  if (!approved) {
543
589
  logToolCall('download', { url }, false, 'denied');
544
590
  return { error: 'Permission denied' };
545
591
  }
546
- const dlResult = spawnSync('curl', ['-sLo', outPath, url], { encoding: 'utf8', timeout: 120000 });
547
- if (!dlResult.error || dlResult.error.code !== 'ENOENT') {
548
- if (dlResult.error || dlResult.status !== 0) {
549
- try { fs.unlinkSync(outPath); } catch {}
550
- const msg = dlResult.error ? dlResult.error.message : (dlResult.stderr || 'curl failed').trim();
551
- _log(` ${FG_RED}✗ ${msg}${RST}`);
552
- logToolCall('download', { url }, true, 'error');
553
- return { error: msg };
554
- }
555
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
556
- logToolCall('download', { url }, true, 'ok');
557
- return { status: 'ok', path: outPath };
558
- }
559
- // Fallback: Node.js http/https
560
592
  return new Promise((resolve) => {
561
- const proto = url.startsWith('https') ? https : http;
562
- const file = fs.createWriteStream(outPath);
563
- proto.get(url, (res) => {
564
- res.pipe(file);
565
- file.on('finish', () => {
566
- file.close();
567
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
568
- logToolCall('download', { url }, true, 'ok');
569
- resolve({ status: 'ok', path: outPath });
593
+ function doDownload(target, redirectsLeft) {
594
+ const proto = target.startsWith('https') ? https : http;
595
+ const req = proto.get(target, (res) => {
596
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
597
+ res.resume();
598
+ return doDownload(res.headers.location, redirectsLeft - 1);
599
+ }
600
+ if (res.statusCode >= 400) {
601
+ res.resume();
602
+ const msg = `HTTP ${res.statusCode}`;
603
+ _log(` ${FG_RED}✗ ${msg}${RST}`);
604
+ logToolCall('download', { url }, true, 'error');
605
+ return resolve({ error: msg });
606
+ }
607
+ const file = fs.createWriteStream(outPath);
608
+ res.pipe(file);
609
+ file.on('finish', () => {
610
+ file.close();
611
+ _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
612
+ logToolCall('download', { url }, true, 'ok');
613
+ resolve({ status: 'ok', path: outPath });
614
+ });
615
+ file.on('error', (err) => {
616
+ fs.unlink(outPath, () => {});
617
+ _log(` ${FG_RED}✗ ${err.message}${RST}`);
618
+ logToolCall('download', { url }, true, 'error');
619
+ resolve({ error: err.message });
620
+ });
570
621
  });
571
- }).on('error', (err) => {
572
- fs.unlink(outPath, () => {});
573
- _log(` ${FG_RED}✗ ${err.message}${RST}`);
574
- logToolCall('download', { url }, true, 'error');
575
- resolve({ error: err.message });
576
- });
622
+ req.on('error', (err) => {
623
+ fs.unlink(outPath, () => {});
624
+ _log(` ${FG_RED}✗ ${err.message}${RST}`);
625
+ logToolCall('download', { url }, true, 'error');
626
+ resolve({ error: err.message });
627
+ });
628
+ req.setTimeout(120000, () => {
629
+ req.destroy();
630
+ fs.unlink(outPath, () => {});
631
+ logToolCall('download', { url }, true, 'error');
632
+ resolve({ error: 'Request timeout' });
633
+ });
634
+ }
635
+ doDownload(url, 5);
577
636
  });
578
637
  }
579
638
 
580
639
  if (action === 'upload') {
581
640
  const filePath = arg0;
582
641
  const encodedContent = arg1 || '';
642
+ if (!isPathSafe(filePath)) {
643
+ logToolCall('upload', { path: filePath }, false, 'denied');
644
+ return _sandboxError(filePath);
645
+ }
583
646
  const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
584
647
  if (!approved) {
585
648
  logToolCall('upload', { path: filePath }, false, 'denied');
@@ -587,9 +650,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
587
650
  }
588
651
  try {
589
652
  const dir = path.dirname(filePath);
590
- if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
653
+ if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
591
654
  const buffer = Buffer.from(encodedContent.trim(), 'base64');
592
- fs.writeFileSync(filePath, buffer);
655
+ await fsp.writeFile(filePath, buffer);
593
656
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Uploaded ${buffer.length} bytes to ${filePath}${RST}`);
594
657
  logToolCall('upload', { path: filePath }, true, 'ok');
595
658
  return { status: 'ok', path: filePath, bytes: buffer.length };
@@ -600,103 +663,59 @@ function createToolExecutor(permissionManager, ui, getConfig) {
600
663
  }
601
664
  }
602
665
 
603
- function buildHttpResult(url, statusCode, body, raw) {
604
- // Strip HTML markup so the LLM receives readable text instead of raw HTML.
605
- // Reduces a typical 150k-char page to ~25-40k chars, cutting chunk count
606
- // from ~16 to ~3 and preventing context-overflow re-fetch loops.
607
- // Pass raw=true to skip stripping (e.g. when the agent needs to parse markup).
608
- const looksLikeHtml = !raw && (/^\s*<!doctype\s+html/i.test(body) || /^\s*<html[\s>]/i.test(body));
609
- const processedBody = looksLikeHtml
610
- ? body
611
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
612
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
613
- .replace(/<[^>]+>/g, ' ')
614
- .replace(/&nbsp;/gi, ' ')
615
- .replace(/&amp;/gi, '&')
616
- .replace(/&lt;/gi, '<')
617
- .replace(/&gt;/gi, '>')
618
- .replace(/&quot;/gi, '"')
619
- .replace(/&#x?[\da-f]+;/gi, ' ')
620
- .replace(/\s{2,}/g, ' ')
621
- .trim()
622
- : body;
623
-
624
- if (processedBody.length > HTTP_CHUNK_CHARS) {
625
- const chunks = [];
626
- for (let i = 0; i < processedBody.length; i += HTTP_CHUNK_CHARS) {
627
- chunks.push(processedBody.slice(i, i + HTTP_CHUNK_CHARS));
628
- }
629
- _httpChunkStore.set(url, { chunks: chunks.slice(1), total: chunks.length, delivered: 1 });
630
- return { status_code: statusCode, body: chunks[0], chunked: true, part: 1, total_parts: chunks.length, key: url };
631
- }
632
- _httpChunkStore.delete(url);
633
- return { status_code: statusCode, body: processedBody };
634
- }
635
-
636
- if (action === 'http_get_next') {
637
- const key = arg0;
638
- const store = _httpChunkStore.get(key);
639
- if (!store || store.chunks.length === 0) {
640
- _httpChunkStore.delete(key);
641
- return { key, body: '', part: null, total_parts: null, done: true };
642
- }
643
- const nextChunk = store.chunks[0];
644
- store.chunks = store.chunks.slice(1);
645
- store.delivered += 1;
646
- const done = store.chunks.length === 0;
647
- if (done) _httpChunkStore.delete(key);
648
- return { key, body: nextChunk, part: store.delivered, total_parts: store.total, done };
649
- }
650
-
651
666
  if (action === 'http_get') {
652
667
  const url = arg0;
653
- const rawHtml = arg1 === 'true';
654
668
  if (_dryRun) {
655
669
  _skippedOps.push({ category: 'net', symbol: '↓', desc: `GET ${url}` });
656
670
  logToolCall('http_get', { url }, false, 'dry-run');
657
671
  return { status: 'dry-run', message: 'dry-run: network call skipped' };
658
672
  }
659
- const approved = await permissionManager.askPermission('shell', `HTTP GET ${url}`, 'http_get');
673
+ const approved = await permissionManager.askPermission('net', `HTTP GET ${url}`, 'http_get');
660
674
  if (!approved) {
661
675
  logToolCall('http_get', { url }, false, 'denied');
662
676
  return { error: 'Permission denied' };
663
677
  }
664
678
  const httpCfg = getConfig ? getConfig() : {};
665
- const curlTimeout = Math.max(15, Math.floor((httpCfg.request_timeout_ms || 15000) / 1000));
666
- // Try curl first: -sL follows redirects; -w appends status code on its own line
667
- const curlResult = spawnSync(
668
- 'curl', ['-sL', '--max-time', String(curlTimeout), '-w', '\n%{http_code}', url],
669
- { encoding: 'utf8', timeout: (curlTimeout + 5) * 1000 }
670
- );
671
- if (!curlResult.error || curlResult.error.code !== 'ENOENT') {
672
- if (curlResult.error) {
673
- _log(` ${FG_RED}✗ ${curlResult.error.message}${RST}`);
674
- logToolCall('http_get', { url }, true, 'error');
675
- return { error: curlResult.error.message };
676
- }
677
- const stdout = curlResult.stdout || '';
678
- const lastNl = stdout.lastIndexOf('\n');
679
- const body = lastNl >= 0 ? stdout.slice(0, lastNl) : stdout;
680
- const statusCode = parseInt((lastNl >= 0 ? stdout.slice(lastNl + 1) : '').trim(), 10) || 0;
681
- _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${url} (${statusCode}, ${body.length} chars)${RST}`);
682
- logToolCall('http_get', { url }, true, statusCode < 400 ? 'ok' : 'error');
683
- return buildHttpResult(url, statusCode, body, rawHtml);
684
- }
685
- // Fallback: Node.js http/https
679
+ const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
680
+ const maxBytes = Math.max(1024, httpCfg.http_fetch_max_bytes || 262144);
686
681
  return new Promise((resolve) => {
687
682
  function doGet(target, redirectsLeft) {
688
683
  const proto = target.startsWith('https') ? https : http;
689
684
  const req = proto.get(target, (res) => {
690
685
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
686
+ res.resume();
691
687
  return doGet(res.headers.location, redirectsLeft - 1);
692
688
  }
693
- let data = '';
694
- res.setEncoding('utf8');
695
- 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
+ });
696
706
  res.on('end', () => {
697
- _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}`);
698
717
  logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
699
- resolve(buildHttpResult(url, res.statusCode, data, rawHtml));
718
+ resolve({ status_code: res.statusCode, body });
700
719
  });
701
720
  });
702
721
  req.on('error', (err) => {
@@ -704,15 +723,19 @@ function createToolExecutor(permissionManager, ui, getConfig) {
704
723
  logToolCall('http_get', { url: target }, true, 'error');
705
724
  resolve({ error: err.message });
706
725
  });
707
- req.setTimeout(15000, () => { req.destroy(); logToolCall('http_get', { url: target }, true, 'error'); resolve({ error: 'Request timeout' }); });
726
+ req.setTimeout(reqTimeoutMs, () => {
727
+ req.destroy();
728
+ logToolCall('http_get', { url: target }, true, 'error');
729
+ resolve({ error: 'Request timeout' });
730
+ });
708
731
  }
709
- doGet(url, 3);
732
+ doGet(url, 5);
710
733
  });
711
734
  }
712
735
 
713
736
  if (action === 'ask_user') {
714
737
  const question = arg0;
715
- const approved = await permissionManager.askPermission('shell', `Ask user: ${question}`, 'ask_user');
738
+ const approved = await permissionManager.askPermission('user', `Ask user: ${question}`, 'ask_user');
716
739
  if (!approved) {
717
740
  logToolCall('ask_user', { question }, false, 'denied');
718
741
  return { error: 'Permission denied' };
@@ -747,17 +770,17 @@ function createToolExecutor(permissionManager, ui, getConfig) {
747
770
  if (action === 'store_memory') {
748
771
  const key = arg0;
749
772
  const value = arg1 || '';
750
- const approved = await permissionManager.askPermission('file', `Store memory: ${key}`, 'store_memory');
773
+ const approved = await permissionManager.askPermission('memory', `Store memory: ${key}`, 'store_memory');
751
774
  if (!approved) {
752
775
  logToolCall('store_memory', { key }, false, 'denied');
753
776
  return { error: 'Permission denied' };
754
777
  }
755
778
  try {
756
779
  let mem = {};
757
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
780
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
758
781
  mem[key] = value;
759
- fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
760
- 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));
761
784
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Stored memory: ${key}${RST}`);
762
785
  logToolCall('store_memory', { key }, true, 'ok');
763
786
  return { status: 'ok', key };
@@ -770,14 +793,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
770
793
 
771
794
  if (action === 'recall_memory') {
772
795
  const key = arg0;
773
- const approved = await permissionManager.askPermission('file', `Recall memory: ${key}`, 'recall_memory');
774
- if (!approved) {
775
- logToolCall('recall_memory', { key }, false, 'denied');
776
- return { error: 'Permission denied' };
777
- }
778
796
  try {
779
797
  let mem = {};
780
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
798
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
781
799
  const found = key in mem;
782
800
  const value = found ? mem[key] : null;
783
801
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Recalled memory: ${key}${RST}`);
@@ -791,14 +809,9 @@ function createToolExecutor(permissionManager, ui, getConfig) {
791
809
  }
792
810
 
793
811
  if (action === 'list_memories') {
794
- const approved = await permissionManager.askPermission('file', 'List memories', 'list_memories');
795
- if (!approved) {
796
- logToolCall('list_memories', {}, false, 'denied');
797
- return { error: 'Permission denied' };
798
- }
799
812
  try {
800
813
  let mem = {};
801
- try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
814
+ try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
802
815
  const keys = Object.keys(mem);
803
816
  _log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Listed ${keys.length} memory key(s)${RST}`);
804
817
  logToolCall('list_memories', {}, true, 'ok');
@@ -811,11 +824,6 @@ function createToolExecutor(permissionManager, ui, getConfig) {
811
824
  }
812
825
 
813
826
  if (action === 'system_info') {
814
- const approved = await permissionManager.askPermission('shell', 'System info', 'system_info');
815
- if (!approved) {
816
- logToolCall('system_info', {}, false, 'denied');
817
- return { error: 'Permission denied' };
818
- }
819
827
  const info = {
820
828
  platform: os.platform(),
821
829
  arch: os.arch(),
@@ -841,9 +849,298 @@ function createToolExecutor(permissionManager, ui, getConfig) {
841
849
  };
842
850
  }
843
851
 
844
- function extractToolCalls(text) {
852
+ // Map a MiniMax-style {name, params} invocation to the internal
853
+ // [action, arg1, arg2, …] call tuple consumed by the agent loop.
854
+ function mapInvokeToCall(toolName, params) {
855
+ const name = (toolName || '').toLowerCase();
856
+ const p = params || {};
857
+ switch (name) {
858
+ case 'write_file':
859
+ case 'create_file':
860
+ return p.path ? ['write', p.path, p.content != null ? p.content : ''] : null;
861
+ case 'read_file':
862
+ return p.path ? ['read', p.path] : null;
863
+ case 'append_file':
864
+ return p.path ? ['append', p.path, p.content != null ? p.content : ''] : null;
865
+ case 'delete_file':
866
+ return p.path ? ['delete_file', p.path] : null;
867
+ case 'list_dir':
868
+ return ['list_dir', p.path || p.dir || '.'];
869
+ case 'make_dir':
870
+ return p.path ? ['make_dir', p.path] : null;
871
+ case 'remove_dir':
872
+ return p.path ? ['remove_dir', p.path] : null;
873
+ case 'move_file':
874
+ return p.src && p.dst ? ['move_file', p.src, p.dst] : null;
875
+ case 'copy_file':
876
+ return p.src && p.dst ? ['copy_file', p.src, p.dst] : null;
877
+ case 'file_stat':
878
+ return p.path ? ['file_stat', p.path] : null;
879
+ case 'search_files':
880
+ return ['search_files', p.pattern || p.glob || '*', p.dir || '.'];
881
+ case 'search_in_file':
882
+ return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
883
+ case 'replace_in_file':
884
+ return p.path && p.search !== undefined
885
+ ? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || '']
886
+ : null;
887
+ case 'edit_file':
888
+ return p.path && p.line !== undefined
889
+ ? ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : '']
890
+ : null;
891
+ case 'get_env':
892
+ return p.name ? ['get_env', p.name] : null;
893
+ case 'set_env':
894
+ return p.name ? ['set_env', p.name, p.value != null ? p.value : ''] : null;
895
+ case 'download':
896
+ return p.url ? ['download', p.url] : null;
897
+ case 'upload':
898
+ return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
899
+ case 'http_get':
900
+ return p.url ? ['http_get', p.url] : null;
901
+ case 'ask_user':
902
+ return p.question ? ['ask_user', p.question] : null;
903
+ case 'store_memory':
904
+ return p.key ? ['store_memory', p.key, p.value != null ? p.value : ''] : null;
905
+ case 'recall_memory':
906
+ return p.key ? ['recall_memory', p.key] : null;
907
+ case 'list_memories':
908
+ return ['list_memories'];
909
+ case 'system_info':
910
+ return ['system_info'];
911
+ case 'exec':
912
+ case 'shell':
913
+ case 'run':
914
+ case 'run_command':
915
+ case 'bash':
916
+ return p.command ? ['shell', p.command] : null;
917
+ default:
918
+ return null;
919
+ }
920
+ }
921
+
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);
845
990
  const calls = [];
846
991
 
992
+ // MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
993
+ // inference server's tool parser is disabled, or round-tripped back into
994
+ // text by chatStream when delta.tool_calls is streamed.
995
+ //
996
+ // <minimax:tool_call> <qwen:tool_call>
997
+ // <invoke name="write_file"> <invoke name="write_file">
998
+ // <parameter name="path">… <parameter name="path">…
999
+ // </invoke> </invoke>
1000
+ // </minimax:tool_call> </qwen:tool_call>
1001
+ const INVOKE_RE = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/g;
1002
+ const PARAM_RE = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/g;
1003
+ const WRAPPER_BLOCK_RE = /<(?:minimax:tool_call|qwen:tool_call)>([\s\S]*?)<\/(?:minimax:tool_call|qwen:tool_call)>/g;
1004
+ for (const blockMatch of text.matchAll(WRAPPER_BLOCK_RE)) {
1005
+ const block = blockMatch[1];
1006
+ for (const invokeMatch of block.matchAll(INVOKE_RE)) {
1007
+ const params = {};
1008
+ for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
1009
+ let val = pMatch[2];
1010
+ if (val.startsWith('\n')) val = val.slice(1);
1011
+ if (val.endsWith('\n')) val = val.slice(0, -1);
1012
+ params[pMatch[1]] = val;
1013
+ }
1014
+ const call = mapInvokeToCall(invokeMatch[1], params);
1015
+ if (call) calls.push(call);
1016
+ }
1017
+ }
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
+
1061
+ // Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
1062
+ // and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
1063
+ //
1064
+ // <tool_call>
1065
+ // {"name": "write_file", "arguments": {"path": "a.css", "content": "…"}}
1066
+ // </tool_call>
1067
+ //
1068
+ // Some variants use <function_call> or the key `parameters` instead of
1069
+ // `arguments`. The block may also wrap <invoke> when the finetune follows
1070
+ // the MiniMax instruction template — handle both.
1071
+ const JSON_BLOCK_RE = /<(tool_call|function_call)>([\s\S]*?)<\/\1>/g;
1072
+ for (const blockMatch of text.matchAll(JSON_BLOCK_RE)) {
1073
+ const inner = blockMatch[2].trim();
1074
+ if (!inner) continue;
1075
+
1076
+ if (/<invoke\s/i.test(inner)) {
1077
+ for (const invokeMatch of inner.matchAll(INVOKE_RE)) {
1078
+ const params = {};
1079
+ for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
1080
+ let val = pMatch[2];
1081
+ if (val.startsWith('\n')) val = val.slice(1);
1082
+ if (val.endsWith('\n')) val = val.slice(0, -1);
1083
+ params[pMatch[1]] = val;
1084
+ }
1085
+ const call = mapInvokeToCall(invokeMatch[1], params);
1086
+ if (call) calls.push(call);
1087
+ }
1088
+ continue;
1089
+ }
1090
+
1091
+ let parsed = null;
1092
+ try { parsed = JSON.parse(inner); } catch {}
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.
1097
+ const firstBrace = inner.indexOf('{');
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
+ }
1127
+ }
1128
+ }
1129
+ if (!parsed) continue;
1130
+
1131
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
1132
+ for (const entry of entries) {
1133
+ if (!entry || typeof entry !== 'object') continue;
1134
+ const name = entry.name || entry.tool || entry.function || entry.tool_name;
1135
+ const params = entry.arguments || entry.parameters || entry.params || entry.args || {};
1136
+ const resolved = typeof params === 'string'
1137
+ ? (() => { try { return JSON.parse(params); } catch { return {}; } })()
1138
+ : params;
1139
+ const call = mapInvokeToCall(name, resolved);
1140
+ if (call) calls.push(call);
1141
+ }
1142
+ }
1143
+
847
1144
  for (const match of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
848
1145
  for (const line of match[1].trim().split('\n')) {
849
1146
  const cmd = line.trim();
@@ -852,113 +1149,120 @@ function extractToolCalls(text) {
852
1149
  }
853
1150
 
854
1151
  for (const match of text.matchAll(/<(?:shell|exec|run_command|run)>([\s\S]*?)<\/(?:shell|exec|run_command|run)>/g)) {
855
- calls.push(['shell', match[1].trim()]);
1152
+ calls.push(['shell', _unwrapInnerTag(match[1]).trim()]);
856
1153
  }
857
1154
 
858
1155
  for (const match of text.matchAll(/<read_file>([\s\S]*?)<\/read_file>/g)) {
859
- calls.push(['read', match[1].trim()]);
1156
+ calls.push(['read', _unwrapInnerTag(match[1]).trim()]);
860
1157
  }
861
1158
 
862
- 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*\\/?>')) {
863
1160
  calls.push(['read', match[1]]);
864
1161
  }
865
1162
 
866
- 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>')) {
867
1164
  calls.push(['write', match[1], match[2]]);
868
1165
  }
869
1166
 
870
- 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>')) {
871
1168
  calls.push(['write', match[1], match[2]]);
872
1169
  }
873
1170
 
874
- 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>')) {
875
1172
  calls.push(['append', match[1], match[2]]);
876
1173
  }
877
1174
 
878
1175
  for (const match of text.matchAll(/<list_dir>([\s\S]*?)<\/list_dir>/g)) {
879
- calls.push(['list_dir', match[1].trim()]);
1176
+ calls.push(['list_dir', _unwrapInnerTag(match[1]).trim()]);
880
1177
  }
881
1178
 
882
1179
  for (const match of text.matchAll(/<search_files>([\s\S]*?)<\/search_files>/g)) {
883
- calls.push(['search_files', match[1].trim(), '.']);
1180
+ calls.push(['search_files', _unwrapInnerTag(match[1]).trim(), '.']);
884
1181
  }
885
1182
 
886
- 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>|\\/>)')) {
887
1184
  calls.push(['search_files', match[1], match[2] || '.']);
888
1185
  }
889
1186
 
890
1187
  for (const match of text.matchAll(/<delete_file>([\s\S]*?)<\/delete_file>/g)) {
891
- calls.push(['delete_file', match[1].trim()]);
1188
+ calls.push(['delete_file', _unwrapInnerTag(match[1]).trim()]);
892
1189
  }
893
1190
 
894
1191
  for (const match of text.matchAll(/<make_dir>([\s\S]*?)<\/make_dir>/g)) {
895
- calls.push(['make_dir', match[1].trim()]);
1192
+ calls.push(['make_dir', _unwrapInnerTag(match[1]).trim()]);
896
1193
  }
897
1194
 
898
1195
  for (const match of text.matchAll(/<remove_dir>([\s\S]*?)<\/remove_dir>/g)) {
899
- calls.push(['remove_dir', match[1].trim()]);
1196
+ calls.push(['remove_dir', _unwrapInnerTag(match[1]).trim()]);
900
1197
  }
901
1198
 
902
1199
  for (const match of text.matchAll(/<get_env>([\s\S]*?)<\/get_env>/g)) {
903
- calls.push(['get_env', match[1].trim()]);
1200
+ calls.push(['get_env', _unwrapInnerTag(match[1]).trim()]);
904
1201
  }
905
1202
 
906
- 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>|\\/>)')) {
907
1204
  calls.push(['set_env', match[1], match[2]]);
908
1205
  }
909
1206
 
910
- 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>|\\/>)')) {
911
1208
  calls.push(['move_file', match[1], match[2]]);
912
1209
  }
913
1210
 
914
- 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>|\\/>)')) {
915
1212
  calls.push(['copy_file', match[1], match[2]]);
916
1213
  }
917
1214
 
918
- 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>')) {
919
1216
  calls.push(['edit_file', match[1], parseInt(match[2], 10), match[3]]);
920
1217
  }
921
1218
 
922
- 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>')) {
923
1220
  calls.push(['search_in_file', match[1], match[2].trim()]);
924
1221
  }
925
1222
 
926
- 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>')) {
927
1224
  calls.push(['replace_in_file', match[1], match[2], match[3], match[4].trim()]);
928
1225
  }
929
1226
 
930
1227
  for (const match of text.matchAll(/<download>([\s\S]*?)<\/download>/g)) {
931
- calls.push(['download', match[1].trim()]);
1228
+ calls.push(['download', _unwrapInnerTag(match[1]).trim()]);
932
1229
  }
933
1230
 
934
- 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>')) {
935
1232
  calls.push(['upload', match[1], match[2]]);
936
1233
  }
937
1234
 
938
1235
  for (const match of text.matchAll(/<file_stat>([\s\S]*?)<\/file_stat>/g)) {
939
- calls.push(['file_stat', match[1].trim()]);
1236
+ calls.push(['file_stat', _unwrapInnerTag(match[1]).trim()]);
940
1237
  }
941
1238
 
942
1239
  for (const match of text.matchAll(/<http_get\b([^>]*?)(?:><\/http_get>|\/>)/g)) {
943
1240
  const attrStr = match[1];
944
- const urlMatch = attrStr.match(/url="([^"]+)"/);
945
- const rawMatch = attrStr.match(/raw="([^"]+)"/);
946
- 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]]);
947
1243
  }
948
1244
 
949
- for (const match of text.matchAll(/<http_get_next\s+key="([^"]+)"\s*(?:><\/http_get_next>|\/>)/g)) {
950
- 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()]);
951
1255
  }
952
1256
 
953
- 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>|\\/>)')) {
954
1258
  calls.push(['ask_user', match[1]]);
955
1259
  }
956
1260
 
957
- 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>')) {
958
1262
  calls.push(['store_memory', match[1], match[2]]);
959
1263
  }
960
1264
 
961
- 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>|\\/>)')) {
962
1266
  calls.push(['recall_memory', match[1]]);
963
1267
  }
964
1268
 
@@ -973,9 +1277,31 @@ function extractToolCalls(text) {
973
1277
  return calls;
974
1278
  }
975
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
+
976
1298
  module.exports = {
1299
+ buildToolsSchema,
977
1300
  createToolExecutor,
978
1301
  extractToolCalls,
979
1302
  getSkippedOps,
1303
+ isUIActive,
1304
+ mapInvokeToCall,
1305
+ repairMinimaxMalformedXml,
980
1306
  setUIActive,
981
1307
  };