@semalt-ai/code 1.7.0 → 1.8.1
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 +8 -0
- package/ARCHITECTURE.md +99 -0
- package/CLAUDE.md +349 -0
- package/index.js +69 -7
- package/lib/agent.js +577 -39
- package/lib/api.js +285 -79
- package/lib/args.js +31 -0
- package/lib/audit.js +31 -0
- package/lib/commands.js +1006 -307
- package/lib/config.js +51 -5
- package/lib/constants.js +72 -0
- package/lib/context.js +2 -6
- package/lib/metrics.js +94 -0
- package/lib/permissions.js +180 -49
- package/lib/prompts.js +96 -13
- package/lib/storage.js +96 -0
- package/lib/tools.js +1009 -35
- package/lib/ui/ansi.js +65 -0
- package/lib/ui/chat-history.js +217 -0
- package/lib/ui/create-ui.js +474 -0
- package/lib/ui/diff.js +243 -0
- package/lib/ui/input-field.js +1176 -0
- package/lib/ui/layout.js +53 -0
- package/lib/ui/legacy.js +130 -0
- package/lib/ui/status-bar.js +131 -0
- package/lib/ui/stream.js +158 -0
- package/lib/ui/utils.js +45 -0
- package/lib/ui.js +42 -598
- package/package.json +1 -1
- package/path +1 -0
package/lib/tools.js
CHANGED
|
@@ -1,81 +1,796 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const os = require('os');
|
|
4
7
|
const path = require('path');
|
|
5
8
|
const { spawnSync } = require('child_process');
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
const { logToolCall } = require('./audit');
|
|
11
|
+
|
|
12
|
+
const MEMORY_PATH = path.join(os.homedir(), '.semalt-ai', 'memory.json');
|
|
13
|
+
|
|
14
|
+
const _dryRun = process.argv.includes('--dry-run');
|
|
15
|
+
const _skippedOps = [];
|
|
16
|
+
function getSkippedOps() { return _skippedOps.slice(); }
|
|
17
|
+
|
|
18
|
+
// When the full TUI is active, suppress direct console.log writes — the UI
|
|
19
|
+
// handles all tool-status display via onToolEnd callbacks instead.
|
|
20
|
+
let _uiActive = false;
|
|
21
|
+
function setUIActive(v) { _uiActive = v; }
|
|
22
|
+
function _log(...args) { if (!_uiActive) console.log(...args); }
|
|
23
|
+
|
|
24
|
+
// Chunk store for large http_get responses.
|
|
25
|
+
// Maps URL → { chunks: string[], total: number, delivered: number }
|
|
26
|
+
const _httpChunkStore = new Map();
|
|
27
|
+
const HTTP_CHUNK_CHARS = 10000;
|
|
28
|
+
|
|
29
|
+
function createToolExecutor(permissionManager, ui, getConfig) {
|
|
30
|
+
const { BOLD, DIM, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, renderDiff } = ui;
|
|
31
|
+
|
|
32
|
+
function _parseNumberedOptions(text) {
|
|
33
|
+
const options = [];
|
|
34
|
+
for (const line of text.split('\n')) {
|
|
35
|
+
const m = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
36
|
+
if (m) options.push(m[1].trim());
|
|
37
|
+
}
|
|
38
|
+
return options.length >= 2 ? options : [];
|
|
39
|
+
}
|
|
9
40
|
|
|
10
41
|
async function agentExecShell(command) {
|
|
11
|
-
|
|
42
|
+
if (_dryRun) {
|
|
43
|
+
_log(` ${FG_DARK}[dry-run] $ ${command}${RST}`);
|
|
44
|
+
_skippedOps.push({ category: 'cmd', symbol: '▶', desc: command });
|
|
45
|
+
logToolCall('exec', { command }, false, 'dry-run');
|
|
46
|
+
return { exit_code: 0, stdout: '', stderr: 'dry-run: command skipped' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const approved = await permissionManager.askPermission('shell', command, 'exec');
|
|
12
50
|
if (!approved) {
|
|
51
|
+
logToolCall('exec', { command }, false, 'denied');
|
|
13
52
|
return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
|
|
14
53
|
}
|
|
15
54
|
|
|
16
|
-
console.log(` ${FG_DARK}$ ${command}${RST}`);
|
|
17
55
|
try {
|
|
18
|
-
const
|
|
56
|
+
const cfg = getConfig ? getConfig() : {};
|
|
57
|
+
const timeout = cfg.command_timeout_ms || 30000;
|
|
58
|
+
const result = spawnSync(command, { shell: true, encoding: 'utf8', timeout });
|
|
19
59
|
const stdout = result.stdout || '';
|
|
20
60
|
const stderr = result.stderr || '';
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (lines.length > 20) {
|
|
25
|
-
lines.slice(0, 15).forEach((line) => console.log(` ${FG_GRAY}${line}${RST}`));
|
|
26
|
-
console.log(` ${FG_DARK}... (${lines.length - 15} more lines)${RST}`);
|
|
27
|
-
} else {
|
|
28
|
-
lines.forEach((line) => console.log(` ${FG_GRAY}${line}${RST}`));
|
|
29
|
-
}
|
|
30
|
-
console.log();
|
|
31
|
-
|
|
32
|
-
return { exit_code: result.status ?? 0, stdout, stderr };
|
|
61
|
+
const exitCode = result.status ?? 0;
|
|
62
|
+
logToolCall('exec', { command }, true, exitCode === 0 ? 'ok' : 'error');
|
|
63
|
+
return { exit_code: exitCode, stdout, stderr };
|
|
33
64
|
} catch (error) {
|
|
34
|
-
|
|
65
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
66
|
+
logToolCall('exec', { command }, true, 'error');
|
|
35
67
|
return { exit_code: -1, stdout: '', stderr: error.message };
|
|
36
68
|
}
|
|
37
69
|
}
|
|
38
70
|
|
|
39
|
-
async function agentExecFile(action,
|
|
40
|
-
|
|
41
|
-
const approved = await permissionManager.askPermission('file', `Read ${filePath}`);
|
|
42
|
-
if (!approved) return { error: 'Permission denied' };
|
|
71
|
+
async function agentExecFile(action, ...args) {
|
|
72
|
+
const [arg0 = null, arg1 = null, arg2 = null, arg3 = null] = args;
|
|
43
73
|
|
|
74
|
+
if (action === 'read') {
|
|
75
|
+
const filePath = arg0;
|
|
76
|
+
try {
|
|
77
|
+
const stat = fs.statSync(filePath);
|
|
78
|
+
const cfg = getConfig ? getConfig() : {};
|
|
79
|
+
const maxBytes = (cfg.max_file_size_kb || 512) * 1024;
|
|
80
|
+
if (stat.size > maxBytes) {
|
|
81
|
+
const kb = (stat.size / 1024).toFixed(0);
|
|
82
|
+
logToolCall('read_file', { path: filePath }, false, 'error');
|
|
83
|
+
return { error: `File too large: ${kb} KB exceeds max_file_size_kb=${cfg.max_file_size_kb || 512}` };
|
|
84
|
+
}
|
|
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
|
+
}
|
|
44
93
|
try {
|
|
45
94
|
const data = fs.readFileSync(filePath, 'utf8');
|
|
46
95
|
const lines = data.split('\n').length;
|
|
47
96
|
if (lines > 10) {
|
|
48
|
-
|
|
97
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
|
|
49
98
|
} else {
|
|
50
|
-
|
|
99
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
|
|
51
100
|
}
|
|
101
|
+
logToolCall('read_file', { path: filePath }, true, 'ok');
|
|
52
102
|
return { content: data, path: filePath };
|
|
53
103
|
} catch (error) {
|
|
54
|
-
|
|
104
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
105
|
+
logToolCall('read_file', { path: filePath }, true, 'error');
|
|
55
106
|
return { error: error.message };
|
|
56
107
|
}
|
|
57
108
|
}
|
|
58
109
|
|
|
59
110
|
if (action === 'write' || action === 'append') {
|
|
111
|
+
const filePath = arg0;
|
|
112
|
+
const content = arg1;
|
|
113
|
+
const tag = action === 'write' ? 'write_file' : 'append_file';
|
|
114
|
+
|
|
115
|
+
const blocked = permissionManager.readonlyBlock(tag);
|
|
116
|
+
if (blocked) {
|
|
117
|
+
logToolCall(tag, { path: filePath, content }, false, 'denied');
|
|
118
|
+
return blocked;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Read existing content for diff display
|
|
122
|
+
let existing = '';
|
|
123
|
+
try { existing = fs.readFileSync(filePath, 'utf8'); } catch {}
|
|
124
|
+
|
|
125
|
+
// For append the final state is existing + new content
|
|
126
|
+
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
127
|
+
|
|
128
|
+
// In CLI mode, print the diff inline. In TUI mode, direct stdout writes
|
|
129
|
+
// collide with the live chat-history/status-bar redraw, so we route the
|
|
130
|
+
// diff into the permission description instead (where it renders inside
|
|
131
|
+
// the permission bubble and is safely truncated by MAX_DESC_LINES).
|
|
132
|
+
const diffOutput = renderDiff(existing, finalContent, filePath);
|
|
133
|
+
if (!_uiActive) process.stdout.write(diffOutput + '\n');
|
|
134
|
+
|
|
135
|
+
// Dry-run: record the skipped op and return without writing
|
|
136
|
+
if (_dryRun) {
|
|
137
|
+
const verb = action === 'write' ? 'write' : 'append';
|
|
138
|
+
_skippedOps.push({ category: 'file', symbol: '✎', desc: `${verb} ${filePath}` });
|
|
139
|
+
logToolCall(tag, { path: filePath }, false, 'dry-run');
|
|
140
|
+
return { status: 'dry-run', message: 'dry-run: write skipped', path: filePath };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
|
|
60
144
|
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
61
145
|
if (content) desc += ` (${content.length} chars)`;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
146
|
+
if (_uiActive) desc = `${desc}\n${diffOutput}`;
|
|
147
|
+
const approved = await permissionManager.askPermission('file', desc, tag);
|
|
148
|
+
if (!approved) {
|
|
149
|
+
logToolCall(tag, { path: filePath, content }, false, 'denied');
|
|
150
|
+
return { error: 'Permission denied' };
|
|
151
|
+
}
|
|
65
152
|
try {
|
|
66
153
|
const dir = path.dirname(filePath);
|
|
67
154
|
if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
|
|
68
155
|
if (action === 'write') fs.writeFileSync(filePath, content || '');
|
|
69
156
|
else fs.appendFileSync(filePath, content || '');
|
|
70
157
|
const verb = action === 'write' ? 'Wrote' : 'Appended to';
|
|
71
|
-
|
|
158
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
|
|
159
|
+
logToolCall(tag, { path: filePath, content }, true, 'ok');
|
|
72
160
|
return { status: 'ok', path: filePath, bytes: (content || '').length };
|
|
73
161
|
} catch (error) {
|
|
74
|
-
|
|
162
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
163
|
+
logToolCall(tag, { path: filePath, content }, true, 'error');
|
|
164
|
+
return { error: error.message };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (action === 'list_dir') {
|
|
169
|
+
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
|
+
try {
|
|
176
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
177
|
+
const items = entries.map((e) => {
|
|
178
|
+
if (e.isSymbolicLink()) return `[L] ${e.name}`;
|
|
179
|
+
if (e.isDirectory()) return `[D] ${e.name}`;
|
|
180
|
+
return `[F] ${e.name}`;
|
|
181
|
+
});
|
|
182
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Listed ${dirPath} (${items.length} items)${RST}`);
|
|
183
|
+
logToolCall('list_dir', { path: dirPath }, true, 'ok');
|
|
184
|
+
return { items, path: dirPath };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
187
|
+
logToolCall('list_dir', { path: dirPath }, true, 'error');
|
|
75
188
|
return { error: error.message };
|
|
76
189
|
}
|
|
77
190
|
}
|
|
78
191
|
|
|
192
|
+
if (action === 'delete_file') {
|
|
193
|
+
const filePath = arg0;
|
|
194
|
+
|
|
195
|
+
const blocked = permissionManager.readonlyBlock('delete_file');
|
|
196
|
+
if (blocked) {
|
|
197
|
+
logToolCall('delete_file', { path: filePath }, false, 'denied');
|
|
198
|
+
return blocked;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_log(` ${FG_YELLOW}${BOLD}⚠ Deleting: ${filePath}${RST}`);
|
|
202
|
+
|
|
203
|
+
const approved = await permissionManager.askPermission('file', `Delete ${filePath}`, 'delete_file');
|
|
204
|
+
if (!approved) {
|
|
205
|
+
logToolCall('delete_file', { path: filePath }, false, 'denied');
|
|
206
|
+
return { error: 'Permission denied' };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
fs.unlinkSync(filePath);
|
|
210
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Deleted ${filePath}${RST}`);
|
|
211
|
+
logToolCall('delete_file', { path: filePath }, true, 'ok');
|
|
212
|
+
return { status: 'ok', path: filePath };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
215
|
+
logToolCall('delete_file', { path: filePath }, true, 'error');
|
|
216
|
+
return { error: error.message };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (action === 'make_dir') {
|
|
221
|
+
const dirPath = arg0;
|
|
222
|
+
const approved = await permissionManager.askPermission('file', `Create directory ${dirPath}`, 'make_dir');
|
|
223
|
+
if (!approved) {
|
|
224
|
+
logToolCall('make_dir', { path: dirPath }, false, 'denied');
|
|
225
|
+
return { error: 'Permission denied' };
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
229
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Created directory ${dirPath}${RST}`);
|
|
230
|
+
logToolCall('make_dir', { path: dirPath }, true, 'ok');
|
|
231
|
+
return { status: 'ok', path: dirPath };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
234
|
+
logToolCall('make_dir', { path: dirPath }, true, 'error');
|
|
235
|
+
return { error: error.message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (action === 'remove_dir') {
|
|
240
|
+
const dirPath = arg0;
|
|
241
|
+
const approved = await permissionManager.askPermission('file', `Remove directory ${dirPath}`, 'remove_dir');
|
|
242
|
+
if (!approved) {
|
|
243
|
+
logToolCall('remove_dir', { path: dirPath }, false, 'denied');
|
|
244
|
+
return { error: 'Permission denied' };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
248
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Removed directory ${dirPath}${RST}`);
|
|
249
|
+
logToolCall('remove_dir', { path: dirPath }, true, 'ok');
|
|
250
|
+
return { status: 'ok', path: dirPath };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
253
|
+
logToolCall('remove_dir', { path: dirPath }, true, 'error');
|
|
254
|
+
return { error: error.message };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (action === 'move_file') {
|
|
259
|
+
const src = arg0;
|
|
260
|
+
const dst = arg1;
|
|
261
|
+
|
|
262
|
+
const blocked = permissionManager.readonlyBlock('move_file');
|
|
263
|
+
if (blocked) {
|
|
264
|
+
logToolCall('move_file', { src, dst }, false, 'denied');
|
|
265
|
+
return blocked;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_log(` ${FG_YELLOW}${BOLD}⚠ Moving: ${src} → ${dst}${RST}`);
|
|
269
|
+
|
|
270
|
+
const approved = await permissionManager.askPermission('file', `Move ${src} to ${dst}`, 'move_file');
|
|
271
|
+
if (!approved) {
|
|
272
|
+
logToolCall('move_file', { src, dst }, false, 'denied');
|
|
273
|
+
return { error: 'Permission denied' };
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const dstDir = path.dirname(dst);
|
|
277
|
+
if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
|
|
278
|
+
try {
|
|
279
|
+
fs.renameSync(src, dst);
|
|
280
|
+
} catch (renameErr) {
|
|
281
|
+
if (renameErr.code !== 'EXDEV') throw renameErr;
|
|
282
|
+
// Cross-device rename not supported — copy then remove
|
|
283
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
284
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
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
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (action === 'copy_file') {
|
|
297
|
+
const src = arg0;
|
|
298
|
+
const dst = arg1;
|
|
299
|
+
|
|
300
|
+
const blocked = permissionManager.readonlyBlock('copy_file');
|
|
301
|
+
if (blocked) {
|
|
302
|
+
logToolCall('copy_file', { src, dst }, false, 'denied');
|
|
303
|
+
return blocked;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const approved = await permissionManager.askPermission('file', `Copy ${src} to ${dst}`, 'copy_file');
|
|
307
|
+
if (!approved) {
|
|
308
|
+
logToolCall('copy_file', { src, dst }, false, 'denied');
|
|
309
|
+
return { error: 'Permission denied' };
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const dstDir = path.dirname(dst);
|
|
313
|
+
if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
|
|
314
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
315
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Copied ${src} → ${dst}${RST}`);
|
|
316
|
+
logToolCall('copy_file', { src, dst }, true, 'ok');
|
|
317
|
+
return { status: 'ok', src, dst };
|
|
318
|
+
} catch (error) {
|
|
319
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
320
|
+
logToolCall('copy_file', { src, dst }, true, 'error');
|
|
321
|
+
return { error: error.message };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (action === 'edit_file') {
|
|
326
|
+
const filePath = arg0;
|
|
327
|
+
const lineNum = arg1;
|
|
328
|
+
const newContent = arg2;
|
|
329
|
+
const approved = await permissionManager.askPermission('file', `Edit line ${lineNum} in ${filePath}`, 'edit_file');
|
|
330
|
+
if (!approved) {
|
|
331
|
+
logToolCall('edit_file', { path: filePath, line: lineNum }, false, 'denied');
|
|
332
|
+
return { error: 'Permission denied' };
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
336
|
+
const lines = data.split('\n');
|
|
337
|
+
if (lineNum < 1 || lineNum > lines.length) {
|
|
338
|
+
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
|
|
339
|
+
return { error: `Line ${lineNum} out of range (file has ${lines.length} lines)` };
|
|
340
|
+
}
|
|
341
|
+
lines[lineNum - 1] = newContent;
|
|
342
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
343
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Edited line ${lineNum} in ${filePath}${RST}`);
|
|
344
|
+
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'ok');
|
|
345
|
+
return { status: 'ok', path: filePath, line: lineNum };
|
|
346
|
+
} catch (error) {
|
|
347
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
348
|
+
logToolCall('edit_file', { path: filePath, line: lineNum }, true, 'error');
|
|
349
|
+
return { error: error.message };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (action === 'search_in_file') {
|
|
354
|
+
const filePath = arg0;
|
|
355
|
+
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
|
+
try {
|
|
362
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
363
|
+
const regex = new RegExp(pattern);
|
|
364
|
+
const matches = data.split('\n')
|
|
365
|
+
.map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
|
|
366
|
+
.filter(Boolean);
|
|
367
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
|
|
368
|
+
logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
|
|
369
|
+
return { matches, path: filePath };
|
|
370
|
+
} catch (error) {
|
|
371
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
372
|
+
logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
|
|
373
|
+
return { error: error.message };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (action === 'replace_in_file') {
|
|
378
|
+
const filePath = arg0;
|
|
379
|
+
const searchStr = arg1;
|
|
380
|
+
const replaceStr = arg2;
|
|
381
|
+
const flags = arg3 || '';
|
|
382
|
+
const approved = await permissionManager.askPermission('file', `Replace in ${filePath}`, 'replace_in_file');
|
|
383
|
+
if (!approved) {
|
|
384
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, false, 'denied');
|
|
385
|
+
return { error: 'Permission denied' };
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
389
|
+
const safeFlags = flags.replace(/[^gimsuy]/g, '');
|
|
390
|
+
const countFlags = safeFlags.includes('g') ? safeFlags : safeFlags + 'g';
|
|
391
|
+
const count = (data.match(new RegExp(searchStr, countFlags)) || []).length;
|
|
392
|
+
const regex = new RegExp(searchStr, safeFlags || undefined);
|
|
393
|
+
const newData = data.replace(regex, replaceStr);
|
|
394
|
+
fs.writeFileSync(filePath, newData);
|
|
395
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Replaced ${count} occurrence(s) in ${filePath}${RST}`);
|
|
396
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'ok');
|
|
397
|
+
return { status: 'ok', path: filePath, count };
|
|
398
|
+
} catch (error) {
|
|
399
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
400
|
+
logToolCall('replace_in_file', { path: filePath, search: searchStr }, true, 'error');
|
|
401
|
+
return { error: error.message };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (action === 'search_files') {
|
|
406
|
+
const pattern = arg0;
|
|
407
|
+
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
|
+
try {
|
|
414
|
+
let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
415
|
+
regStr = regStr.replace(/\*\*/g, '\x00');
|
|
416
|
+
regStr = regStr.replace(/\*/g, '[^/]*');
|
|
417
|
+
regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
|
|
418
|
+
regStr = regStr.replace(/\x00/g, '.*');
|
|
419
|
+
const regex = new RegExp(`^${regStr}$`);
|
|
420
|
+
const matchName = !pattern.includes('/');
|
|
421
|
+
const files = [];
|
|
422
|
+
function walk(dir, rel) {
|
|
423
|
+
let entries;
|
|
424
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
427
|
+
if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
|
|
428
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
walk(searchDir, '');
|
|
432
|
+
files.sort();
|
|
433
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
|
|
434
|
+
logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
|
|
435
|
+
return { files, pattern, dir: searchDir };
|
|
436
|
+
} catch (error) {
|
|
437
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
438
|
+
logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
|
|
439
|
+
return { error: error.message };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (action === 'file_stat') {
|
|
444
|
+
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
|
+
try {
|
|
451
|
+
const stat = fs.statSync(filePath);
|
|
452
|
+
const type = stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file';
|
|
453
|
+
const size_kb = (stat.size / 1024).toFixed(2);
|
|
454
|
+
const mode = '0o' + stat.mode.toString(8);
|
|
455
|
+
const mtime = stat.mtime.toISOString();
|
|
456
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Stat ${filePath}${RST}`);
|
|
457
|
+
logToolCall('file_stat', { path: filePath }, true, 'ok');
|
|
458
|
+
return { path: filePath, size_kb, mtime, type, mode };
|
|
459
|
+
} catch (error) {
|
|
460
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
461
|
+
logToolCall('file_stat', { path: filePath }, true, 'error');
|
|
462
|
+
return { error: error.message };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (action === 'get_env') {
|
|
467
|
+
const varName = arg0;
|
|
468
|
+
const value = process.env[varName];
|
|
469
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Got env ${varName}${RST}`);
|
|
470
|
+
logToolCall('get_env', { name: varName }, true, 'ok');
|
|
471
|
+
return { name: varName, value: value !== undefined ? value : null };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (action === 'set_env') {
|
|
475
|
+
const varName = arg0;
|
|
476
|
+
const value = arg1 || '';
|
|
477
|
+
const approved = await permissionManager.askPermission('shell', `Set env ${varName}=${value}`, 'set_env');
|
|
478
|
+
if (!approved) {
|
|
479
|
+
logToolCall('set_env', { name: varName }, false, 'denied');
|
|
480
|
+
return { error: 'Permission denied' };
|
|
481
|
+
}
|
|
482
|
+
process.env[varName] = value;
|
|
483
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Set env ${varName}${RST}`);
|
|
484
|
+
logToolCall('set_env', { name: varName }, true, 'ok');
|
|
485
|
+
return { status: 'ok', name: varName };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (action === 'download') {
|
|
489
|
+
const url = arg0;
|
|
490
|
+
if (_dryRun) {
|
|
491
|
+
_skippedOps.push({ category: 'net', symbol: '↓', desc: `download ${url}` });
|
|
492
|
+
logToolCall('download', { url }, false, 'dry-run');
|
|
493
|
+
return { status: 'dry-run', message: 'dry-run: network call skipped' };
|
|
494
|
+
}
|
|
495
|
+
let fileName;
|
|
496
|
+
try {
|
|
497
|
+
fileName = path.basename(new URL(url).pathname) || 'download';
|
|
498
|
+
} catch {
|
|
499
|
+
fileName = 'download';
|
|
500
|
+
}
|
|
501
|
+
const outPath = path.join(process.cwd(), fileName);
|
|
502
|
+
const approved = await permissionManager.askPermission('shell', `Download ${url}`, 'download');
|
|
503
|
+
if (!approved) {
|
|
504
|
+
logToolCall('download', { url }, false, 'denied');
|
|
505
|
+
return { error: 'Permission denied' };
|
|
506
|
+
}
|
|
507
|
+
return new Promise((resolve) => {
|
|
508
|
+
function doDownload(target, redirectsLeft) {
|
|
509
|
+
const proto = target.startsWith('https') ? https : http;
|
|
510
|
+
const req = proto.get(target, (res) => {
|
|
511
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
|
|
512
|
+
res.resume();
|
|
513
|
+
return doDownload(res.headers.location, redirectsLeft - 1);
|
|
514
|
+
}
|
|
515
|
+
if (res.statusCode >= 400) {
|
|
516
|
+
res.resume();
|
|
517
|
+
const msg = `HTTP ${res.statusCode}`;
|
|
518
|
+
_log(` ${FG_RED}✗ ${msg}${RST}`);
|
|
519
|
+
logToolCall('download', { url }, true, 'error');
|
|
520
|
+
return resolve({ error: msg });
|
|
521
|
+
}
|
|
522
|
+
const file = fs.createWriteStream(outPath);
|
|
523
|
+
res.pipe(file);
|
|
524
|
+
file.on('finish', () => {
|
|
525
|
+
file.close();
|
|
526
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
|
|
527
|
+
logToolCall('download', { url }, true, 'ok');
|
|
528
|
+
resolve({ status: 'ok', path: outPath });
|
|
529
|
+
});
|
|
530
|
+
file.on('error', (err) => {
|
|
531
|
+
fs.unlink(outPath, () => {});
|
|
532
|
+
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
533
|
+
logToolCall('download', { url }, true, 'error');
|
|
534
|
+
resolve({ error: err.message });
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
req.on('error', (err) => {
|
|
538
|
+
fs.unlink(outPath, () => {});
|
|
539
|
+
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
540
|
+
logToolCall('download', { url }, true, 'error');
|
|
541
|
+
resolve({ error: err.message });
|
|
542
|
+
});
|
|
543
|
+
req.setTimeout(120000, () => {
|
|
544
|
+
req.destroy();
|
|
545
|
+
fs.unlink(outPath, () => {});
|
|
546
|
+
logToolCall('download', { url }, true, 'error');
|
|
547
|
+
resolve({ error: 'Request timeout' });
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
doDownload(url, 5);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (action === 'upload') {
|
|
555
|
+
const filePath = arg0;
|
|
556
|
+
const encodedContent = arg1 || '';
|
|
557
|
+
const approved = await permissionManager.askPermission('file', `Upload to ${filePath}`, 'upload');
|
|
558
|
+
if (!approved) {
|
|
559
|
+
logToolCall('upload', { path: filePath }, false, 'denied');
|
|
560
|
+
return { error: 'Permission denied' };
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const dir = path.dirname(filePath);
|
|
564
|
+
if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
|
|
565
|
+
const buffer = Buffer.from(encodedContent.trim(), 'base64');
|
|
566
|
+
fs.writeFileSync(filePath, buffer);
|
|
567
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Uploaded ${buffer.length} bytes to ${filePath}${RST}`);
|
|
568
|
+
logToolCall('upload', { path: filePath }, true, 'ok');
|
|
569
|
+
return { status: 'ok', path: filePath, bytes: buffer.length };
|
|
570
|
+
} catch (error) {
|
|
571
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
572
|
+
logToolCall('upload', { path: filePath }, true, 'error');
|
|
573
|
+
return { error: error.message };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
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
|
+
if (action === 'http_get') {
|
|
626
|
+
const url = arg0;
|
|
627
|
+
const rawHtml = arg1 === 'true';
|
|
628
|
+
if (_dryRun) {
|
|
629
|
+
_skippedOps.push({ category: 'net', symbol: '↓', desc: `GET ${url}` });
|
|
630
|
+
logToolCall('http_get', { url }, false, 'dry-run');
|
|
631
|
+
return { status: 'dry-run', message: 'dry-run: network call skipped' };
|
|
632
|
+
}
|
|
633
|
+
const approved = await permissionManager.askPermission('shell', `HTTP GET ${url}`, 'http_get');
|
|
634
|
+
if (!approved) {
|
|
635
|
+
logToolCall('http_get', { url }, false, 'denied');
|
|
636
|
+
return { error: 'Permission denied' };
|
|
637
|
+
}
|
|
638
|
+
const httpCfg = getConfig ? getConfig() : {};
|
|
639
|
+
const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
|
|
640
|
+
return new Promise((resolve) => {
|
|
641
|
+
function doGet(target, redirectsLeft) {
|
|
642
|
+
const proto = target.startsWith('https') ? https : http;
|
|
643
|
+
const req = proto.get(target, (res) => {
|
|
644
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
|
|
645
|
+
res.resume();
|
|
646
|
+
return doGet(res.headers.location, redirectsLeft - 1);
|
|
647
|
+
}
|
|
648
|
+
let data = '';
|
|
649
|
+
res.setEncoding('utf8');
|
|
650
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
651
|
+
res.on('end', () => {
|
|
652
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${target} (${res.statusCode}, ${data.length} chars)${RST}`);
|
|
653
|
+
logToolCall('http_get', { url: target }, true, res.statusCode < 400 ? 'ok' : 'error');
|
|
654
|
+
resolve(buildHttpResult(url, res.statusCode, data, rawHtml));
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
req.on('error', (err) => {
|
|
658
|
+
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
659
|
+
logToolCall('http_get', { url: target }, true, 'error');
|
|
660
|
+
resolve({ error: err.message });
|
|
661
|
+
});
|
|
662
|
+
req.setTimeout(reqTimeoutMs, () => {
|
|
663
|
+
req.destroy();
|
|
664
|
+
logToolCall('http_get', { url: target }, true, 'error');
|
|
665
|
+
resolve({ error: 'Request timeout' });
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
doGet(url, 5);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (action === 'ask_user') {
|
|
673
|
+
const question = arg0;
|
|
674
|
+
const approved = await permissionManager.askPermission('shell', `Ask user: ${question}`, 'ask_user');
|
|
675
|
+
if (!approved) {
|
|
676
|
+
logToolCall('ask_user', { question }, false, 'denied');
|
|
677
|
+
return { error: 'Permission denied' };
|
|
678
|
+
}
|
|
679
|
+
const options = _parseNumberedOptions(question);
|
|
680
|
+
if (options.length >= 2) {
|
|
681
|
+
const selected = await permissionManager.captureSelect({ options });
|
|
682
|
+
logToolCall('ask_user', { question }, true, 'ok');
|
|
683
|
+
return { question, answer: selected || options[0] };
|
|
684
|
+
}
|
|
685
|
+
if (!process.stdout.isTTY || process.stdin.isRaw) {
|
|
686
|
+
process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${DIM}[auto-answering 'y']${RST}\n`);
|
|
687
|
+
logToolCall('ask_user', { question }, true, 'ok');
|
|
688
|
+
return { question, answer: 'y' };
|
|
689
|
+
}
|
|
690
|
+
process.stdout.write(`\n ${FG_YELLOW}?${RST} ${question}\n ${FG_GRAY}>${RST} `);
|
|
691
|
+
const buf = Buffer.alloc(4096);
|
|
692
|
+
let input = '';
|
|
693
|
+
while (true) {
|
|
694
|
+
const n = fs.readSync(0, buf, 0, 1);
|
|
695
|
+
if (n === 0) break;
|
|
696
|
+
const ch = buf[0];
|
|
697
|
+
if (ch === 0x0a) break;
|
|
698
|
+
if (ch === 0x0d) continue;
|
|
699
|
+
input += String.fromCharCode(ch);
|
|
700
|
+
}
|
|
701
|
+
_log();
|
|
702
|
+
logToolCall('ask_user', { question }, true, 'ok');
|
|
703
|
+
return { question, answer: input };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (action === 'store_memory') {
|
|
707
|
+
const key = arg0;
|
|
708
|
+
const value = arg1 || '';
|
|
709
|
+
const approved = await permissionManager.askPermission('file', `Store memory: ${key}`, 'store_memory');
|
|
710
|
+
if (!approved) {
|
|
711
|
+
logToolCall('store_memory', { key }, false, 'denied');
|
|
712
|
+
return { error: 'Permission denied' };
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
let mem = {};
|
|
716
|
+
try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
|
|
717
|
+
mem[key] = value;
|
|
718
|
+
fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
|
|
719
|
+
fs.writeFileSync(MEMORY_PATH, JSON.stringify(mem, null, 2));
|
|
720
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Stored memory: ${key}${RST}`);
|
|
721
|
+
logToolCall('store_memory', { key }, true, 'ok');
|
|
722
|
+
return { status: 'ok', key };
|
|
723
|
+
} catch (error) {
|
|
724
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
725
|
+
logToolCall('store_memory', { key }, true, 'error');
|
|
726
|
+
return { error: error.message };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (action === 'recall_memory') {
|
|
731
|
+
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
|
+
try {
|
|
738
|
+
let mem = {};
|
|
739
|
+
try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
|
|
740
|
+
const found = key in mem;
|
|
741
|
+
const value = found ? mem[key] : null;
|
|
742
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Recalled memory: ${key}${RST}`);
|
|
743
|
+
logToolCall('recall_memory', { key }, true, 'ok');
|
|
744
|
+
return { key, value, found };
|
|
745
|
+
} catch (error) {
|
|
746
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
747
|
+
logToolCall('recall_memory', { key }, true, 'error');
|
|
748
|
+
return { error: error.message };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
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
|
+
try {
|
|
759
|
+
let mem = {};
|
|
760
|
+
try { mem = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8')); } catch {}
|
|
761
|
+
const keys = Object.keys(mem);
|
|
762
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Listed ${keys.length} memory key(s)${RST}`);
|
|
763
|
+
logToolCall('list_memories', {}, true, 'ok');
|
|
764
|
+
return { keys };
|
|
765
|
+
} catch (error) {
|
|
766
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
767
|
+
logToolCall('list_memories', {}, true, 'error');
|
|
768
|
+
return { error: error.message };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
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
|
+
const info = {
|
|
779
|
+
platform: os.platform(),
|
|
780
|
+
arch: os.arch(),
|
|
781
|
+
hostname: os.hostname(),
|
|
782
|
+
user: process.env.USER || process.env.USERNAME || '',
|
|
783
|
+
total_mem_mb: Math.round(os.totalmem() / 1024 / 1024),
|
|
784
|
+
free_mem_mb: Math.round(os.freemem() / 1024 / 1024),
|
|
785
|
+
node_version: process.version,
|
|
786
|
+
cwd: process.cwd(),
|
|
787
|
+
};
|
|
788
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}System info: ${info.platform}/${info.arch}${RST}`);
|
|
789
|
+
logToolCall('system_info', {}, true, 'ok');
|
|
790
|
+
return info;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
logToolCall(action, { action }, false, 'error');
|
|
79
794
|
return { error: `Unknown action: ${action}` };
|
|
80
795
|
}
|
|
81
796
|
|
|
@@ -85,9 +800,162 @@ function createToolExecutor(permissionManager, ui) {
|
|
|
85
800
|
};
|
|
86
801
|
}
|
|
87
802
|
|
|
803
|
+
// Map a MiniMax-style {name, params} invocation to the internal
|
|
804
|
+
// [action, arg1, arg2, …] call tuple consumed by the agent loop.
|
|
805
|
+
function mapInvokeToCall(toolName, params) {
|
|
806
|
+
const name = (toolName || '').toLowerCase();
|
|
807
|
+
const p = params || {};
|
|
808
|
+
switch (name) {
|
|
809
|
+
case 'write_file':
|
|
810
|
+
case 'create_file':
|
|
811
|
+
return p.path ? ['write', p.path, p.content != null ? p.content : ''] : null;
|
|
812
|
+
case 'read_file':
|
|
813
|
+
return p.path ? ['read', p.path] : null;
|
|
814
|
+
case 'append_file':
|
|
815
|
+
return p.path ? ['append', p.path, p.content != null ? p.content : ''] : null;
|
|
816
|
+
case 'delete_file':
|
|
817
|
+
return p.path ? ['delete_file', p.path] : null;
|
|
818
|
+
case 'list_dir':
|
|
819
|
+
return ['list_dir', p.path || p.dir || '.'];
|
|
820
|
+
case 'make_dir':
|
|
821
|
+
return p.path ? ['make_dir', p.path] : null;
|
|
822
|
+
case 'remove_dir':
|
|
823
|
+
return p.path ? ['remove_dir', p.path] : null;
|
|
824
|
+
case 'move_file':
|
|
825
|
+
return p.src && p.dst ? ['move_file', p.src, p.dst] : null;
|
|
826
|
+
case 'copy_file':
|
|
827
|
+
return p.src && p.dst ? ['copy_file', p.src, p.dst] : null;
|
|
828
|
+
case 'file_stat':
|
|
829
|
+
return p.path ? ['file_stat', p.path] : null;
|
|
830
|
+
case 'search_files':
|
|
831
|
+
return ['search_files', p.pattern || p.glob || '*', p.dir || '.'];
|
|
832
|
+
case 'search_in_file':
|
|
833
|
+
return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
|
|
834
|
+
case 'replace_in_file':
|
|
835
|
+
return p.path && p.search !== undefined
|
|
836
|
+
? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || '']
|
|
837
|
+
: null;
|
|
838
|
+
case 'edit_file':
|
|
839
|
+
return p.path && p.line !== undefined
|
|
840
|
+
? ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : '']
|
|
841
|
+
: null;
|
|
842
|
+
case 'get_env':
|
|
843
|
+
return p.name ? ['get_env', p.name] : null;
|
|
844
|
+
case 'set_env':
|
|
845
|
+
return p.name ? ['set_env', p.name, p.value != null ? p.value : ''] : null;
|
|
846
|
+
case 'download':
|
|
847
|
+
return p.url ? ['download', p.url] : null;
|
|
848
|
+
case 'upload':
|
|
849
|
+
return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
|
|
850
|
+
case 'http_get':
|
|
851
|
+
return p.url ? ['http_get', p.url, p.raw || ''] : null;
|
|
852
|
+
case 'http_get_next':
|
|
853
|
+
return p.key ? ['http_get_next', p.key] : null;
|
|
854
|
+
case 'ask_user':
|
|
855
|
+
return p.question ? ['ask_user', p.question] : null;
|
|
856
|
+
case 'store_memory':
|
|
857
|
+
return p.key ? ['store_memory', p.key, p.value != null ? p.value : ''] : null;
|
|
858
|
+
case 'recall_memory':
|
|
859
|
+
return p.key ? ['recall_memory', p.key] : null;
|
|
860
|
+
case 'list_memories':
|
|
861
|
+
return ['list_memories'];
|
|
862
|
+
case 'system_info':
|
|
863
|
+
return ['system_info'];
|
|
864
|
+
case 'exec':
|
|
865
|
+
case 'shell':
|
|
866
|
+
case 'run':
|
|
867
|
+
case 'run_command':
|
|
868
|
+
case 'bash':
|
|
869
|
+
return p.command ? ['shell', p.command] : null;
|
|
870
|
+
default:
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
88
875
|
function extractToolCalls(text) {
|
|
89
876
|
const calls = [];
|
|
90
877
|
|
|
878
|
+
// MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
|
|
879
|
+
// inference server's tool parser is disabled, or round-tripped back into
|
|
880
|
+
// text by chatStream when delta.tool_calls is streamed.
|
|
881
|
+
//
|
|
882
|
+
// <minimax:tool_call> <qwen:tool_call>
|
|
883
|
+
// <invoke name="write_file"> <invoke name="write_file">
|
|
884
|
+
// <parameter name="path">… <parameter name="path">…
|
|
885
|
+
// </invoke> </invoke>
|
|
886
|
+
// </minimax:tool_call> </qwen:tool_call>
|
|
887
|
+
const INVOKE_RE = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/g;
|
|
888
|
+
const PARAM_RE = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/g;
|
|
889
|
+
const WRAPPER_BLOCK_RE = /<(?:minimax:tool_call|qwen:tool_call)>([\s\S]*?)<\/(?:minimax:tool_call|qwen:tool_call)>/g;
|
|
890
|
+
for (const blockMatch of text.matchAll(WRAPPER_BLOCK_RE)) {
|
|
891
|
+
const block = blockMatch[1];
|
|
892
|
+
for (const invokeMatch of block.matchAll(INVOKE_RE)) {
|
|
893
|
+
const params = {};
|
|
894
|
+
for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
|
|
895
|
+
let val = pMatch[2];
|
|
896
|
+
if (val.startsWith('\n')) val = val.slice(1);
|
|
897
|
+
if (val.endsWith('\n')) val = val.slice(0, -1);
|
|
898
|
+
params[pMatch[1]] = val;
|
|
899
|
+
}
|
|
900
|
+
const call = mapInvokeToCall(invokeMatch[1], params);
|
|
901
|
+
if (call) calls.push(call);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
|
|
906
|
+
// and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
|
|
907
|
+
//
|
|
908
|
+
// <tool_call>
|
|
909
|
+
// {"name": "write_file", "arguments": {"path": "a.css", "content": "…"}}
|
|
910
|
+
// </tool_call>
|
|
911
|
+
//
|
|
912
|
+
// Some variants use <function_call> or the key `parameters` instead of
|
|
913
|
+
// `arguments`. The block may also wrap <invoke> when the finetune follows
|
|
914
|
+
// the MiniMax instruction template — handle both.
|
|
915
|
+
const JSON_BLOCK_RE = /<(tool_call|function_call)>([\s\S]*?)<\/\1>/g;
|
|
916
|
+
for (const blockMatch of text.matchAll(JSON_BLOCK_RE)) {
|
|
917
|
+
const inner = blockMatch[2].trim();
|
|
918
|
+
if (!inner) continue;
|
|
919
|
+
|
|
920
|
+
if (/<invoke\s/i.test(inner)) {
|
|
921
|
+
for (const invokeMatch of inner.matchAll(INVOKE_RE)) {
|
|
922
|
+
const params = {};
|
|
923
|
+
for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
|
|
924
|
+
let val = pMatch[2];
|
|
925
|
+
if (val.startsWith('\n')) val = val.slice(1);
|
|
926
|
+
if (val.endsWith('\n')) val = val.slice(0, -1);
|
|
927
|
+
params[pMatch[1]] = val;
|
|
928
|
+
}
|
|
929
|
+
const call = mapInvokeToCall(invokeMatch[1], params);
|
|
930
|
+
if (call) calls.push(call);
|
|
931
|
+
}
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
let parsed = null;
|
|
936
|
+
try { parsed = JSON.parse(inner); } catch {}
|
|
937
|
+
if (!parsed) {
|
|
938
|
+
const firstBrace = inner.indexOf('{');
|
|
939
|
+
const lastBrace = inner.lastIndexOf('}');
|
|
940
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
941
|
+
try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (!parsed) continue;
|
|
945
|
+
|
|
946
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
947
|
+
for (const entry of entries) {
|
|
948
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
949
|
+
const name = entry.name || entry.tool || entry.function || entry.tool_name;
|
|
950
|
+
const params = entry.arguments || entry.parameters || entry.params || entry.args || {};
|
|
951
|
+
const resolved = typeof params === 'string'
|
|
952
|
+
? (() => { try { return JSON.parse(params); } catch { return {}; } })()
|
|
953
|
+
: params;
|
|
954
|
+
const call = mapInvokeToCall(name, resolved);
|
|
955
|
+
if (call) calls.push(call);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
91
959
|
for (const match of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
|
|
92
960
|
for (const line of match[1].trim().split('\n')) {
|
|
93
961
|
const cmd = line.trim();
|
|
@@ -95,11 +963,7 @@ function extractToolCalls(text) {
|
|
|
95
963
|
}
|
|
96
964
|
}
|
|
97
965
|
|
|
98
|
-
for (const match of text.matchAll(/<shell>([\s\S]*?)<\/shell>/g)) {
|
|
99
|
-
calls.push(['shell', match[1].trim()]);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
for (const match of text.matchAll(/<exec>([\s\S]*?)<\/exec>/g)) {
|
|
966
|
+
for (const match of text.matchAll(/<(?:shell|exec|run_command|run)>([\s\S]*?)<\/(?:shell|exec|run_command|run)>/g)) {
|
|
103
967
|
calls.push(['shell', match[1].trim()]);
|
|
104
968
|
}
|
|
105
969
|
|
|
@@ -107,14 +971,124 @@ function extractToolCalls(text) {
|
|
|
107
971
|
calls.push(['read', match[1].trim()]);
|
|
108
972
|
}
|
|
109
973
|
|
|
974
|
+
for (const match of text.matchAll(/<read_file\s+path="([^"]+)"\s*\/?>/g)) {
|
|
975
|
+
calls.push(['read', match[1]]);
|
|
976
|
+
}
|
|
977
|
+
|
|
110
978
|
for (const match of text.matchAll(/<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g)) {
|
|
111
979
|
calls.push(['write', match[1], match[2]]);
|
|
112
980
|
}
|
|
113
981
|
|
|
982
|
+
for (const match of text.matchAll(/<create_file\s+path="([^"]+)">([\s\S]*?)<\/create_file>/g)) {
|
|
983
|
+
calls.push(['write', match[1], match[2]]);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
for (const match of text.matchAll(/<append_file\s+path="([^"]+)">([\s\S]*?)<\/append_file>/g)) {
|
|
987
|
+
calls.push(['append', match[1], match[2]]);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
for (const match of text.matchAll(/<list_dir>([\s\S]*?)<\/list_dir>/g)) {
|
|
991
|
+
calls.push(['list_dir', match[1].trim()]);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
for (const match of text.matchAll(/<search_files>([\s\S]*?)<\/search_files>/g)) {
|
|
995
|
+
calls.push(['search_files', match[1].trim(), '.']);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
for (const match of text.matchAll(/<search_files\s+pattern="([^"]+)"(?:\s+dir="([^"]*)")?\s*(?:><\/search_files>|\/>)/g)) {
|
|
999
|
+
calls.push(['search_files', match[1], match[2] || '.']);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
for (const match of text.matchAll(/<delete_file>([\s\S]*?)<\/delete_file>/g)) {
|
|
1003
|
+
calls.push(['delete_file', match[1].trim()]);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
for (const match of text.matchAll(/<make_dir>([\s\S]*?)<\/make_dir>/g)) {
|
|
1007
|
+
calls.push(['make_dir', match[1].trim()]);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
for (const match of text.matchAll(/<remove_dir>([\s\S]*?)<\/remove_dir>/g)) {
|
|
1011
|
+
calls.push(['remove_dir', match[1].trim()]);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
for (const match of text.matchAll(/<get_env>([\s\S]*?)<\/get_env>/g)) {
|
|
1015
|
+
calls.push(['get_env', match[1].trim()]);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
for (const match of text.matchAll(/<set_env\s+name="([^"]+)"\s+value="([^"]*)"\s*(?:><\/set_env>|\/>)/g)) {
|
|
1019
|
+
calls.push(['set_env', match[1], match[2]]);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
for (const match of text.matchAll(/<move_file\s+src="([^"]+)"\s+dst="([^"]+)"\s*(?:><\/move_file>|\/>)/g)) {
|
|
1023
|
+
calls.push(['move_file', match[1], match[2]]);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
for (const match of text.matchAll(/<copy_file\s+src="([^"]+)"\s+dst="([^"]+)"\s*(?:><\/copy_file>|\/>)/g)) {
|
|
1027
|
+
calls.push(['copy_file', match[1], match[2]]);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
for (const match of text.matchAll(/<edit_file\s+path="([^"]+)"\s+line="(\d+)">([\s\S]*?)<\/edit_file>/g)) {
|
|
1031
|
+
calls.push(['edit_file', match[1], parseInt(match[2], 10), match[3]]);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
for (const match of text.matchAll(/<search_in_file\s+path="([^"]+)">([\s\S]*?)<\/search_in_file>/g)) {
|
|
1035
|
+
calls.push(['search_in_file', match[1], match[2].trim()]);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
for (const match of text.matchAll(/<replace_in_file\s+path="([^"]+)"\s+search="([^"]*)"\s+replace="([^"]*)">([\s\S]*?)<\/replace_in_file>/g)) {
|
|
1039
|
+
calls.push(['replace_in_file', match[1], match[2], match[3], match[4].trim()]);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
for (const match of text.matchAll(/<download>([\s\S]*?)<\/download>/g)) {
|
|
1043
|
+
calls.push(['download', match[1].trim()]);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
for (const match of text.matchAll(/<upload\s+path="([^"]+)">([\s\S]*?)<\/upload>/g)) {
|
|
1047
|
+
calls.push(['upload', match[1], match[2]]);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
for (const match of text.matchAll(/<file_stat>([\s\S]*?)<\/file_stat>/g)) {
|
|
1051
|
+
calls.push(['file_stat', match[1].trim()]);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
for (const match of text.matchAll(/<http_get\b([^>]*?)(?:><\/http_get>|\/>)/g)) {
|
|
1055
|
+
const attrStr = match[1];
|
|
1056
|
+
const urlMatch = attrStr.match(/url="([^"]+)"/);
|
|
1057
|
+
const rawMatch = attrStr.match(/raw="([^"]+)"/);
|
|
1058
|
+
if (urlMatch) calls.push(['http_get', urlMatch[1], rawMatch ? rawMatch[1] : '']);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
for (const match of text.matchAll(/<http_get_next\s+key="([^"]+)"\s*(?:><\/http_get_next>|\/>)/g)) {
|
|
1062
|
+
calls.push(['http_get_next', match[1]]);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
for (const match of text.matchAll(/<ask_user\s+question="([^"]+)"\s*(?:><\/ask_user>|\/>)/g)) {
|
|
1066
|
+
calls.push(['ask_user', match[1]]);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
for (const match of text.matchAll(/<store_memory\s+key="([^"]+)">([\s\S]*?)<\/store_memory>/g)) {
|
|
1070
|
+
calls.push(['store_memory', match[1], match[2]]);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
for (const match of text.matchAll(/<recall_memory\s+key="([^"]+)"\s*(?:><\/recall_memory>|\/>)/g)) {
|
|
1074
|
+
calls.push(['recall_memory', match[1]]);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for (const match of text.matchAll(/<list_memories\s*(?:><\/list_memories>|\/>)/g)) {
|
|
1078
|
+
calls.push(['list_memories']);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
for (const match of text.matchAll(/<system_info\s*(?:><\/system_info>|\/>)/g)) {
|
|
1082
|
+
calls.push(['system_info']);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
114
1085
|
return calls;
|
|
115
1086
|
}
|
|
116
1087
|
|
|
117
1088
|
module.exports = {
|
|
118
1089
|
createToolExecutor,
|
|
119
1090
|
extractToolCalls,
|
|
1091
|
+
getSkippedOps,
|
|
1092
|
+
mapInvokeToCall,
|
|
1093
|
+
setUIActive,
|
|
120
1094
|
};
|