@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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +607 -77
- package/lib/api.js +240 -23
- package/lib/commands.js +105 -81
- package/lib/config.js +32 -4
- package/lib/constants.js +67 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +97 -83
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +645 -319
- package/lib/ui/ansi.js +17 -4
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +76 -58
- package/lib/ui/status-bar.js +56 -25
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
129
|
-
|
|
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 !== '.')
|
|
150
|
-
if (action === 'write')
|
|
151
|
-
else
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !== '.')
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 !== '.')
|
|
315
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const matches =
|
|
371
|
-
|
|
372
|
-
|
|
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 (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 (
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 =
|
|
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('
|
|
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('
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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 !== '.')
|
|
653
|
+
if (dir && dir !== '.') await fsp.mkdir(dir, { recursive: true });
|
|
591
654
|
const buffer = Buffer.from(encodedContent.trim(), 'base64');
|
|
592
|
-
|
|
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(/ /gi, ' ')
|
|
615
|
-
.replace(/&/gi, '&')
|
|
616
|
-
.replace(/</gi, '<')
|
|
617
|
-
.replace(/>/gi, '>')
|
|
618
|
-
.replace(/"/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('
|
|
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
|
|
666
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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('
|
|
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('
|
|
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(
|
|
780
|
+
try { mem = JSON.parse(await fsp.readFile(MEMORY_PATH, 'utf8')); } catch {}
|
|
758
781
|
mem[key] = value;
|
|
759
|
-
|
|
760
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|