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