vigthoria-cli 1.10.36 → 1.10.47
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/dist/commands/agent-session-menu.d.ts +19 -0
- package/dist/commands/agent-session-menu.js +155 -0
- package/dist/commands/auth.js +68 -51
- package/dist/commands/bridge.js +19 -12
- package/dist/commands/cancel.js +22 -15
- package/dist/commands/chat.d.ts +0 -22
- package/dist/commands/chat.js +402 -1084
- package/dist/commands/config.js +73 -33
- package/dist/commands/deploy.js +123 -83
- package/dist/commands/device.js +61 -21
- package/dist/commands/edit.js +39 -32
- package/dist/commands/explain.js +25 -18
- package/dist/commands/generate.js +44 -37
- package/dist/commands/hub.js +102 -95
- package/dist/commands/index.js +46 -41
- package/dist/commands/legion.js +186 -146
- package/dist/commands/review.js +36 -29
- package/dist/commands/security.js +12 -5
- package/dist/commands/wallet.js +35 -28
- package/dist/commands/workflow.js +20 -13
- package/dist/utils/brain-hub-client.d.ts +32 -0
- package/dist/utils/brain-hub-client.js +52 -0
- package/dist/utils/bridge-client.js +52 -11
- package/dist/utils/codebase-indexer.d.ts +59 -0
- package/dist/utils/codebase-indexer.js +351 -0
- package/dist/utils/context-ranker.js +21 -15
- package/dist/utils/files.js +42 -5
- package/dist/utils/logger.js +50 -42
- package/dist/utils/persona.js +8 -3
- package/dist/utils/post-write-validator.js +29 -22
- package/dist/utils/project-memory.js +23 -16
- package/dist/utils/task-display.js +20 -13
- package/dist/utils/workspace-brain-service.d.ts +43 -0
- package/dist/utils/workspace-brain-service.js +158 -0
- package/dist/utils/workspace-cache.js +26 -18
- package/dist/utils/workspace-stream.js +63 -21
- package/package.json +3 -6
- package/scripts/release/validate-no-go-gates.sh +1 -1
- package/dist/commands/fork.d.ts +0 -17
- package/dist/commands/fork.js +0 -164
- package/dist/commands/history.d.ts +0 -17
- package/dist/commands/history.js +0 -113
- package/dist/commands/preview.d.ts +0 -55
- package/dist/commands/preview.js +0 -467
- package/dist/commands/replay.d.ts +0 -18
- package/dist/commands/replay.js +0 -156
- package/dist/commands/repo.d.ts +0 -97
- package/dist/commands/repo.js +0 -773
- package/dist/commands/update.d.ts +0 -9
- package/dist/commands/update.js +0 -201
- package/dist/index.d.ts +0 -21
- package/dist/index.js +0 -1823
- package/dist/utils/api.d.ts +0 -572
- package/dist/utils/api.js +0 -6548
- package/dist/utils/cli-state.d.ts +0 -54
- package/dist/utils/cli-state.js +0 -185
- package/dist/utils/config.d.ts +0 -85
- package/dist/utils/config.js +0 -267
- package/dist/utils/session.d.ts +0 -118
- package/dist/utils/session.js +0 -423
- package/dist/utils/tools.d.ts +0 -274
- package/dist/utils/tools.js +0 -3502
package/dist/utils/tools.js
DELETED
|
@@ -1,3502 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vigthoria CLI Tools - Agentic Tool System
|
|
3
|
-
*
|
|
4
|
-
* This module provides Vigthoria Autonomous autonomous tool execution.
|
|
5
|
-
* Tools can be called by the AI to perform actions.
|
|
6
|
-
*
|
|
7
|
-
* Enhanced with:
|
|
8
|
-
* - Risk-based permission system
|
|
9
|
-
* - Automatic retry logic with exponential backoff
|
|
10
|
-
* - Undo functionality for file operations
|
|
11
|
-
* - Detailed error messages with suggestions
|
|
12
|
-
*
|
|
13
|
-
* @version 1.1.0
|
|
14
|
-
* @author Vigthoria Labs
|
|
15
|
-
*/
|
|
16
|
-
import * as fs from 'fs';
|
|
17
|
-
import * as path from 'path';
|
|
18
|
-
import { execSync, spawn } from 'child_process';
|
|
19
|
-
import { createRequire } from 'node:module';
|
|
20
|
-
import chalk from 'chalk';
|
|
21
|
-
// ESM shim — re-create CommonJS-style `require()` so the inline
|
|
22
|
-
// `require('os')` / `require('minimatch')` / `require('glob')` calls
|
|
23
|
-
// inside lazy code paths continue to work unchanged in ESM mode.
|
|
24
|
-
const require = createRequire(import.meta.url);
|
|
25
|
-
import { CH } from './logger.js';
|
|
26
|
-
import { isServerRuntime } from './api.js';
|
|
27
|
-
const STREAM_RESPONSE_MAX_YIELD_CHARS = 32 * 1024;
|
|
28
|
-
const POWERSHELL_SAFE_PATH_PATTERN = /^[A-Za-z0-9_:\\/.\-\s]+$/;
|
|
29
|
-
const POWERSHELL_SAFE_INCLUDE_PATTERN = /^[A-Za-z0-9_*?.\-]+$/;
|
|
30
|
-
const SSH_SAFE_HOST_PATTERN = /^[A-Za-z0-9.-]+$/;
|
|
31
|
-
function getSshAllowedHosts() {
|
|
32
|
-
const configured = String(process.env.VIGTHORIA_SSH_ALLOWED_HOSTS || '')
|
|
33
|
-
.split(',')
|
|
34
|
-
.map((v) => v.trim().toLowerCase())
|
|
35
|
-
.filter(Boolean);
|
|
36
|
-
const defaults = ['vigthoria-server', 'localhost', '127.0.0.1'];
|
|
37
|
-
return new Set([...defaults, ...configured]);
|
|
38
|
-
}
|
|
39
|
-
function isNodeError(error) {
|
|
40
|
-
return error instanceof Error && 'code' in error;
|
|
41
|
-
}
|
|
42
|
-
function resolveWindowsInstallerPath() {
|
|
43
|
-
const configuredPath = process.env.VIGTHORIA_WINDOWS_INSTALLER || process.env.VIGTHORIA_UPDATE_INSTALLER;
|
|
44
|
-
if (configuredPath && configuredPath.trim().length > 0) {
|
|
45
|
-
return path.resolve(configuredPath);
|
|
46
|
-
}
|
|
47
|
-
const executableDir = path.dirname(process.execPath);
|
|
48
|
-
const cwd = process.cwd();
|
|
49
|
-
const candidates = [
|
|
50
|
-
path.resolve(cwd, 'dist', 'VigthoriaSetup.exe'),
|
|
51
|
-
path.resolve(cwd, 'release', 'VigthoriaSetup.exe'),
|
|
52
|
-
path.resolve(cwd, 'VigthoriaSetup.exe'),
|
|
53
|
-
path.resolve(executableDir, 'VigthoriaSetup.exe'),
|
|
54
|
-
path.resolve(executableDir, '..', 'VigthoriaSetup.exe'),
|
|
55
|
-
];
|
|
56
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
|
|
57
|
-
}
|
|
58
|
-
export async function installUpdateWindows() {
|
|
59
|
-
const installerPath = resolveWindowsInstallerPath();
|
|
60
|
-
try {
|
|
61
|
-
if (!fs.existsSync(installerPath)) {
|
|
62
|
-
return { success: false, platform: 'windows', error: 'ENOENT' };
|
|
63
|
-
}
|
|
64
|
-
await fs.promises.access(installerPath, fs.constants.F_OK);
|
|
65
|
-
await new Promise((resolve, reject) => {
|
|
66
|
-
const child = spawn(installerPath, [], {
|
|
67
|
-
cwd: path.dirname(installerPath),
|
|
68
|
-
detached: true,
|
|
69
|
-
stdio: 'ignore',
|
|
70
|
-
windowsHide: false,
|
|
71
|
-
});
|
|
72
|
-
child.once('error', reject);
|
|
73
|
-
child.once('spawn', () => {
|
|
74
|
-
child.unref();
|
|
75
|
-
resolve();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
return { success: true, platform: 'windows' };
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
82
|
-
return { success: false, platform: 'windows', error: 'ENOENT' };
|
|
83
|
-
}
|
|
84
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
-
return { success: false, platform: 'windows', error: message };
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
export async function* robustifyStreamResponse(res) {
|
|
89
|
-
const MAX_YIELD_CHARS = STREAM_RESPONSE_MAX_YIELD_CHARS;
|
|
90
|
-
const MAX_BUFFER_CHARS = 256 * 1024;
|
|
91
|
-
const body = res?.body ?? res;
|
|
92
|
-
const decoder = new TextDecoder('utf-8');
|
|
93
|
-
let reader = null;
|
|
94
|
-
let lineBuffer = '';
|
|
95
|
-
const emitContent = async function* (type, content) {
|
|
96
|
-
for (let offset = 0; offset < content.length; offset += MAX_YIELD_CHARS) {
|
|
97
|
-
const part = content.slice(offset, offset + MAX_YIELD_CHARS);
|
|
98
|
-
if (part.length > 0) {
|
|
99
|
-
yield { type, content: part };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
const textFromObject = (value) => {
|
|
104
|
-
if (!value || typeof value !== 'object')
|
|
105
|
-
return '';
|
|
106
|
-
if (typeof value.content === 'string')
|
|
107
|
-
return value.content;
|
|
108
|
-
if (typeof value.text === 'string')
|
|
109
|
-
return value.text;
|
|
110
|
-
if (typeof value.delta === 'string')
|
|
111
|
-
return value.delta;
|
|
112
|
-
if (typeof value.delta?.text === 'string')
|
|
113
|
-
return value.delta.text;
|
|
114
|
-
if (typeof value.message?.content === 'string')
|
|
115
|
-
return value.message.content;
|
|
116
|
-
if (typeof value.choices?.[0]?.delta?.content === 'string')
|
|
117
|
-
return value.choices[0].delta.content;
|
|
118
|
-
if (typeof value.choices?.[0]?.message?.content === 'string')
|
|
119
|
-
return value.choices[0].message.content;
|
|
120
|
-
if (typeof value.error === 'string')
|
|
121
|
-
return value.error;
|
|
122
|
-
if (typeof value.error?.message === 'string')
|
|
123
|
-
return value.error.message;
|
|
124
|
-
return '';
|
|
125
|
-
};
|
|
126
|
-
const stringifyChunk = (chunk) => {
|
|
127
|
-
if (typeof chunk === 'string')
|
|
128
|
-
return chunk;
|
|
129
|
-
if (Buffer.isBuffer(chunk))
|
|
130
|
-
return chunk.toString('utf8');
|
|
131
|
-
if (chunk instanceof Uint8Array)
|
|
132
|
-
return Buffer.from(chunk).toString('utf8');
|
|
133
|
-
if (chunk instanceof ArrayBuffer)
|
|
134
|
-
return Buffer.from(chunk).toString('utf8');
|
|
135
|
-
if (chunk === null || chunk === undefined)
|
|
136
|
-
return '';
|
|
137
|
-
const objectText = textFromObject(chunk);
|
|
138
|
-
return objectText || String(chunk);
|
|
139
|
-
};
|
|
140
|
-
const decodeLine = (line) => {
|
|
141
|
-
const trimmed = line.trim();
|
|
142
|
-
if (!trimmed || trimmed === 'event: ping' || trimmed === ':' || trimmed === 'data: [DONE]' || trimmed === '[DONE]') {
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
const payload = trimmed.startsWith('data:') ? trimmed.slice(5).trimStart() : trimmed;
|
|
146
|
-
if (!payload || payload === '[DONE]')
|
|
147
|
-
return null;
|
|
148
|
-
if ((payload.startsWith('{') && payload.endsWith('}')) || (payload.startsWith('[') && payload.endsWith(']'))) {
|
|
149
|
-
try {
|
|
150
|
-
const parsed = JSON.parse(payload);
|
|
151
|
-
const parsedText = textFromObject(parsed);
|
|
152
|
-
if (parsedText) {
|
|
153
|
-
const type = parsed.error ? 'error' : 'delta';
|
|
154
|
-
return { type, content: parsedText };
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
catch (error) {
|
|
158
|
-
console.debug(`Stream JSON fragment preserved as text: ${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
if (payload.startsWith('event:') || payload.startsWith('id:') || payload.startsWith('retry:')) {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
return { type: 'delta', content: payload };
|
|
165
|
-
};
|
|
166
|
-
const consumeText = async function* (text, flush = false) {
|
|
167
|
-
if (text) {
|
|
168
|
-
lineBuffer += text;
|
|
169
|
-
}
|
|
170
|
-
while (lineBuffer.length > MAX_BUFFER_CHARS) {
|
|
171
|
-
const newlineIndex = lineBuffer.indexOf('\n', Math.max(0, MAX_BUFFER_CHARS - MAX_YIELD_CHARS));
|
|
172
|
-
const cutIndex = newlineIndex >= 0 ? newlineIndex + 1 : MAX_BUFFER_CHARS;
|
|
173
|
-
const overflow = lineBuffer.slice(0, cutIndex);
|
|
174
|
-
lineBuffer = lineBuffer.slice(cutIndex);
|
|
175
|
-
const decoded = decodeLine(overflow);
|
|
176
|
-
if (decoded) {
|
|
177
|
-
yield* emitContent(decoded.type, decoded.content);
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
yield* emitContent('delta', overflow);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
const lines = lineBuffer.split(/\r?\n/);
|
|
184
|
-
const partialLine = lines.pop() ?? '';
|
|
185
|
-
for (const line of lines) {
|
|
186
|
-
const decoded = decodeLine(line);
|
|
187
|
-
if (decoded) {
|
|
188
|
-
yield* emitContent(decoded.type, decoded.content);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (flush) {
|
|
192
|
-
lineBuffer = '';
|
|
193
|
-
if (partialLine) {
|
|
194
|
-
const decoded = decodeLine(partialLine);
|
|
195
|
-
if (decoded) {
|
|
196
|
-
yield* emitContent(decoded.type, decoded.content);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
else {
|
|
201
|
-
lineBuffer = partialLine;
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
try {
|
|
205
|
-
if (!body) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (typeof body.getReader === 'function') {
|
|
209
|
-
reader = body.getReader();
|
|
210
|
-
const streamReader = reader;
|
|
211
|
-
const decoder = new TextDecoder();
|
|
212
|
-
while (true) {
|
|
213
|
-
const { done, value } = await streamReader.read();
|
|
214
|
-
if (done)
|
|
215
|
-
break;
|
|
216
|
-
const content = decoder.decode(value, { stream: true });
|
|
217
|
-
if (content.length > 0) {
|
|
218
|
-
yield* consumeText(content);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
const tail = decoder.decode();
|
|
222
|
-
if (tail.length > 0) {
|
|
223
|
-
yield* consumeText(tail);
|
|
224
|
-
}
|
|
225
|
-
yield* consumeText('', true);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (typeof body[Symbol.asyncIterator] === 'function') {
|
|
229
|
-
for await (const chunk of body) {
|
|
230
|
-
const content = stringifyChunk(chunk);
|
|
231
|
-
if (content.length > 0) {
|
|
232
|
-
yield* consumeText(content);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
yield* consumeText('', true);
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const content = stringifyChunk(body);
|
|
239
|
-
if (content.length > 0) {
|
|
240
|
-
yield* emitContent('text', content);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
catch (error) {
|
|
244
|
-
const content = error instanceof Error ? error.message : String(error);
|
|
245
|
-
yield { type: 'error', content };
|
|
246
|
-
}
|
|
247
|
-
finally {
|
|
248
|
-
lineBuffer = '';
|
|
249
|
-
try {
|
|
250
|
-
reader?.releaseLock();
|
|
251
|
-
}
|
|
252
|
-
catch (error) {
|
|
253
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
-
console.debug(`Stream reader release failed: ${message}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
const TOOL_ARG_ALIASES = {
|
|
259
|
-
read_file: {
|
|
260
|
-
path: ['file', 'filePath', 'filepath', 'target', 'targetPath'],
|
|
261
|
-
start_line: ['start', 'startLine', 'fromLine', 'lineStart'],
|
|
262
|
-
end_line: ['end', 'endLine', 'toLine', 'lineEnd'],
|
|
263
|
-
},
|
|
264
|
-
write_file: {
|
|
265
|
-
path: ['file', 'filePath', 'filepath', 'target', 'targetPath'],
|
|
266
|
-
content: ['text', 'contents', 'body'],
|
|
267
|
-
},
|
|
268
|
-
edit_file: {
|
|
269
|
-
path: ['file', 'filePath', 'filepath', 'target', 'targetPath'],
|
|
270
|
-
old_text: ['old', 'oldText', 'find', 'search', 'searchText'],
|
|
271
|
-
new_text: ['new', 'newText', 'replace', 'replaceText', 'replacement'],
|
|
272
|
-
},
|
|
273
|
-
bash: {
|
|
274
|
-
command: ['cmd', 'script'],
|
|
275
|
-
cwd: ['path', 'dir', 'directory'],
|
|
276
|
-
},
|
|
277
|
-
grep: {
|
|
278
|
-
pattern: ['query', 'search', 'regex', 'text'],
|
|
279
|
-
path: ['dir', 'directory', 'cwd', 'root'],
|
|
280
|
-
include: ['includePattern', 'filePattern', 'glob'],
|
|
281
|
-
},
|
|
282
|
-
list_dir: {
|
|
283
|
-
path: ['dir', 'directory', 'cwd', 'root'],
|
|
284
|
-
recursive: ['recurse'],
|
|
285
|
-
},
|
|
286
|
-
glob: {
|
|
287
|
-
pattern: ['query', 'search', 'path', 'file', 'name', 'filename', 'glob'],
|
|
288
|
-
},
|
|
289
|
-
git: {
|
|
290
|
-
args: ['command', 'cmd'],
|
|
291
|
-
},
|
|
292
|
-
fetch_url: {
|
|
293
|
-
url: ['link', 'href'],
|
|
294
|
-
method: ['httpMethod'],
|
|
295
|
-
headers: ['header'],
|
|
296
|
-
body: ['content', 'data'],
|
|
297
|
-
selector: ['css', 'cssSelector'],
|
|
298
|
-
},
|
|
299
|
-
ssh_exec: {
|
|
300
|
-
command: ['cmd', 'script'],
|
|
301
|
-
host: ['server'],
|
|
302
|
-
},
|
|
303
|
-
task: {
|
|
304
|
-
description: ['prompt', 'task', 'query', 'instructions'],
|
|
305
|
-
working_dir: ['cwd', 'dir', 'directory', 'workDir'],
|
|
306
|
-
},
|
|
307
|
-
multi_edit: {
|
|
308
|
-
edits: ['changes', 'operations', 'replacements'],
|
|
309
|
-
},
|
|
310
|
-
codebase_search: {
|
|
311
|
-
query: ['search', 'pattern', 'text', 'symbol'],
|
|
312
|
-
scope: ['type', 'mode'],
|
|
313
|
-
include: ['includePattern', 'filePattern', 'glob'],
|
|
314
|
-
max_results: ['limit', 'maxResults', 'count'],
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
// Error types for better handling
|
|
318
|
-
export var ToolErrorType;
|
|
319
|
-
(function (ToolErrorType) {
|
|
320
|
-
ToolErrorType["FILE_NOT_FOUND"] = "FILE_NOT_FOUND";
|
|
321
|
-
ToolErrorType["PERMISSION_DENIED"] = "PERMISSION_DENIED";
|
|
322
|
-
ToolErrorType["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
323
|
-
ToolErrorType["TIMEOUT"] = "TIMEOUT";
|
|
324
|
-
ToolErrorType["INVALID_ARGS"] = "INVALID_ARGS";
|
|
325
|
-
ToolErrorType["EXECUTION_FAILED"] = "EXECUTION_FAILED";
|
|
326
|
-
ToolErrorType["USER_CANCELLED"] = "USER_CANCELLED";
|
|
327
|
-
})(ToolErrorType || (ToolErrorType = {}));
|
|
328
|
-
export class AgenticTools {
|
|
329
|
-
logger;
|
|
330
|
-
cwd;
|
|
331
|
-
permissionCallback;
|
|
332
|
-
autoApprove;
|
|
333
|
-
undoStack = [];
|
|
334
|
-
maxUndoStack = 50;
|
|
335
|
-
retryConfig = {
|
|
336
|
-
maxRetries: 3,
|
|
337
|
-
baseDelayMs: 1000,
|
|
338
|
-
maxDelayMs: 10000,
|
|
339
|
-
retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED', 'EAI_AGAIN'],
|
|
340
|
-
};
|
|
341
|
-
formatExternalToolError(toolName, operation, error) {
|
|
342
|
-
if (error instanceof Error) {
|
|
343
|
-
const processError = error;
|
|
344
|
-
const details = [
|
|
345
|
-
`External tool call failed in ${toolName} during ${operation}: ${error.message}`,
|
|
346
|
-
processError.code !== undefined ? `code=${processError.code}` : undefined,
|
|
347
|
-
processError.status !== undefined ? `status=${processError.status}` : undefined,
|
|
348
|
-
processError.signal ? `signal=${processError.signal}` : undefined,
|
|
349
|
-
processError.stderr ? `stderr=${processError.stderr.toString().trim()}` : undefined,
|
|
350
|
-
processError.stdout ? `stdout=${processError.stdout.toString().trim()}` : undefined,
|
|
351
|
-
].filter(Boolean);
|
|
352
|
-
return details.join(' | ');
|
|
353
|
-
}
|
|
354
|
-
return `External tool call failed in ${toolName} during ${operation}: ${String(error)}`;
|
|
355
|
-
}
|
|
356
|
-
externalToolFailure(toolName, operation, error, suggestion) {
|
|
357
|
-
const message = this.formatExternalToolError(toolName, operation, error);
|
|
358
|
-
this.logger.debug(message);
|
|
359
|
-
this.logger.warn(message);
|
|
360
|
-
return {
|
|
361
|
-
success: false,
|
|
362
|
-
error: message,
|
|
363
|
-
suggestion: suggestion || 'Review the command, arguments, permissions, and environment, then retry.',
|
|
364
|
-
canRetry: true,
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
cleanupAfterToolError(toolName, operation, cleanup) {
|
|
368
|
-
try {
|
|
369
|
-
cleanup();
|
|
370
|
-
}
|
|
371
|
-
catch (cleanupError) {
|
|
372
|
-
const message = this.formatExternalToolError(toolName, `${operation} cleanup`, cleanupError);
|
|
373
|
-
this.logger.debug(message);
|
|
374
|
-
this.logger.warn(message);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
runExternalCommand(toolName, operation, command, options, cleanup) {
|
|
378
|
-
if (!this.isNonEmptyString(toolName)) {
|
|
379
|
-
throw new Error('Invalid tool execution: toolName must be a non-empty string.');
|
|
380
|
-
}
|
|
381
|
-
if (!this.isNonEmptyString(operation)) {
|
|
382
|
-
throw new Error(`Invalid external command for ${toolName}: operation must be a non-empty string.`);
|
|
383
|
-
}
|
|
384
|
-
if (!this.isNonEmptyString(command)) {
|
|
385
|
-
throw new Error(`Invalid external command for ${toolName} during ${operation}: command must be a non-empty string.`);
|
|
386
|
-
}
|
|
387
|
-
try {
|
|
388
|
-
return execSync(command, options);
|
|
389
|
-
}
|
|
390
|
-
catch (error) {
|
|
391
|
-
if (cleanup) {
|
|
392
|
-
this.cleanupAfterToolError(toolName, operation, cleanup);
|
|
393
|
-
}
|
|
394
|
-
const message = this.formatExternalToolError(toolName, operation, error);
|
|
395
|
-
const wrapped = new Error(message);
|
|
396
|
-
wrapped.cause = error;
|
|
397
|
-
throw wrapped;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
isNonEmptyString(value) {
|
|
401
|
-
return typeof value === 'string' && value.trim().length > 0;
|
|
402
|
-
}
|
|
403
|
-
describeInvalidValue(value) {
|
|
404
|
-
if (value === null)
|
|
405
|
-
return 'null';
|
|
406
|
-
if (value === undefined)
|
|
407
|
-
return 'undefined';
|
|
408
|
-
if (Array.isArray(value))
|
|
409
|
-
return 'array';
|
|
410
|
-
return typeof value;
|
|
411
|
-
}
|
|
412
|
-
assertStringRecord(value, context) {
|
|
413
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
414
|
-
throw new Error(`${context} must be an object with string values; received ${this.describeInvalidValue(value)}.`);
|
|
415
|
-
}
|
|
416
|
-
for (const [key, entryValue] of Object.entries(value)) {
|
|
417
|
-
if (!this.isNonEmptyString(key)) {
|
|
418
|
-
throw new Error(`${context} contains an invalid empty parameter name.`);
|
|
419
|
-
}
|
|
420
|
-
if (typeof entryValue !== 'string') {
|
|
421
|
-
throw new Error(`${context}.${key} must be a string; received ${this.describeInvalidValue(entryValue)}.`);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
requireNonEmptyString(value, fieldName, toolName) {
|
|
426
|
-
if (!this.isNonEmptyString(value)) {
|
|
427
|
-
throw new Error(`Invalid arguments for ${toolName}: ${fieldName} is required and must be a non-empty string.`);
|
|
428
|
-
}
|
|
429
|
-
return value;
|
|
430
|
-
}
|
|
431
|
-
requireArgsObject(args, toolName) {
|
|
432
|
-
this.assertStringRecord(args, `Invalid arguments for ${toolName}: args`);
|
|
433
|
-
}
|
|
434
|
-
// Session-based tool approvals - remembers which tools user approved for this turn
|
|
435
|
-
sessionApprovedTools = new Set();
|
|
436
|
-
// Persistent permissions - tool allowlists per project
|
|
437
|
-
static permissionsFile = path.join(process.env.HOME || process.env.USERPROFILE || '~', '.vigthoria', 'permissions.json');
|
|
438
|
-
constructor(logger, cwd, permissionCallback, autoApprove = false) {
|
|
439
|
-
if (!logger) {
|
|
440
|
-
throw new Error('AgenticTools initialization failed: logger is required.');
|
|
441
|
-
}
|
|
442
|
-
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
|
443
|
-
throw new Error('AgenticTools initialization failed: cwd must be a non-empty string.');
|
|
444
|
-
}
|
|
445
|
-
if (typeof permissionCallback !== 'function') {
|
|
446
|
-
throw new Error('AgenticTools initialization failed: permissionCallback must be a function.');
|
|
447
|
-
}
|
|
448
|
-
if (typeof autoApprove !== 'boolean') {
|
|
449
|
-
throw new Error('AgenticTools initialization failed: autoApprove must be a boolean.');
|
|
450
|
-
}
|
|
451
|
-
this.logger = logger;
|
|
452
|
-
this.cwd = path.resolve(cwd);
|
|
453
|
-
this.permissionCallback = permissionCallback;
|
|
454
|
-
this.autoApprove = autoApprove;
|
|
455
|
-
}
|
|
456
|
-
/** Rebind tool execution to a different local workspace root (e.g. prompt path override). */
|
|
457
|
-
setWorkspaceRoot(cwd) {
|
|
458
|
-
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
|
459
|
-
throw new Error('AgenticTools.setWorkspaceRoot failed: cwd must be a non-empty string.');
|
|
460
|
-
}
|
|
461
|
-
this.cwd = path.resolve(cwd);
|
|
462
|
-
}
|
|
463
|
-
getWorkspaceRoot() {
|
|
464
|
-
return this.cwd;
|
|
465
|
-
}
|
|
466
|
-
getErrorMessage(error) {
|
|
467
|
-
if (error instanceof Error) {
|
|
468
|
-
return error.message;
|
|
469
|
-
}
|
|
470
|
-
if (typeof error === 'string') {
|
|
471
|
-
return error;
|
|
472
|
-
}
|
|
473
|
-
try {
|
|
474
|
-
return JSON.stringify(error);
|
|
475
|
-
}
|
|
476
|
-
catch {
|
|
477
|
-
return String(error);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
assertToolCall(call) {
|
|
481
|
-
if (!call || typeof call !== 'object') {
|
|
482
|
-
throw new Error('Invalid tool call: call must be an object.');
|
|
483
|
-
}
|
|
484
|
-
if (typeof call.tool !== 'string' || call.tool.trim().length === 0) {
|
|
485
|
-
throw new Error('Invalid tool call: tool name must be a non-empty string.');
|
|
486
|
-
}
|
|
487
|
-
if (!call.args || typeof call.args !== 'object' || Array.isArray(call.args)) {
|
|
488
|
-
throw new Error(`Invalid tool call for ${call.tool}: args must be an object.`);
|
|
489
|
-
}
|
|
490
|
-
for (const [key, value] of Object.entries(call.args)) {
|
|
491
|
-
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
492
|
-
throw new Error(`Invalid tool call for ${call.tool}: argument names must be non-empty strings.`);
|
|
493
|
-
}
|
|
494
|
-
if (typeof value !== 'string') {
|
|
495
|
-
throw new Error(`Invalid argument "${key}" for ${call.tool}: expected string, received ${typeof value}.`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Load persistent permissions for the current project
|
|
501
|
-
*/
|
|
502
|
-
loadPersistentPermissions() {
|
|
503
|
-
try {
|
|
504
|
-
if (fs.existsSync(AgenticTools.permissionsFile)) {
|
|
505
|
-
return JSON.parse(fs.readFileSync(AgenticTools.permissionsFile, 'utf-8'));
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
catch (error) {
|
|
509
|
-
const message = this.formatExternalToolError('permissions', `read ${AgenticTools.permissionsFile}`, error);
|
|
510
|
-
this.logger.debug(message);
|
|
511
|
-
this.logger.warn(message);
|
|
512
|
-
}
|
|
513
|
-
return {};
|
|
514
|
-
}
|
|
515
|
-
/**
|
|
516
|
-
* Save a persistent permission for a tool in the current project
|
|
517
|
-
*/
|
|
518
|
-
savePersistentPermission(toolName) {
|
|
519
|
-
const permissions = this.loadPersistentPermissions();
|
|
520
|
-
const projectKey = this.cwd;
|
|
521
|
-
if (!permissions[projectKey]) {
|
|
522
|
-
permissions[projectKey] = { tools: [], updatedAt: new Date().toISOString() };
|
|
523
|
-
}
|
|
524
|
-
if (!permissions[projectKey].tools.includes(toolName)) {
|
|
525
|
-
permissions[projectKey].tools.push(toolName);
|
|
526
|
-
permissions[projectKey].updatedAt = new Date().toISOString();
|
|
527
|
-
}
|
|
528
|
-
try {
|
|
529
|
-
const dir = path.dirname(AgenticTools.permissionsFile);
|
|
530
|
-
if (!fs.existsSync(dir))
|
|
531
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
532
|
-
fs.writeFileSync(AgenticTools.permissionsFile, JSON.stringify(permissions, null, 2), 'utf-8');
|
|
533
|
-
}
|
|
534
|
-
catch (error) {
|
|
535
|
-
const message = this.formatExternalToolError('permissions', `write ${AgenticTools.permissionsFile}`, error);
|
|
536
|
-
this.logger.debug(message);
|
|
537
|
-
this.logger.warn(message);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
/**
|
|
541
|
-
* Check if a tool has persistent permission for the current project
|
|
542
|
-
*/
|
|
543
|
-
hasPersistentPermission(toolName) {
|
|
544
|
-
const permissions = this.loadPersistentPermissions();
|
|
545
|
-
const projectKey = this.cwd;
|
|
546
|
-
return permissions[projectKey]?.tools?.includes(toolName) || false;
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* Clear session-approved tools (call this at the start of each new AI turn)
|
|
550
|
-
*/
|
|
551
|
-
clearSessionApprovals() {
|
|
552
|
-
this.sessionApprovedTools.clear();
|
|
553
|
-
}
|
|
554
|
-
/**
|
|
555
|
-
* Get currently approved tools for this session
|
|
556
|
-
*/
|
|
557
|
-
getSessionApprovedTools() {
|
|
558
|
-
return Array.from(this.sessionApprovedTools);
|
|
559
|
-
}
|
|
560
|
-
/**
|
|
561
|
-
* Get the undo stack for inspection
|
|
562
|
-
*/
|
|
563
|
-
getUndoStack() {
|
|
564
|
-
return [...this.undoStack];
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* Undo the last file operation
|
|
568
|
-
*/
|
|
569
|
-
async undo() {
|
|
570
|
-
const lastOp = this.undoStack.pop();
|
|
571
|
-
if (!lastOp) {
|
|
572
|
-
return {
|
|
573
|
-
success: false,
|
|
574
|
-
error: 'Nothing to undo',
|
|
575
|
-
suggestion: 'The undo stack is empty. Only file write/edit operations can be undone.',
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
if (lastOp.filePath) {
|
|
579
|
-
try {
|
|
580
|
-
if (lastOp.originalContent === null) {
|
|
581
|
-
// File was created, so delete it
|
|
582
|
-
if (fs.existsSync(lastOp.filePath)) {
|
|
583
|
-
fs.unlinkSync(lastOp.filePath);
|
|
584
|
-
return {
|
|
585
|
-
success: true,
|
|
586
|
-
output: `${CH.success} Undone: ${lastOp.description}\n File deleted: ${lastOp.filePath}`,
|
|
587
|
-
metadata: { remainingUndos: this.undoStack.length },
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
else if (lastOp.originalContent !== undefined) {
|
|
592
|
-
// Restore original content
|
|
593
|
-
fs.writeFileSync(lastOp.filePath, lastOp.originalContent, 'utf-8');
|
|
594
|
-
return {
|
|
595
|
-
success: true,
|
|
596
|
-
output: `${CH.success} Undone: ${lastOp.description}\n File restored: ${lastOp.filePath}`,
|
|
597
|
-
metadata: { remainingUndos: this.undoStack.length },
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
catch (error) {
|
|
602
|
-
return {
|
|
603
|
-
success: false,
|
|
604
|
-
error: `Failed to undo: ${error.message}`,
|
|
605
|
-
suggestion: 'The file may have been moved or permissions changed.',
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
return {
|
|
610
|
-
success: false,
|
|
611
|
-
error: 'This operation cannot be undone',
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
/**
|
|
615
|
-
* List of available tools - Vigthoria's advanced
|
|
616
|
-
* Enhanced with risk levels and categories
|
|
617
|
-
*/
|
|
618
|
-
static getToolDefinitions() {
|
|
619
|
-
return [
|
|
620
|
-
{
|
|
621
|
-
name: 'read_file',
|
|
622
|
-
description: 'Read the contents of a file',
|
|
623
|
-
parameters: [
|
|
624
|
-
{ name: 'path', description: 'File path (relative or absolute)', required: true },
|
|
625
|
-
{ name: 'start_line', description: 'Start line (1-indexed)', required: false },
|
|
626
|
-
{ name: 'end_line', description: 'End line (1-indexed)', required: false },
|
|
627
|
-
],
|
|
628
|
-
requiresPermission: false,
|
|
629
|
-
dangerous: false,
|
|
630
|
-
riskLevel: 'low',
|
|
631
|
-
category: 'read',
|
|
632
|
-
},
|
|
633
|
-
{
|
|
634
|
-
name: 'write_file',
|
|
635
|
-
description: 'Write content to a file (creates if not exists)',
|
|
636
|
-
parameters: [
|
|
637
|
-
{ name: 'path', description: 'File path', required: true },
|
|
638
|
-
{ name: 'content', description: 'Content to write', required: true },
|
|
639
|
-
],
|
|
640
|
-
requiresPermission: true,
|
|
641
|
-
dangerous: false,
|
|
642
|
-
riskLevel: 'medium',
|
|
643
|
-
category: 'write',
|
|
644
|
-
},
|
|
645
|
-
{
|
|
646
|
-
name: 'edit_file',
|
|
647
|
-
description: 'Make specific edits to a file by replacing text',
|
|
648
|
-
parameters: [
|
|
649
|
-
{ name: 'path', description: 'File path', required: true },
|
|
650
|
-
{ name: 'old_text', description: 'Text to replace', required: true },
|
|
651
|
-
{ name: 'new_text', description: 'New text', required: true },
|
|
652
|
-
],
|
|
653
|
-
requiresPermission: true,
|
|
654
|
-
dangerous: false,
|
|
655
|
-
riskLevel: 'medium',
|
|
656
|
-
category: 'write',
|
|
657
|
-
},
|
|
658
|
-
{
|
|
659
|
-
name: 'bash',
|
|
660
|
-
description: 'Run a shell command',
|
|
661
|
-
parameters: [
|
|
662
|
-
{ name: 'command', description: 'Shell command to execute', required: true },
|
|
663
|
-
{ name: 'cwd', description: 'Working directory', required: false },
|
|
664
|
-
{ name: 'timeout', description: 'Timeout in seconds', required: false },
|
|
665
|
-
],
|
|
666
|
-
requiresPermission: true,
|
|
667
|
-
dangerous: true,
|
|
668
|
-
riskLevel: 'high',
|
|
669
|
-
category: 'execute',
|
|
670
|
-
},
|
|
671
|
-
{
|
|
672
|
-
name: 'grep',
|
|
673
|
-
description: 'Search for patterns in files',
|
|
674
|
-
parameters: [
|
|
675
|
-
{ name: 'pattern', description: 'Search pattern (regex)', required: true },
|
|
676
|
-
{ name: 'path', description: 'File or directory path', required: false },
|
|
677
|
-
{ name: 'include', description: 'File pattern to include (e.g., *.ts)', required: false },
|
|
678
|
-
],
|
|
679
|
-
requiresPermission: false,
|
|
680
|
-
dangerous: false,
|
|
681
|
-
riskLevel: 'low',
|
|
682
|
-
category: 'search',
|
|
683
|
-
},
|
|
684
|
-
{
|
|
685
|
-
name: 'list_dir',
|
|
686
|
-
description: 'List contents of a directory',
|
|
687
|
-
parameters: [
|
|
688
|
-
{ name: 'path', description: 'Directory path', required: true },
|
|
689
|
-
{ name: 'recursive', description: 'List recursively', required: false },
|
|
690
|
-
],
|
|
691
|
-
requiresPermission: false,
|
|
692
|
-
dangerous: false,
|
|
693
|
-
riskLevel: 'low',
|
|
694
|
-
category: 'read',
|
|
695
|
-
},
|
|
696
|
-
{
|
|
697
|
-
name: 'glob',
|
|
698
|
-
description: 'Find files matching a pattern',
|
|
699
|
-
parameters: [
|
|
700
|
-
{ name: 'pattern', description: 'Glob pattern (e.g., **/*.ts)', required: true },
|
|
701
|
-
],
|
|
702
|
-
requiresPermission: false,
|
|
703
|
-
dangerous: false,
|
|
704
|
-
riskLevel: 'low',
|
|
705
|
-
category: 'search',
|
|
706
|
-
},
|
|
707
|
-
{
|
|
708
|
-
name: 'git',
|
|
709
|
-
description: 'Run git commands',
|
|
710
|
-
parameters: [
|
|
711
|
-
{ name: 'args', description: 'Git command arguments', required: true },
|
|
712
|
-
],
|
|
713
|
-
requiresPermission: true,
|
|
714
|
-
dangerous: false,
|
|
715
|
-
riskLevel: 'medium',
|
|
716
|
-
category: 'execute',
|
|
717
|
-
},
|
|
718
|
-
{
|
|
719
|
-
name: 'repo',
|
|
720
|
-
description: 'Manage projects in Vigthoria Repository - push, pull, list, share, delete, or clone projects',
|
|
721
|
-
parameters: [
|
|
722
|
-
{ name: 'action', description: 'Action: push, pull, list, status, share, delete, clone', required: true },
|
|
723
|
-
{ name: 'project', description: 'Project name (for push/pull/status/share/delete)', required: false },
|
|
724
|
-
{ name: 'files', description: 'Files to push as JSON object {filename: content} or array [{path, content}]', required: false },
|
|
725
|
-
{ name: 'description', description: 'Project description (for push)', required: false },
|
|
726
|
-
{ name: 'visibility', description: 'Visibility: public or private (for push)', required: false },
|
|
727
|
-
{ name: 'path', description: 'Directory path (for push) or target path (for clone)', required: false },
|
|
728
|
-
{ name: 'username', description: 'Username to share with (for share action)', required: false },
|
|
729
|
-
{ name: 'permission', description: 'Permission level: read, write, admin (for share action)', required: false },
|
|
730
|
-
],
|
|
731
|
-
requiresPermission: true,
|
|
732
|
-
dangerous: false,
|
|
733
|
-
riskLevel: 'medium',
|
|
734
|
-
category: 'execute',
|
|
735
|
-
},
|
|
736
|
-
{
|
|
737
|
-
name: 'fetch_url',
|
|
738
|
-
description: 'Fetch content from a URL (web page, API endpoint). Works cross-platform on Windows, Mac, and Linux.',
|
|
739
|
-
parameters: [
|
|
740
|
-
{ name: 'url', description: 'URL to fetch (must be http or https)', required: true },
|
|
741
|
-
{ name: 'method', description: 'HTTP method (GET, POST, etc.)', required: false },
|
|
742
|
-
{ name: 'headers', description: 'JSON string of headers to send', required: false },
|
|
743
|
-
{ name: 'body', description: 'Request body for POST/PUT requests', required: false },
|
|
744
|
-
{ name: 'selector', description: 'CSS selector to extract specific content (for HTML)', required: false },
|
|
745
|
-
],
|
|
746
|
-
requiresPermission: true,
|
|
747
|
-
dangerous: false,
|
|
748
|
-
riskLevel: 'low',
|
|
749
|
-
category: 'read',
|
|
750
|
-
},
|
|
751
|
-
{
|
|
752
|
-
name: 'browser',
|
|
753
|
-
description: 'Drive a real headless Chromium via the Vigthoria DevTools Bridge. Use to capture a page (screenshot+HTML+metrics), collect console/page errors, or run a quick smoke test on a URL. Cross-platform.',
|
|
754
|
-
parameters: [
|
|
755
|
-
{ name: 'mode', description: "One of: 'capture' (screenshot + html + metrics), 'errors' (console + page errors), 'test' (quick smoke test)", required: true },
|
|
756
|
-
{ name: 'url', description: 'Target URL (must start with http:// or https://)', required: true },
|
|
757
|
-
{ name: 'include_screenshot', description: "For mode=capture: 'true' to include base64 screenshot (default true), 'false' to skip", required: false },
|
|
758
|
-
],
|
|
759
|
-
requiresPermission: true,
|
|
760
|
-
dangerous: false,
|
|
761
|
-
riskLevel: 'low',
|
|
762
|
-
category: 'read',
|
|
763
|
-
},
|
|
764
|
-
{
|
|
765
|
-
name: 'ssh_exec',
|
|
766
|
-
description: 'Execute a command on a remote server via SSH (useful for Unix commands from Windows)',
|
|
767
|
-
parameters: [
|
|
768
|
-
{ name: 'command', description: 'Command to execute on remote server', required: true },
|
|
769
|
-
{ name: 'host', description: 'SSH host (default: vigthoria-server)', required: false },
|
|
770
|
-
{ name: 'timeout', description: 'Timeout in seconds', required: false },
|
|
771
|
-
],
|
|
772
|
-
requiresPermission: true,
|
|
773
|
-
dangerous: true,
|
|
774
|
-
riskLevel: 'high',
|
|
775
|
-
category: 'execute',
|
|
776
|
-
},
|
|
777
|
-
{
|
|
778
|
-
name: 'task',
|
|
779
|
-
description: 'Launch an independent sub-agent to handle a complex subtask. The sub-agent has its own context and tool access, runs autonomously, and returns a single result. Use this to parallelize work or delegate research.',
|
|
780
|
-
parameters: [
|
|
781
|
-
{ name: 'description', description: 'A detailed description of the subtask for the sub-agent to perform', required: true },
|
|
782
|
-
{ name: 'working_dir', description: 'Working directory for the sub-agent (relative to project root)', required: false },
|
|
783
|
-
],
|
|
784
|
-
requiresPermission: true,
|
|
785
|
-
dangerous: false,
|
|
786
|
-
riskLevel: 'medium',
|
|
787
|
-
category: 'execute',
|
|
788
|
-
},
|
|
789
|
-
{
|
|
790
|
-
name: 'multi_edit',
|
|
791
|
-
description: 'Apply multiple file edits atomically. All edits succeed or all are rolled back. Each edit specifies a file, old_text to find, and new_text to replace it with.',
|
|
792
|
-
parameters: [
|
|
793
|
-
{ name: 'edits', description: 'JSON array of edits: [{"path": "file.ts", "old_text": "find this", "new_text": "replace with"}]', required: true },
|
|
794
|
-
],
|
|
795
|
-
requiresPermission: true,
|
|
796
|
-
dangerous: false,
|
|
797
|
-
riskLevel: 'medium',
|
|
798
|
-
category: 'write',
|
|
799
|
-
},
|
|
800
|
-
{
|
|
801
|
-
name: 'codebase_search',
|
|
802
|
-
description: 'Perform a deep semantic search across the entire codebase. Searches file names, symbol names (functions, classes, variables), and content across all files - not limited to the workspace snapshot. Use for large projects.',
|
|
803
|
-
parameters: [
|
|
804
|
-
{ name: 'query', description: 'Search query - can be a symbol name, concept, or natural language description', required: true },
|
|
805
|
-
{ name: 'scope', description: 'Search scope: "symbols" (functions/classes), "files" (filenames), "content" (full-text), or "all" (default)', required: false },
|
|
806
|
-
{ name: 'include', description: 'File pattern to include (e.g., "*.ts", "src/**/*.js")', required: false },
|
|
807
|
-
{ name: 'max_results', description: 'Maximum results to return (default: 30)', required: false },
|
|
808
|
-
],
|
|
809
|
-
requiresPermission: false,
|
|
810
|
-
dangerous: false,
|
|
811
|
-
riskLevel: 'low',
|
|
812
|
-
category: 'search',
|
|
813
|
-
},
|
|
814
|
-
];
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* Execute a tool call with enhanced error handling and retry logic
|
|
818
|
-
*/
|
|
819
|
-
async execute(call) {
|
|
820
|
-
// Guard against malformed tool calls before accessing fields.
|
|
821
|
-
if (!call || typeof call !== 'object') {
|
|
822
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid tool call: expected an object, received ${call === null ? 'null' : typeof call}`, 'Provide a valid ToolCall object with a non-empty tool name and args object.');
|
|
823
|
-
}
|
|
824
|
-
if (!call.tool || typeof call.tool !== 'string' || !call.tool.trim()) {
|
|
825
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid tool call: tool name is ${call.tool === undefined ? 'undefined' : typeof call.tool === 'string' ? 'empty' : typeof call.tool}`, `Provide a valid tool name. Available tools: ${AgenticTools.getToolDefinitions().map(t => t.name).join(', ')}`);
|
|
826
|
-
}
|
|
827
|
-
if (!call.args || typeof call.args !== 'object' || Array.isArray(call.args)) {
|
|
828
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid tool call for '${call.tool}': args must be a key/value object`, 'Provide args as an object with string values, for example { path: "src/index.ts" }.');
|
|
829
|
-
}
|
|
830
|
-
try {
|
|
831
|
-
this.assertStringRecord(call.args, `Invalid tool call for '${call.tool}': args`);
|
|
832
|
-
}
|
|
833
|
-
catch (error) {
|
|
834
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, this.getErrorMessage(error), 'All tool argument names must be non-empty strings and every argument value must be a string.');
|
|
835
|
-
}
|
|
836
|
-
const normalizedCall = this.normalizeToolCall(call);
|
|
837
|
-
const tool = AgenticTools.getToolDefinitions().find(t => t.name === normalizedCall.tool);
|
|
838
|
-
if (!tool) {
|
|
839
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Unknown tool: ${call.tool}`, `Available tools: ${AgenticTools.getToolDefinitions().map(t => t.name).join(', ')}`);
|
|
840
|
-
}
|
|
841
|
-
// Validate required parameters
|
|
842
|
-
const validationError = this.validateParameters(normalizedCall, tool);
|
|
843
|
-
if (validationError) {
|
|
844
|
-
return validationError;
|
|
845
|
-
}
|
|
846
|
-
// Check permission for dangerous/modifying actions
|
|
847
|
-
if (tool.requiresPermission && !this.autoApprove) {
|
|
848
|
-
const requiresPerCommandApproval = normalizedCall.tool === 'bash';
|
|
849
|
-
if (requiresPerCommandApproval) {
|
|
850
|
-
const approved = await this.permissionCallback(this.formatPermissionRequest(normalizedCall, tool), { batchApproval: false });
|
|
851
|
-
if (approved === false) {
|
|
852
|
-
return {
|
|
853
|
-
success: false,
|
|
854
|
-
error: 'Permission denied by user',
|
|
855
|
-
canRetry: true,
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
else if (this.hasPersistentPermission(normalizedCall.tool)) {
|
|
860
|
-
// Check persistent permissions first (project-scoped), then session
|
|
861
|
-
this.logger.info(`${call.tool}: Auto-approved (persistent)`);
|
|
862
|
-
}
|
|
863
|
-
else if (this.sessionApprovedTools.has(normalizedCall.tool)) {
|
|
864
|
-
// Already approved - skip permission prompt
|
|
865
|
-
this.logger.info(`${call.tool}: Auto-approved (batch)`);
|
|
866
|
-
}
|
|
867
|
-
else {
|
|
868
|
-
const approved = await this.permissionCallback(this.formatPermissionRequest(normalizedCall, tool), { batchApproval: true });
|
|
869
|
-
if (approved === false) {
|
|
870
|
-
return {
|
|
871
|
-
success: false,
|
|
872
|
-
error: 'Permission denied by user',
|
|
873
|
-
canRetry: true,
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
// 'batch' (typed 'a') = approve for this session
|
|
877
|
-
// 'persist' (typed 'p') = approve permanently for this project
|
|
878
|
-
// 'y' or 'yes' = approve this single request only
|
|
879
|
-
if (approved === 'batch') {
|
|
880
|
-
this.sessionApprovedTools.add(normalizedCall.tool);
|
|
881
|
-
}
|
|
882
|
-
else if (approved === 'persist') {
|
|
883
|
-
this.sessionApprovedTools.add(normalizedCall.tool);
|
|
884
|
-
this.savePersistentPermission(normalizedCall.tool);
|
|
885
|
-
this.logger.info(`${normalizedCall.tool}: Saved as persistent permission`);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
// Execute with retry logic for applicable operations
|
|
890
|
-
return this.executeWithRetry(normalizedCall, tool);
|
|
891
|
-
}
|
|
892
|
-
normalizeToolCall(call) {
|
|
893
|
-
const tool = String(call.tool || '').trim();
|
|
894
|
-
const originalArgs = call.args || {};
|
|
895
|
-
const args = { ...originalArgs };
|
|
896
|
-
const aliasMap = TOOL_ARG_ALIASES[tool] || {};
|
|
897
|
-
for (const [canonicalName, aliases] of Object.entries(aliasMap)) {
|
|
898
|
-
if (args[canonicalName]) {
|
|
899
|
-
continue;
|
|
900
|
-
}
|
|
901
|
-
for (const alias of aliases) {
|
|
902
|
-
const aliasValue = args[alias];
|
|
903
|
-
if (typeof aliasValue === 'string' && aliasValue.trim()) {
|
|
904
|
-
args[canonicalName] = aliasValue;
|
|
905
|
-
break;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
if (tool === 'list_dir' && !args.path) {
|
|
910
|
-
args.path = '.';
|
|
911
|
-
}
|
|
912
|
-
if (tool === 'glob') {
|
|
913
|
-
if (!args.pattern && args.path) {
|
|
914
|
-
args.pattern = args.path;
|
|
915
|
-
}
|
|
916
|
-
if (args.pattern) {
|
|
917
|
-
const normalizedPattern = args.pattern.trim().replace(/\\/g, '/');
|
|
918
|
-
if (normalizedPattern && !/[\[*?{}]/.test(normalizedPattern)) {
|
|
919
|
-
if (normalizedPattern.includes('/')) {
|
|
920
|
-
args.pattern = normalizedPattern;
|
|
921
|
-
}
|
|
922
|
-
else {
|
|
923
|
-
args.pattern = `**/${normalizedPattern}`;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
else {
|
|
927
|
-
args.pattern = normalizedPattern;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
return { tool, args };
|
|
932
|
-
}
|
|
933
|
-
/**
|
|
934
|
-
* Execute tool with automatic retry for transient failures
|
|
935
|
-
*/
|
|
936
|
-
async executeWithRetry(call, tool) {
|
|
937
|
-
let lastError = null;
|
|
938
|
-
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
939
|
-
try {
|
|
940
|
-
const result = await this.executeTool(call);
|
|
941
|
-
if (result.success) {
|
|
942
|
-
return result;
|
|
943
|
-
}
|
|
944
|
-
// Check if error is retryable
|
|
945
|
-
const isRetryable = result.canRetry !== false &&
|
|
946
|
-
this.isRetryableError(result.error || '');
|
|
947
|
-
if (!isRetryable || attempt === this.retryConfig.maxRetries) {
|
|
948
|
-
return result;
|
|
949
|
-
}
|
|
950
|
-
lastError = result;
|
|
951
|
-
// Calculate delay with exponential backoff
|
|
952
|
-
const delay = Math.min(this.retryConfig.baseDelayMs * Math.pow(2, attempt), this.retryConfig.maxDelayMs);
|
|
953
|
-
this.logger.warn(`Retrying ${call.tool} in ${delay}ms (attempt ${attempt + 2}/${this.retryConfig.maxRetries + 1})...`);
|
|
954
|
-
await this.sleep(delay);
|
|
955
|
-
}
|
|
956
|
-
catch (error) {
|
|
957
|
-
lastError = this.createErrorResult(ToolErrorType.EXECUTION_FAILED, error.message);
|
|
958
|
-
if (attempt === this.retryConfig.maxRetries) {
|
|
959
|
-
return lastError;
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
return lastError || this.createErrorResult(ToolErrorType.EXECUTION_FAILED, 'Unknown error after retries');
|
|
964
|
-
}
|
|
965
|
-
/**
|
|
966
|
-
* Tool names that mutate the user's filesystem, shell state, or remote
|
|
967
|
-
* services. Used by the dry-run / read-only gate below.
|
|
968
|
-
*/
|
|
969
|
-
static MUTATING_TOOLS = new Set([
|
|
970
|
-
'write_file', 'edit_file', 'multi_edit',
|
|
971
|
-
'bash', 'ssh_exec',
|
|
972
|
-
'git', 'repo',
|
|
973
|
-
]);
|
|
974
|
-
/**
|
|
975
|
-
* `VIGTHORIA_DRY_RUN=1` and `VIGTHORIA_READ_ONLY=1` are end-user safety
|
|
976
|
-
* switches: every mutating tool short-circuits with a clear "would have
|
|
977
|
-
* happened" result instead of touching the filesystem or network.
|
|
978
|
-
*
|
|
979
|
-
* The flags are checked at call-time (not at construction time) so they
|
|
980
|
-
* can be flipped per-prompt by users who want to inspect agent intent
|
|
981
|
-
* before committing.
|
|
982
|
-
*/
|
|
983
|
-
isDryRunActive() {
|
|
984
|
-
const flag = (name) => String(process.env[name] || '').trim().toLowerCase();
|
|
985
|
-
const isOn = (v) => v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
986
|
-
return isOn(flag('VIGTHORIA_DRY_RUN')) || isOn(flag('VIGTHORIA_READ_ONLY'));
|
|
987
|
-
}
|
|
988
|
-
/**
|
|
989
|
-
* Execute the actual tool operation
|
|
990
|
-
*/
|
|
991
|
-
async executeTool(call) {
|
|
992
|
-
try {
|
|
993
|
-
if (this.isDryRunActive() && AgenticTools.MUTATING_TOOLS.has(call.tool)) {
|
|
994
|
-
const argSummary = this.safeStringifyArgs(call.args);
|
|
995
|
-
const message = `Dry-run: ${call.tool} would have run with ${argSummary}. Unset VIGTHORIA_DRY_RUN to execute.`;
|
|
996
|
-
this.logger.warn(message);
|
|
997
|
-
return {
|
|
998
|
-
success: true,
|
|
999
|
-
output: '',
|
|
1000
|
-
dryRun: true,
|
|
1001
|
-
message,
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
switch (call.tool) {
|
|
1005
|
-
case 'read_file':
|
|
1006
|
-
return this.readFile(call.args);
|
|
1007
|
-
case 'write_file':
|
|
1008
|
-
return this.writeFile(call.args);
|
|
1009
|
-
case 'edit_file':
|
|
1010
|
-
return this.editFile(call.args);
|
|
1011
|
-
case 'bash':
|
|
1012
|
-
return this.bash(call.args);
|
|
1013
|
-
case 'grep':
|
|
1014
|
-
return this.grep(call.args);
|
|
1015
|
-
case 'list_dir':
|
|
1016
|
-
return this.listDir(call.args);
|
|
1017
|
-
case 'glob':
|
|
1018
|
-
return this.glob(call.args);
|
|
1019
|
-
case 'git':
|
|
1020
|
-
return this.git(call.args);
|
|
1021
|
-
case 'repo':
|
|
1022
|
-
return this.repo(call.args);
|
|
1023
|
-
case 'fetch_url':
|
|
1024
|
-
return this.fetchUrl(call.args);
|
|
1025
|
-
case 'browser':
|
|
1026
|
-
return this.browserTool(call.args);
|
|
1027
|
-
case 'ssh_exec':
|
|
1028
|
-
return this.sshExec(call.args);
|
|
1029
|
-
case 'task':
|
|
1030
|
-
return this.task(call.args);
|
|
1031
|
-
case 'multi_edit':
|
|
1032
|
-
return this.multiEdit(call.args);
|
|
1033
|
-
case 'codebase_search':
|
|
1034
|
-
return this.codebaseSearch(call.args);
|
|
1035
|
-
default:
|
|
1036
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Tool not implemented: ${call.tool}`);
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
catch (error) {
|
|
1040
|
-
const context = `Tool '${call.tool}' failed while executing with args ${this.safeStringifyArgs(call.args)}`;
|
|
1041
|
-
const stderr = error?.stderr?.toString?.().trim();
|
|
1042
|
-
const stdout = error?.stdout?.toString?.().trim();
|
|
1043
|
-
const status = error?.status !== undefined ? `exit status ${error.status}` : undefined;
|
|
1044
|
-
const signal = error?.signal ? `signal ${error.signal}` : undefined;
|
|
1045
|
-
const details = [context, status, signal, stderr || error?.message || String(error)].filter(Boolean).join(': ');
|
|
1046
|
-
this.logger.error(details);
|
|
1047
|
-
return {
|
|
1048
|
-
success: false,
|
|
1049
|
-
output: stdout,
|
|
1050
|
-
error: details,
|
|
1051
|
-
suggestion: 'Review the tool arguments and subprocess output above, then retry with corrected input.',
|
|
1052
|
-
canRetry: this.isRetryableError(details),
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* Check if an error is retryable
|
|
1058
|
-
*/
|
|
1059
|
-
isRetryableError(error) {
|
|
1060
|
-
return this.retryConfig.retryableErrors.some(e => error.includes(e));
|
|
1061
|
-
}
|
|
1062
|
-
/**
|
|
1063
|
-
* Safely stringify tool arguments without dumping very large payloads into logs.
|
|
1064
|
-
*/
|
|
1065
|
-
safeStringifyArgs(args) {
|
|
1066
|
-
try {
|
|
1067
|
-
const json = JSON.stringify(args ?? {}, (_key, value) => {
|
|
1068
|
-
if (typeof value === 'string' && value.length > 500) {
|
|
1069
|
-
return `${value.slice(0, 500)}...<truncated ${value.length - 500} chars>`;
|
|
1070
|
-
}
|
|
1071
|
-
return value;
|
|
1072
|
-
});
|
|
1073
|
-
return json || '{}';
|
|
1074
|
-
}
|
|
1075
|
-
catch (error) {
|
|
1076
|
-
return `[unserializable args: ${error instanceof Error ? error.message : String(error)}]`;
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
/**
|
|
1080
|
-
* Validate tool parameters
|
|
1081
|
-
*/
|
|
1082
|
-
validateParameters(call, tool) {
|
|
1083
|
-
if (!call || typeof call !== 'object') {
|
|
1084
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Invalid tool call: expected an object with tool and args fields', 'Pass a ToolCall object shaped like { tool: "read_file", args: { path: "file.ts" } }.');
|
|
1085
|
-
}
|
|
1086
|
-
if (!call.args || typeof call.args !== 'object' || Array.isArray(call.args)) {
|
|
1087
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid arguments for ${tool.name}: args must be a key/value object`, `Pass ${tool.name} arguments as an object. Received: ${this.safeStringifyArgs(call.args)}`);
|
|
1088
|
-
}
|
|
1089
|
-
for (const [key, value] of Object.entries(call.args)) {
|
|
1090
|
-
if (!key || typeof key !== 'string') {
|
|
1091
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid argument key for ${tool.name}: keys must be non-empty strings`, 'Use documented parameter names for this tool.');
|
|
1092
|
-
}
|
|
1093
|
-
if (typeof value !== 'string') {
|
|
1094
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid argument '${key}' for ${tool.name}: expected string value, received ${Array.isArray(value) ? 'array' : typeof value}`, `Convert '${key}' to a string before invoking ${tool.name}.`);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
for (const param of tool.parameters) {
|
|
1098
|
-
const value = call.args[param.name];
|
|
1099
|
-
if (param.required && (value === undefined || value.trim() === '')) {
|
|
1100
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Missing required parameter '${param.name}' for tool '${call.tool}'`, `The ${call.tool} tool requires the '${param.name}' parameter. ${param.description}`);
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
const pathLikeParams = ['path', 'working_dir', 'cwd'];
|
|
1104
|
-
for (const paramName of pathLikeParams) {
|
|
1105
|
-
const value = call.args[paramName];
|
|
1106
|
-
if (typeof value === 'string' && value.includes('\0')) {
|
|
1107
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid ${paramName} for ${tool.name}: paths cannot contain null bytes`, 'Remove null bytes from the path and retry.');
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
if (call.args.start_line !== undefined && (!/^\d+$/.test(call.args.start_line) || Number(call.args.start_line) < 1)) {
|
|
1111
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid start_line for ${tool.name}: expected a positive integer, received '${call.args.start_line}'`, 'Use a 1-based positive integer for start_line.');
|
|
1112
|
-
}
|
|
1113
|
-
if (call.args.end_line !== undefined && (!/^\d+$/.test(call.args.end_line) || Number(call.args.end_line) < 0)) {
|
|
1114
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid end_line for ${tool.name}: expected a non-negative integer, received '${call.args.end_line}'`, 'Use 0 for end-of-file or a positive 1-based line number for end_line.');
|
|
1115
|
-
}
|
|
1116
|
-
if (call.args.start_line !== undefined && call.args.end_line !== undefined) {
|
|
1117
|
-
const startLine = Number(call.args.start_line);
|
|
1118
|
-
const endLine = Number(call.args.end_line);
|
|
1119
|
-
if (endLine !== 0 && endLine < startLine) {
|
|
1120
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid line range for ${tool.name}: end_line (${endLine}) is before start_line (${startLine})`, 'Use an end_line greater than or equal to start_line, or 0 to read through EOF.');
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
if (call.args.timeout !== undefined && (!/^\d+$/.test(call.args.timeout) || Number(call.args.timeout) < 1)) {
|
|
1124
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid timeout for ${tool.name}: expected a positive integer number of seconds, received '${call.args.timeout}'`, 'Use a positive integer timeout in seconds.');
|
|
1125
|
-
}
|
|
1126
|
-
if (call.args.max_results !== undefined && (!/^\d+$/.test(call.args.max_results) || Number(call.args.max_results) < 1)) {
|
|
1127
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid max_results for ${tool.name}: expected a positive integer, received '${call.args.max_results}'`, 'Use a positive integer for max_results.');
|
|
1128
|
-
}
|
|
1129
|
-
if (call.tool === 'list_dir' && call.args.recursive !== undefined && !['true', 'false', '1', '0', 'yes', 'no'].includes(call.args.recursive.toLowerCase())) {
|
|
1130
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid recursive value for list_dir: expected true or false, received '${call.args.recursive}'`, 'Use recursive="true" or recursive="false".');
|
|
1131
|
-
}
|
|
1132
|
-
if (call.tool === 'fetch_url' && call.args.url !== undefined) {
|
|
1133
|
-
try {
|
|
1134
|
-
const parsed = new URL(call.args.url);
|
|
1135
|
-
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
1136
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid URL protocol for fetch_url: '${parsed.protocol}'`, 'Use an http:// or https:// URL.');
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
catch (error) {
|
|
1140
|
-
const message = this.getErrorMessage(error);
|
|
1141
|
-
this.logger.debug(`Invalid URL for fetch_url '${call.args.url}': ${message}`);
|
|
1142
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid URL for fetch_url: '${call.args.url}'`, 'Provide a fully-qualified http:// or https:// URL.');
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
if (call.tool === 'multi_edit' && call.args.edits !== undefined) {
|
|
1146
|
-
try {
|
|
1147
|
-
const edits = JSON.parse(call.args.edits);
|
|
1148
|
-
if (!Array.isArray(edits) || edits.length === 0) {
|
|
1149
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Invalid edits for multi_edit: expected a non-empty JSON array', 'Provide edits as a JSON array of { path, old_text, new_text } objects.');
|
|
1150
|
-
}
|
|
1151
|
-
for (const [index, edit] of edits.entries()) {
|
|
1152
|
-
if (!edit || typeof edit !== 'object' || Array.isArray(edit)) {
|
|
1153
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid edit at index ${index}: expected an object`);
|
|
1154
|
-
}
|
|
1155
|
-
for (const field of ['path', 'old_text', 'new_text']) {
|
|
1156
|
-
if (typeof edit[field] !== 'string') {
|
|
1157
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid edit at index ${index}: '${field}' must be a string`);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
catch (error) {
|
|
1163
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid edits JSON for multi_edit: ${error instanceof Error ? error.message : String(error)}`, 'Pass a valid JSON array string for the edits parameter.');
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
return null;
|
|
1167
|
-
}
|
|
1168
|
-
/**
|
|
1169
|
-
* Create a standardized error result
|
|
1170
|
-
*/
|
|
1171
|
-
createErrorResult(type, message, suggestion) {
|
|
1172
|
-
const suggestions = {
|
|
1173
|
-
[ToolErrorType.FILE_NOT_FOUND]: 'Check if the file path is correct. Use list_dir to see available files.',
|
|
1174
|
-
[ToolErrorType.PERMISSION_DENIED]: 'You may need elevated permissions. Try using sudo or check file ownership.',
|
|
1175
|
-
[ToolErrorType.NETWORK_ERROR]: 'Check your internet connection. The operation will retry automatically.',
|
|
1176
|
-
[ToolErrorType.TIMEOUT]: 'The operation took too long. Try with a shorter timeout or simpler command.',
|
|
1177
|
-
[ToolErrorType.INVALID_ARGS]: 'Check the tool documentation for correct parameter usage.',
|
|
1178
|
-
[ToolErrorType.EXECUTION_FAILED]: 'Review the command syntax and try again.',
|
|
1179
|
-
[ToolErrorType.USER_CANCELLED]: 'Operation cancelled. You can retry if needed.',
|
|
1180
|
-
};
|
|
1181
|
-
return {
|
|
1182
|
-
success: false,
|
|
1183
|
-
error: message,
|
|
1184
|
-
suggestion: suggestion || suggestions[type],
|
|
1185
|
-
canRetry: type !== ToolErrorType.USER_CANCELLED && type !== ToolErrorType.INVALID_ARGS,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
/**
|
|
1189
|
-
* Enhanced permission request with risk visualization
|
|
1190
|
-
*/
|
|
1191
|
-
formatPermissionRequest(call, tool) {
|
|
1192
|
-
const riskColors = {
|
|
1193
|
-
low: chalk.green,
|
|
1194
|
-
medium: chalk.yellow,
|
|
1195
|
-
high: chalk.red,
|
|
1196
|
-
critical: chalk.bgRed.white,
|
|
1197
|
-
};
|
|
1198
|
-
const riskIcons = {
|
|
1199
|
-
low: '🟢',
|
|
1200
|
-
medium: '🟡',
|
|
1201
|
-
high: '🔴',
|
|
1202
|
-
critical: '⛔',
|
|
1203
|
-
};
|
|
1204
|
-
const riskColor = riskColors[tool.riskLevel];
|
|
1205
|
-
const riskIcon = riskIcons[tool.riskLevel];
|
|
1206
|
-
let msg = `\n${riskIcon} ${riskColor(`${tool.riskLevel.toUpperCase()} RISK`)} - AI wants to use ${chalk.cyan.bold(call.tool)}\n`;
|
|
1207
|
-
msg += chalk.gray(CH.hLine.repeat(50)) + '\n';
|
|
1208
|
-
// Tool-specific details
|
|
1209
|
-
if (call.tool === 'bash') {
|
|
1210
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Command:')} ${chalk.yellow(call.args.command)}\n`;
|
|
1211
|
-
if (call.args.cwd) {
|
|
1212
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Directory:')} ${call.args.cwd}\n`;
|
|
1213
|
-
}
|
|
1214
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Timeout:')} ${call.args.timeout || '30'}s\n`;
|
|
1215
|
-
}
|
|
1216
|
-
else if (call.tool === 'write_file') {
|
|
1217
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('File:')} ${chalk.cyan(call.args.path)}\n`;
|
|
1218
|
-
const preview = call.args.content.substring(0, 100);
|
|
1219
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Content preview:')} ${chalk.gray(preview)}${call.args.content.length > 100 ? '...' : ''}\n`;
|
|
1220
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Size:')} ${call.args.content.length} bytes\n`;
|
|
1221
|
-
}
|
|
1222
|
-
else if (call.tool === 'edit_file') {
|
|
1223
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('File:')} ${chalk.cyan(call.args.path)}\n`;
|
|
1224
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Replace:')} ${chalk.red(call.args.old_text.substring(0, 50))}${call.args.old_text.length > 50 ? '...' : ''}\n`;
|
|
1225
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('With:')} ${chalk.green(call.args.new_text.substring(0, 50))}${call.args.new_text.length > 50 ? '...' : ''}\n`;
|
|
1226
|
-
}
|
|
1227
|
-
else if (call.tool === 'git') {
|
|
1228
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Command:')} git ${chalk.yellow(call.args.args)}\n`;
|
|
1229
|
-
}
|
|
1230
|
-
// Safety info
|
|
1231
|
-
msg += chalk.gray(CH.hLine.repeat(50)) + '\n';
|
|
1232
|
-
const canUndo = ['write_file', 'edit_file'].includes(call.tool);
|
|
1233
|
-
msg += `${chalk.gray(CH.teeR + CH.hLine)} ${chalk.white('Undo:')} ${canUndo ? chalk.green(CH.success + ' Available (use /undo)') : chalk.gray(CH.error + ' Not available')}\n`;
|
|
1234
|
-
msg += `${chalk.gray(CH.bl + CH.hLine)} ${chalk.white('Category:')} ${tool.category}\n`;
|
|
1235
|
-
if (tool.dangerous) {
|
|
1236
|
-
msg += `\n${chalk.red.bold(CH.warnEmoji + ' WARNING: This is a potentially dangerous action!')}\n`;
|
|
1237
|
-
msg += chalk.red(' The AI is requesting to execute commands on your system.\n');
|
|
1238
|
-
}
|
|
1239
|
-
// Add batch approval hint
|
|
1240
|
-
msg += `\n${chalk.gray('Respond:')} ${chalk.white('[y]es')} ${chalk.gray('/')} ${chalk.white('[n]o')} ${chalk.gray('/')} ${chalk.cyan('[a]pprove all')} ${chalk.cyan(call.tool)} ${chalk.gray('for this turn')}\n`;
|
|
1241
|
-
return msg;
|
|
1242
|
-
}
|
|
1243
|
-
sleep(ms) {
|
|
1244
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1245
|
-
}
|
|
1246
|
-
// Tool implementations with enhanced error handling and undo support
|
|
1247
|
-
bridgeToolsEnabled() {
|
|
1248
|
-
const flag = String(process.env.VIGTHORIA_BRIDGE_TOOLS || '').trim().toLowerCase();
|
|
1249
|
-
return flag === '1' || flag === 'true' || flag === 'yes' || flag === 'on';
|
|
1250
|
-
}
|
|
1251
|
-
tryBridgeReadFile(args) {
|
|
1252
|
-
if (!this.bridgeToolsEnabled()) {
|
|
1253
|
-
return null;
|
|
1254
|
-
}
|
|
1255
|
-
const bridgeBase = String(process.env.VIGTHORIA_BRIDGE_HTTP
|
|
1256
|
-
|| process.env.VIGTHORIA_DESKTOP_BRIDGE_URL
|
|
1257
|
-
|| 'http://127.0.0.1:49160').replace(/\/$/, '');
|
|
1258
|
-
const targetPath = args.path || '';
|
|
1259
|
-
if (!targetPath) {
|
|
1260
|
-
return null;
|
|
1261
|
-
}
|
|
1262
|
-
try {
|
|
1263
|
-
const response = execSync(`curl -sS -m 15 -X POST "${bridgeBase}/tools/read_file" -H "Content-Type: application/json" -d ${JSON.stringify(JSON.stringify({
|
|
1264
|
-
path: targetPath,
|
|
1265
|
-
start_line: args.start_line ? Number(args.start_line) : undefined,
|
|
1266
|
-
end_line: args.end_line ? Number(args.end_line) : undefined,
|
|
1267
|
-
}))}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1268
|
-
const payload = JSON.parse(response);
|
|
1269
|
-
if (payload?.success === true) {
|
|
1270
|
-
return {
|
|
1271
|
-
success: true,
|
|
1272
|
-
output: String(payload.output || payload.content || ''),
|
|
1273
|
-
metadata: { source: 'desktop-bridge', path: targetPath },
|
|
1274
|
-
};
|
|
1275
|
-
}
|
|
1276
|
-
if (payload?.error) {
|
|
1277
|
-
this.logger.debug(`Desktop bridge read failed for ${targetPath}: ${payload.error}`);
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
catch (error) {
|
|
1281
|
-
this.logger.debug(`Desktop bridge read unavailable for ${targetPath}: ${this.formatExternalToolError('read_file', 'bridge delegation', error)}`);
|
|
1282
|
-
}
|
|
1283
|
-
return null;
|
|
1284
|
-
}
|
|
1285
|
-
/**
|
|
1286
|
-
* Read file with enhanced error handling and suggestions
|
|
1287
|
-
*/
|
|
1288
|
-
readFile(args) {
|
|
1289
|
-
const bridgeRead = this.tryBridgeReadFile(args);
|
|
1290
|
-
if (bridgeRead) {
|
|
1291
|
-
return bridgeRead;
|
|
1292
|
-
}
|
|
1293
|
-
const filePath = this.resolvePath(args.path);
|
|
1294
|
-
if (!fs.existsSync(filePath)) {
|
|
1295
|
-
// Try to find similar files
|
|
1296
|
-
const dir = path.dirname(filePath);
|
|
1297
|
-
const basename = path.basename(filePath);
|
|
1298
|
-
let suggestions = [];
|
|
1299
|
-
if (fs.existsSync(dir)) {
|
|
1300
|
-
const files = fs.readdirSync(dir);
|
|
1301
|
-
suggestions = files
|
|
1302
|
-
.filter(f => f.toLowerCase().includes(basename.toLowerCase().slice(0, 3)))
|
|
1303
|
-
.slice(0, 3);
|
|
1304
|
-
}
|
|
1305
|
-
return this.createErrorResult(ToolErrorType.FILE_NOT_FOUND, `File not found: ${args.path}`, suggestions.length > 0
|
|
1306
|
-
? `Did you mean one of these? ${suggestions.join(', ')}`
|
|
1307
|
-
: 'Use list_dir to see available files in the directory.');
|
|
1308
|
-
}
|
|
1309
|
-
try {
|
|
1310
|
-
const stats = fs.statSync(filePath);
|
|
1311
|
-
if (stats.size > 1024 * 1024) { // 1MB warning
|
|
1312
|
-
this.logger.warn(`Large file (${(stats.size / 1024 / 1024).toFixed(2)}MB) - consider using start_line/end_line`);
|
|
1313
|
-
}
|
|
1314
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1315
|
-
const lines = content.split('\n');
|
|
1316
|
-
// Clamp start_line: API is 1-based; the model sometimes emits 0 or
|
|
1317
|
-
// negative values. Silently clamp to valid range instead of failing.
|
|
1318
|
-
let rawStart = args.start_line ? parseInt(args.start_line) : 1;
|
|
1319
|
-
if (rawStart < 1)
|
|
1320
|
-
rawStart = 1;
|
|
1321
|
-
if (rawStart > lines.length)
|
|
1322
|
-
rawStart = lines.length;
|
|
1323
|
-
const startLine = rawStart - 1; // convert to 0-based
|
|
1324
|
-
let rawEnd = args.end_line ? parseInt(args.end_line) : lines.length;
|
|
1325
|
-
if (rawEnd < rawStart)
|
|
1326
|
-
rawEnd = rawStart;
|
|
1327
|
-
if (rawEnd > lines.length)
|
|
1328
|
-
rawEnd = lines.length;
|
|
1329
|
-
const endLine = rawEnd;
|
|
1330
|
-
const selectedLines = lines.slice(startLine, Math.min(endLine, lines.length));
|
|
1331
|
-
return {
|
|
1332
|
-
success: true,
|
|
1333
|
-
output: selectedLines.join('\n'),
|
|
1334
|
-
metadata: {
|
|
1335
|
-
totalLines: lines.length,
|
|
1336
|
-
linesReturned: selectedLines.length,
|
|
1337
|
-
filePath: args.path,
|
|
1338
|
-
},
|
|
1339
|
-
};
|
|
1340
|
-
}
|
|
1341
|
-
catch (error) {
|
|
1342
|
-
if (error.code === 'EACCES') {
|
|
1343
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `Permission denied: ${args.path}`);
|
|
1344
|
-
}
|
|
1345
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, error.message);
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
/**
|
|
1349
|
-
* Write file with undo support
|
|
1350
|
-
*/
|
|
1351
|
-
writeFile(args) {
|
|
1352
|
-
const filePath = this.resolvePath(args.path);
|
|
1353
|
-
const dir = path.dirname(filePath);
|
|
1354
|
-
// Save original content for undo if file exists
|
|
1355
|
-
let originalContent = null;
|
|
1356
|
-
if (fs.existsSync(filePath)) {
|
|
1357
|
-
originalContent = fs.readFileSync(filePath, 'utf-8');
|
|
1358
|
-
}
|
|
1359
|
-
try {
|
|
1360
|
-
// Create directory if needed
|
|
1361
|
-
if (!fs.existsSync(dir)) {
|
|
1362
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1363
|
-
}
|
|
1364
|
-
fs.writeFileSync(filePath, args.content, 'utf-8');
|
|
1365
|
-
const validationErrors = this.validateWrittenFile(filePath, args.content);
|
|
1366
|
-
if (validationErrors.length > 0) {
|
|
1367
|
-
if (originalContent === null) {
|
|
1368
|
-
fs.unlinkSync(filePath);
|
|
1369
|
-
}
|
|
1370
|
-
else {
|
|
1371
|
-
fs.writeFileSync(filePath, originalContent, 'utf-8');
|
|
1372
|
-
}
|
|
1373
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Validation failed after writing ${args.path}`, validationErrors.join(' '));
|
|
1374
|
-
}
|
|
1375
|
-
// Store undo operation
|
|
1376
|
-
const undoOp = {
|
|
1377
|
-
id: `undo-${Date.now()}`,
|
|
1378
|
-
tool: 'write_file',
|
|
1379
|
-
timestamp: Date.now(),
|
|
1380
|
-
filePath: filePath,
|
|
1381
|
-
originalContent: originalContent,
|
|
1382
|
-
description: originalContent
|
|
1383
|
-
? `Restore ${args.path} to previous version`
|
|
1384
|
-
: `Delete ${args.path} (was created)`,
|
|
1385
|
-
};
|
|
1386
|
-
this.undoStack.push(undoOp);
|
|
1387
|
-
// Limit undo stack size
|
|
1388
|
-
if (this.undoStack.length > 50) {
|
|
1389
|
-
this.undoStack.shift();
|
|
1390
|
-
}
|
|
1391
|
-
const isNew = originalContent === null;
|
|
1392
|
-
return {
|
|
1393
|
-
success: true,
|
|
1394
|
-
output: `File ${isNew ? 'created' : 'updated'}: ${args.path}`,
|
|
1395
|
-
metadata: {
|
|
1396
|
-
bytes: args.content.length,
|
|
1397
|
-
isNew,
|
|
1398
|
-
canUndo: true,
|
|
1399
|
-
},
|
|
1400
|
-
};
|
|
1401
|
-
}
|
|
1402
|
-
catch (error) {
|
|
1403
|
-
if (error.code === 'EACCES') {
|
|
1404
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `Permission denied writing to: ${args.path}`);
|
|
1405
|
-
}
|
|
1406
|
-
if (error.code === 'ENOSPC') {
|
|
1407
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, 'No space left on device');
|
|
1408
|
-
}
|
|
1409
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, error.message);
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
/**
|
|
1413
|
-
* Edit file with undo support and helpful error messages
|
|
1414
|
-
*/
|
|
1415
|
-
editFile(args) {
|
|
1416
|
-
const filePath = this.resolvePath(args.path);
|
|
1417
|
-
if (!fs.existsSync(filePath)) {
|
|
1418
|
-
return this.createErrorResult(ToolErrorType.FILE_NOT_FOUND, `File not found: ${args.path}`, 'Use write_file to create a new file, or check the path.');
|
|
1419
|
-
}
|
|
1420
|
-
try {
|
|
1421
|
-
let content = fs.readFileSync(filePath, 'utf-8');
|
|
1422
|
-
const originalContent = content;
|
|
1423
|
-
if (!content.includes(args.old_text)) {
|
|
1424
|
-
// Try to help identify the issue
|
|
1425
|
-
const lines = content.split('\n');
|
|
1426
|
-
const searchTerms = args.old_text.split('\n')[0].trim().slice(0, 30);
|
|
1427
|
-
const possibleMatches = lines
|
|
1428
|
-
.map((line, i) => ({ line: i + 1, content: line }))
|
|
1429
|
-
.filter(l => l.content.includes(searchTerms.slice(0, 10)))
|
|
1430
|
-
.slice(0, 3);
|
|
1431
|
-
let suggestion = 'The exact text was not found. ';
|
|
1432
|
-
if (possibleMatches.length > 0) {
|
|
1433
|
-
suggestion += `Similar content found at lines: ${possibleMatches.map(m => m.line).join(', ')}. `;
|
|
1434
|
-
}
|
|
1435
|
-
suggestion += 'Ensure whitespace and indentation match exactly.';
|
|
1436
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, 'Old text not found in file', suggestion);
|
|
1437
|
-
}
|
|
1438
|
-
// Check for multiple matches
|
|
1439
|
-
const matchCount = (content.match(new RegExp(args.old_text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
|
1440
|
-
if (matchCount > 1) {
|
|
1441
|
-
this.logger.warn(`Found ${matchCount} matches - only replacing first occurrence`);
|
|
1442
|
-
}
|
|
1443
|
-
content = content.replace(args.old_text, args.new_text);
|
|
1444
|
-
fs.writeFileSync(filePath, content, 'utf-8');
|
|
1445
|
-
const validationErrors = this.validateWrittenFile(filePath, content);
|
|
1446
|
-
if (validationErrors.length > 0) {
|
|
1447
|
-
fs.writeFileSync(filePath, originalContent, 'utf-8');
|
|
1448
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Validation failed after editing ${args.path}`, validationErrors.join(' '));
|
|
1449
|
-
}
|
|
1450
|
-
// Store undo operation
|
|
1451
|
-
const undoOp = {
|
|
1452
|
-
id: `undo-${Date.now()}`,
|
|
1453
|
-
tool: 'edit_file',
|
|
1454
|
-
timestamp: Date.now(),
|
|
1455
|
-
filePath: filePath,
|
|
1456
|
-
originalContent: originalContent,
|
|
1457
|
-
description: `Restore ${args.path} (reverts edit)`,
|
|
1458
|
-
};
|
|
1459
|
-
this.undoStack.push(undoOp);
|
|
1460
|
-
// Limit undo stack size
|
|
1461
|
-
if (this.undoStack.length > 50) {
|
|
1462
|
-
this.undoStack.shift();
|
|
1463
|
-
}
|
|
1464
|
-
// Show syntax-highlighted diff
|
|
1465
|
-
this.logger.unifiedDiff(args.path, args.old_text, args.new_text);
|
|
1466
|
-
return {
|
|
1467
|
-
success: true,
|
|
1468
|
-
output: `File edited: ${args.path}`,
|
|
1469
|
-
metadata: {
|
|
1470
|
-
matchesFound: matchCount,
|
|
1471
|
-
canUndo: true,
|
|
1472
|
-
},
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
catch (error) {
|
|
1476
|
-
if (error.code === 'EACCES') {
|
|
1477
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `Permission denied: ${args.path}`);
|
|
1478
|
-
}
|
|
1479
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, error.message);
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
validateWrittenFile(filePath, content) {
|
|
1483
|
-
const errors = [];
|
|
1484
|
-
const extension = path.extname(filePath).toLowerCase();
|
|
1485
|
-
const placeholderPatterns = [
|
|
1486
|
-
/\.\.\.\s*(rest|more)/i,
|
|
1487
|
-
/implementation goes here/i,
|
|
1488
|
-
/replace (?:this|the) placeholder/i,
|
|
1489
|
-
/placeholder (?:text|content|copy|image)/i,
|
|
1490
|
-
/todo:/i,
|
|
1491
|
-
/all the .* from earlier/i,
|
|
1492
|
-
];
|
|
1493
|
-
if (placeholderPatterns.some((pattern) => pattern.test(content))) {
|
|
1494
|
-
errors.push('The written file still contains placeholder or truncated content.');
|
|
1495
|
-
}
|
|
1496
|
-
if (extension === '.json') {
|
|
1497
|
-
try {
|
|
1498
|
-
JSON.parse(content);
|
|
1499
|
-
}
|
|
1500
|
-
catch (error) {
|
|
1501
|
-
errors.push(`JSON is invalid: ${error.message}`);
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
if (extension === '.js' || extension === '.mjs' || extension === '.cjs') {
|
|
1505
|
-
const jsError = this.validateJavaScriptSyntax(content);
|
|
1506
|
-
if (jsError) {
|
|
1507
|
-
errors.push(jsError);
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
if (extension === '.html' || extension === '.htm') {
|
|
1511
|
-
errors.push(...this.validateHtmlContent(content));
|
|
1512
|
-
}
|
|
1513
|
-
return errors;
|
|
1514
|
-
}
|
|
1515
|
-
validateJavaScriptSyntax(source) {
|
|
1516
|
-
const tempFile = path.join(this.cwd, `.vigthoria-temp-${Date.now()}.js`);
|
|
1517
|
-
try {
|
|
1518
|
-
try {
|
|
1519
|
-
fs.writeFileSync(tempFile, source, 'utf-8');
|
|
1520
|
-
}
|
|
1521
|
-
catch (writeError) {
|
|
1522
|
-
return this.formatExternalToolError('syntax_check', `write temporary file ${tempFile}`, writeError);
|
|
1523
|
-
}
|
|
1524
|
-
try {
|
|
1525
|
-
this.runExternalCommand('syntax_check', `node --check ${tempFile}`, `node --check "${tempFile}"`, { stdio: 'pipe' });
|
|
1526
|
-
return null;
|
|
1527
|
-
}
|
|
1528
|
-
catch (error) {
|
|
1529
|
-
const stderr = error.stderr?.toString()?.trim();
|
|
1530
|
-
return stderr || error.message || this.formatExternalToolError('syntax_check', `node --check ${tempFile}`, error);
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
finally {
|
|
1534
|
-
this.cleanupAfterToolError('syntax_check', `remove temporary file ${tempFile}`, () => {
|
|
1535
|
-
if (fs.existsSync(tempFile)) {
|
|
1536
|
-
fs.unlinkSync(tempFile);
|
|
1537
|
-
}
|
|
1538
|
-
});
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
validateHtmlContent(content) {
|
|
1542
|
-
const errors = [];
|
|
1543
|
-
const requiredClosers = ['</html>', '</body>'];
|
|
1544
|
-
for (const closer of requiredClosers) {
|
|
1545
|
-
if (!content.toLowerCase().includes(closer)) {
|
|
1546
|
-
errors.push(`Missing required HTML closer: ${closer}`);
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
const inlineScripts = [...content.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)];
|
|
1550
|
-
inlineScripts.forEach((match, index) => {
|
|
1551
|
-
const scriptContent = match[1].trim();
|
|
1552
|
-
if (!scriptContent) {
|
|
1553
|
-
return;
|
|
1554
|
-
}
|
|
1555
|
-
const scriptError = this.validateJavaScriptSyntax(scriptContent);
|
|
1556
|
-
if (scriptError) {
|
|
1557
|
-
errors.push(`Inline script ${index + 1} has invalid JavaScript: ${scriptError}`);
|
|
1558
|
-
}
|
|
1559
|
-
});
|
|
1560
|
-
const selfClosingTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
1561
|
-
const stack = [];
|
|
1562
|
-
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g;
|
|
1563
|
-
let match;
|
|
1564
|
-
while ((match = tagRegex.exec(content)) !== null) {
|
|
1565
|
-
const fullTag = match[0];
|
|
1566
|
-
const tagName = match[1].toLowerCase();
|
|
1567
|
-
if (selfClosingTags.has(tagName) || fullTag.endsWith('/>')) {
|
|
1568
|
-
continue;
|
|
1569
|
-
}
|
|
1570
|
-
if (fullTag.startsWith('</')) {
|
|
1571
|
-
const last = stack.pop();
|
|
1572
|
-
if (last !== tagName) {
|
|
1573
|
-
errors.push(`Unbalanced HTML tags near </${tagName}>.`);
|
|
1574
|
-
break;
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
else {
|
|
1578
|
-
stack.push(tagName);
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
if (stack.length > 0) {
|
|
1582
|
-
errors.push(`Unclosed HTML tags detected: ${stack.slice(-5).join(', ')}`);
|
|
1583
|
-
}
|
|
1584
|
-
return errors;
|
|
1585
|
-
}
|
|
1586
|
-
/**
|
|
1587
|
-
* Execute bash command with enhanced error handling
|
|
1588
|
-
* SECURITY: Commands are sandboxed to workspace directory
|
|
1589
|
-
* WINDOWS: Detects Unix-specific commands and suggests alternatives
|
|
1590
|
-
*/
|
|
1591
|
-
bash(args) {
|
|
1592
|
-
const cwd = args.cwd ? this.resolvePath(args.cwd) : this.cwd;
|
|
1593
|
-
const timeout = args.timeout ? parseInt(args.timeout) * 1000 : 30000;
|
|
1594
|
-
const os = require('os');
|
|
1595
|
-
const platform = os.platform();
|
|
1596
|
-
// Unix-specific commands that don't exist on Windows
|
|
1597
|
-
const unixOnlyCommands = [
|
|
1598
|
-
'head', 'tail', 'grep', 'awk', 'sed', 'wc', 'cut', 'sort', 'uniq',
|
|
1599
|
-
'xargs', 'find', 'chmod', 'chown', 'ln', 'df', 'du', 'ps', 'kill',
|
|
1600
|
-
'top', 'htop', 'which', 'whereis', 'man', 'less', 'more', 'cat',
|
|
1601
|
-
];
|
|
1602
|
-
// Check if command uses Unix-only tools on Windows
|
|
1603
|
-
if (platform === 'win32') {
|
|
1604
|
-
const cmdParts = args.command.split(/[|&;]/);
|
|
1605
|
-
for (const part of cmdParts) {
|
|
1606
|
-
const firstWord = part.trim().split(/\s+/)[0].toLowerCase();
|
|
1607
|
-
if (unixOnlyCommands.includes(firstWord)) {
|
|
1608
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Command '${firstWord}' is not available on Windows`, `Use a local PowerShell equivalent instead: dir (ls), type (cat), findstr (grep), select-string (grep), or rerun with a Windows-safe command.`);
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
// SECURITY: Block dangerous commands that could access outside workspace
|
|
1613
|
-
// HARDENED: Protects Vigthoria ecosystem, server config, and system files
|
|
1614
|
-
const blockedPatterns = [
|
|
1615
|
-
// === System file access ===
|
|
1616
|
-
/\bcat\s+\/etc\//i, // Reading system files
|
|
1617
|
-
/\bcat\s+\/var\/(?!log)/i, // Reading /var/ except logs
|
|
1618
|
-
/\bcat\s+\/root\//i, // Reading root home
|
|
1619
|
-
/\bcat\s+\/home\/[^\/]+\/\.[^\/]/i, // Reading hidden files in home dirs
|
|
1620
|
-
/\bcat\s+~\/\./i, // Reading hidden files via ~
|
|
1621
|
-
/\bcd\s+(\/etc|\/var\/www|\/root|\/home)/i, // CD to sensitive dirs
|
|
1622
|
-
// === Destructive operations ===
|
|
1623
|
-
/\brm\s+-rf?\s+\//i, // Dangerous rm commands
|
|
1624
|
-
/\brm\s+-rf?\s+\.\.\//i, // rm going up directories
|
|
1625
|
-
/\brm\s+-rf?\s+~\//i, // rm in home directory
|
|
1626
|
-
/\b(curl|wget).*\|\s*(bash|sh)\b/i, // Downloading and executing scripts
|
|
1627
|
-
/\bsudo\b/i, // Sudo commands
|
|
1628
|
-
/\bsu\s+-/i, // Su to root
|
|
1629
|
-
/\bchmod\s+[0-7]*777/i, // World-writable permissions
|
|
1630
|
-
/\/(var\/www|opt)\/[a-z]+-?(database|models|coder|mcp|operator|voice|music)/i, // Vigthoria ecosystem
|
|
1631
|
-
/vigthoria-(models|database|mcp|operator|voice|music)/i, // Vigthoria project names
|
|
1632
|
-
// === Vigthoria Server Protection (CRITICAL) ===
|
|
1633
|
-
/\/var\/www\/(?!$)/i, // Block ANY access to /var/www/* (server web root)
|
|
1634
|
-
/\/etc\/(caddy|nginx|apache|systemd|pm2)/i, // Server config files
|
|
1635
|
-
/\/etc\/(passwd|shadow|group|sudoers)/i, // System auth files
|
|
1636
|
-
/\b(systemctl|service)\s+(start|stop|restart|enable|disable|reload)/i, // Service control
|
|
1637
|
-
/\bpm2\s+(start|stop|restart|delete|kill|flush)/i, // PM2 process management
|
|
1638
|
-
/\bjournalctl\b/i, // System logs access
|
|
1639
|
-
/\bnpm\s+publish\b/i, // Block npm publish from agent
|
|
1640
|
-
/\bdocker\s+(rm|kill|stop|exec|run)/i, // Docker container manipulation
|
|
1641
|
-
/\biptables\b/i, // Firewall rules
|
|
1642
|
-
/\bufw\s+(allow|deny|delete|disable)/i, // UFW firewall
|
|
1643
|
-
/\bssh\s+.*vigthoria/i, // SSH to Vigthoria servers
|
|
1644
|
-
/\bssh\s+.*78\.46\.154/i, // SSH to known Vigthoria IP
|
|
1645
|
-
/\.env\b/i, // Environment files (may contain secrets)
|
|
1646
|
-
/\bprivate[_-]?key|secret[_-]?key|api[_-]?key|auth[_-]?token/i, // Sensitive variable patterns in commands
|
|
1647
|
-
/\bcrontab\b/i, // Cron manipulation
|
|
1648
|
-
/\bmount\s/i, // Mount filesystems
|
|
1649
|
-
/\bmkfs\b/i, // Format filesystems
|
|
1650
|
-
/>\s*\/dev\/sd/i, // Writing to disk devices
|
|
1651
|
-
/\bdd\s+.*of=/i, // dd write operations
|
|
1652
|
-
/\bnode\s+-e\b/i, // Node eval (sandbox bypass)
|
|
1653
|
-
/\bnode\s+--eval\b/i, // Node eval long form
|
|
1654
|
-
/\bpython3?\s+-c\b/i, // Python exec (sandbox bypass)
|
|
1655
|
-
/\bruby\s+-e\b/i, // Ruby eval (sandbox bypass)
|
|
1656
|
-
/\bperl\s+-e\b/i, // Perl eval (sandbox bypass)
|
|
1657
|
-
];
|
|
1658
|
-
for (const pattern of blockedPatterns) {
|
|
1659
|
-
if (pattern.test(args.command)) {
|
|
1660
|
-
this.logger.warn(`Security: Blocked potentially dangerous command: ${args.command.substring(0, 50)}...`);
|
|
1661
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, 'This command is blocked for security reasons', 'Commands must operate within your current project workspace.');
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
// Validate working directory
|
|
1665
|
-
if (!fs.existsSync(cwd)) {
|
|
1666
|
-
return this.createErrorResult(ToolErrorType.FILE_NOT_FOUND, `Working directory not found: ${cwd}`, 'Check that the directory exists or use an absolute path.');
|
|
1667
|
-
}
|
|
1668
|
-
try {
|
|
1669
|
-
const startTime = Date.now();
|
|
1670
|
-
// Build exec options based on platform
|
|
1671
|
-
const execOptions = {
|
|
1672
|
-
cwd,
|
|
1673
|
-
timeout,
|
|
1674
|
-
encoding: 'utf-8',
|
|
1675
|
-
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
1676
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1677
|
-
// Ensure consistent environment
|
|
1678
|
-
env: {
|
|
1679
|
-
...process.env,
|
|
1680
|
-
// Prevent locale issues
|
|
1681
|
-
LC_ALL: 'C',
|
|
1682
|
-
LANG: 'C',
|
|
1683
|
-
},
|
|
1684
|
-
};
|
|
1685
|
-
// Set shell based on platform for consistent behavior
|
|
1686
|
-
if (platform === 'win32') {
|
|
1687
|
-
execOptions.shell = true; // Use default cmd.exe on Windows
|
|
1688
|
-
}
|
|
1689
|
-
else {
|
|
1690
|
-
// Use /bin/sh on Unix-like systems (more portable than bash)
|
|
1691
|
-
execOptions.shell = '/bin/sh';
|
|
1692
|
-
}
|
|
1693
|
-
const output = this.runExternalCommand('bash', args.command, args.command, execOptions);
|
|
1694
|
-
const duration = Date.now() - startTime;
|
|
1695
|
-
return {
|
|
1696
|
-
success: true,
|
|
1697
|
-
output: output.trim(),
|
|
1698
|
-
metadata: {
|
|
1699
|
-
durationMs: duration,
|
|
1700
|
-
cwd,
|
|
1701
|
-
},
|
|
1702
|
-
};
|
|
1703
|
-
}
|
|
1704
|
-
catch (error) {
|
|
1705
|
-
// Command failed but may have output
|
|
1706
|
-
const output = error.stdout || '';
|
|
1707
|
-
const stderr = error.stderr || '';
|
|
1708
|
-
// Provide helpful error messages for common cases
|
|
1709
|
-
if (error.killed) {
|
|
1710
|
-
return this.createErrorResult(ToolErrorType.TIMEOUT, `Command timed out after ${timeout / 1000}s`, 'Try increasing the timeout or breaking the command into smaller parts.');
|
|
1711
|
-
}
|
|
1712
|
-
if (stderr.includes('command not found') || stderr.includes('not recognized')) {
|
|
1713
|
-
const cmd = args.command.split(' ')[0];
|
|
1714
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Command not found: ${cmd}`, 'Check that the command is installed and in PATH.');
|
|
1715
|
-
}
|
|
1716
|
-
if (stderr.includes('Permission denied')) {
|
|
1717
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, 'Permission denied executing command', 'Try using sudo or check file/directory permissions.');
|
|
1718
|
-
}
|
|
1719
|
-
return {
|
|
1720
|
-
success: false,
|
|
1721
|
-
output,
|
|
1722
|
-
error: stderr || error.message,
|
|
1723
|
-
suggestion: 'Check command syntax and try again.',
|
|
1724
|
-
canRetry: !error.killed,
|
|
1725
|
-
};
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
grep(args) {
|
|
1729
|
-
const searchPath = args.path ? this.resolvePath(args.path) : this.cwd;
|
|
1730
|
-
const osModule = require('os');
|
|
1731
|
-
const platform = osModule.platform();
|
|
1732
|
-
// If forced fallback, go directly to Node-native
|
|
1733
|
-
if (args._fallback === 'node-native') {
|
|
1734
|
-
return this.grepNodeNative(args, searchPath);
|
|
1735
|
-
}
|
|
1736
|
-
// Try external search tools first (rg > grep/Select-String), fall back to Node-native
|
|
1737
|
-
if (platform === 'win32') {
|
|
1738
|
-
return this.grepWindows(args, searchPath);
|
|
1739
|
-
}
|
|
1740
|
-
return this.grepUnix(args, searchPath, platform);
|
|
1741
|
-
}
|
|
1742
|
-
/**
|
|
1743
|
-
* Windows grep: rg > Select-String > Node-native
|
|
1744
|
-
*/
|
|
1745
|
-
grepWindows(args, searchPath) {
|
|
1746
|
-
// 1. Try ripgrep (rg) first — best cross-platform search tool
|
|
1747
|
-
try {
|
|
1748
|
-
execSync('rg --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
|
|
1749
|
-
return this.grepWithRg(args, searchPath);
|
|
1750
|
-
}
|
|
1751
|
-
catch (error) {
|
|
1752
|
-
this.logger.debug(this.formatExternalToolError('grep', 'checking ripgrep availability on Windows', error));
|
|
1753
|
-
}
|
|
1754
|
-
// 2. Try PowerShell Select-String
|
|
1755
|
-
try {
|
|
1756
|
-
return this.grepWithSelectString(args, searchPath);
|
|
1757
|
-
}
|
|
1758
|
-
catch (error) {
|
|
1759
|
-
this.logger.debug(this.formatExternalToolError('grep', 'running PowerShell Select-String fallback', error));
|
|
1760
|
-
}
|
|
1761
|
-
// 3. Fall back to Node-native recursive file scanner
|
|
1762
|
-
return this.grepNodeNative(args, searchPath);
|
|
1763
|
-
}
|
|
1764
|
-
/**
|
|
1765
|
-
* Unix grep: rg > system grep (BSD/GNU)
|
|
1766
|
-
*/
|
|
1767
|
-
grepUnix(args, searchPath, platform) {
|
|
1768
|
-
// Try ripgrep first if available
|
|
1769
|
-
try {
|
|
1770
|
-
this.runExternalCommand('grep', 'checking ripgrep availability on Unix', 'rg --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
|
|
1771
|
-
return this.grepWithRg(args, searchPath);
|
|
1772
|
-
}
|
|
1773
|
-
catch (error) {
|
|
1774
|
-
this.logger.debug(this.formatExternalToolError('grep', 'checking ripgrep availability on Unix', error));
|
|
1775
|
-
// rg not available, fall through to system grep
|
|
1776
|
-
}
|
|
1777
|
-
const isMac = platform === 'darwin';
|
|
1778
|
-
let cmd;
|
|
1779
|
-
if (isMac) {
|
|
1780
|
-
cmd = args.include
|
|
1781
|
-
? `grep -rn --include="${args.include}" "${args.pattern}" "${searchPath}"`
|
|
1782
|
-
: `grep -rn "${args.pattern}" "${searchPath}"`;
|
|
1783
|
-
}
|
|
1784
|
-
else {
|
|
1785
|
-
cmd = args.include
|
|
1786
|
-
? `grep -rn --color=never --include="${args.include}" "${args.pattern}" "${searchPath}"`
|
|
1787
|
-
: `grep -rn --color=never "${args.pattern}" "${searchPath}"`;
|
|
1788
|
-
}
|
|
1789
|
-
try {
|
|
1790
|
-
const output = this.runExternalCommand('grep', 'system grep search', cmd, {
|
|
1791
|
-
cwd: this.cwd,
|
|
1792
|
-
encoding: 'utf-8',
|
|
1793
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
1794
|
-
timeout: 30000,
|
|
1795
|
-
env: { ...process.env, GREP_OPTIONS: '' },
|
|
1796
|
-
});
|
|
1797
|
-
return {
|
|
1798
|
-
success: true,
|
|
1799
|
-
output: output.trim(),
|
|
1800
|
-
metadata: { searchStatus: 'search_matches_found' },
|
|
1801
|
-
};
|
|
1802
|
-
}
|
|
1803
|
-
catch (error) {
|
|
1804
|
-
// Distinguish real "no matches" (exit 1 + no stderr) from command failure
|
|
1805
|
-
const stderr = (error.stderr || '').toString();
|
|
1806
|
-
if (error.status === 1 && !stderr.trim()) {
|
|
1807
|
-
return {
|
|
1808
|
-
success: true,
|
|
1809
|
-
output: 'No matches found',
|
|
1810
|
-
metadata: { searchStatus: 'search_no_matches' },
|
|
1811
|
-
};
|
|
1812
|
-
}
|
|
1813
|
-
// Real failure: command not found, permission denied, etc.
|
|
1814
|
-
return {
|
|
1815
|
-
success: false,
|
|
1816
|
-
error: stderr || error.message,
|
|
1817
|
-
suggestion: 'The grep command failed. Install ripgrep (rg) for best cross-platform search.',
|
|
1818
|
-
canRetry: true,
|
|
1819
|
-
metadata: { searchStatus: 'search_failed' },
|
|
1820
|
-
};
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
/**
|
|
1824
|
-
* Search using ripgrep (rg) — works on all platforms
|
|
1825
|
-
*/
|
|
1826
|
-
grepWithRg(args, searchPath) {
|
|
1827
|
-
let cmd = `rg -n --no-heading`;
|
|
1828
|
-
if (args.include) {
|
|
1829
|
-
cmd += ` -g "${args.include}"`;
|
|
1830
|
-
}
|
|
1831
|
-
cmd += ` "${args.pattern}" "${searchPath}"`;
|
|
1832
|
-
try {
|
|
1833
|
-
const output = this.runExternalCommand('grep', 'ripgrep search', cmd, {
|
|
1834
|
-
cwd: this.cwd,
|
|
1835
|
-
encoding: 'utf-8',
|
|
1836
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
1837
|
-
timeout: 30000,
|
|
1838
|
-
});
|
|
1839
|
-
return {
|
|
1840
|
-
success: true,
|
|
1841
|
-
output: output.trim(),
|
|
1842
|
-
metadata: { searchStatus: 'search_matches_found', backend: 'ripgrep' },
|
|
1843
|
-
};
|
|
1844
|
-
}
|
|
1845
|
-
catch (error) {
|
|
1846
|
-
if (error.status === 1 && !(error.stderr || '').toString().trim()) {
|
|
1847
|
-
return {
|
|
1848
|
-
success: true,
|
|
1849
|
-
output: 'No matches found',
|
|
1850
|
-
metadata: { searchStatus: 'search_no_matches', backend: 'ripgrep' },
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
throw error; // Let caller try next backend
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
/**
|
|
1857
|
-
* Search using PowerShell Select-String (Windows)
|
|
1858
|
-
*/
|
|
1859
|
-
grepWithSelectString(args, searchPath) {
|
|
1860
|
-
// Normalize path for PowerShell
|
|
1861
|
-
const psPath = searchPath.replace(/\//g, '\\');
|
|
1862
|
-
if (!POWERSHELL_SAFE_PATH_PATTERN.test(psPath)) {
|
|
1863
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Unsafe search path for PowerShell backend', 'Use a normal workspace path containing only letters, numbers, separators, dot, dash, and spaces.');
|
|
1864
|
-
}
|
|
1865
|
-
const includeArg = args.include ? String(args.include) : '*';
|
|
1866
|
-
if (!POWERSHELL_SAFE_INCLUDE_PATTERN.test(includeArg)) {
|
|
1867
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Unsafe include filter for PowerShell backend', 'Use simple wildcard filters like *.ts or *.py.');
|
|
1868
|
-
}
|
|
1869
|
-
const includeFilter = `-Include "${includeArg}"`;
|
|
1870
|
-
const escapedPattern = args.pattern.replace(/'/g, "''");
|
|
1871
|
-
const cmd = `powershell -NoProfile -Command "Get-ChildItem -Path '${psPath}' -Recurse -File ${includeFilter} | Select-String -Pattern '${escapedPattern}' | ForEach-Object { $_.Path + ':' + $_.LineNumber + ':' + $_.Line }"`;
|
|
1872
|
-
try {
|
|
1873
|
-
const output = execSync(cmd, {
|
|
1874
|
-
cwd: this.cwd,
|
|
1875
|
-
encoding: 'utf-8',
|
|
1876
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
1877
|
-
timeout: 60000,
|
|
1878
|
-
});
|
|
1879
|
-
const trimmed = output.trim();
|
|
1880
|
-
if (!trimmed) {
|
|
1881
|
-
return {
|
|
1882
|
-
success: true,
|
|
1883
|
-
output: 'No matches found',
|
|
1884
|
-
metadata: { searchStatus: 'search_no_matches', backend: 'select-string' },
|
|
1885
|
-
};
|
|
1886
|
-
}
|
|
1887
|
-
return {
|
|
1888
|
-
success: true,
|
|
1889
|
-
output: trimmed,
|
|
1890
|
-
metadata: { searchStatus: 'search_matches_found', backend: 'select-string' },
|
|
1891
|
-
};
|
|
1892
|
-
}
|
|
1893
|
-
catch (error) {
|
|
1894
|
-
const stderr = (error.stderr || '').toString();
|
|
1895
|
-
// Select-String returns exit code 1 for no matches when used in pipeline
|
|
1896
|
-
if (error.status === 1 && !stderr.trim()) {
|
|
1897
|
-
return {
|
|
1898
|
-
success: true,
|
|
1899
|
-
output: 'No matches found',
|
|
1900
|
-
metadata: { searchStatus: 'search_no_matches', backend: 'select-string' },
|
|
1901
|
-
};
|
|
1902
|
-
}
|
|
1903
|
-
throw error; // Let caller try next backend
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
/**
|
|
1907
|
-
* Pure Node.js recursive file search — reliable on all platforms, no external deps
|
|
1908
|
-
*/
|
|
1909
|
-
grepNodeNative(args, searchPath) {
|
|
1910
|
-
const { minimatch } = (() => {
|
|
1911
|
-
try {
|
|
1912
|
-
return require('minimatch');
|
|
1913
|
-
}
|
|
1914
|
-
catch {
|
|
1915
|
-
return { minimatch: null };
|
|
1916
|
-
}
|
|
1917
|
-
})();
|
|
1918
|
-
const results = [];
|
|
1919
|
-
let regex;
|
|
1920
|
-
try {
|
|
1921
|
-
regex = new RegExp(args.pattern, 'i');
|
|
1922
|
-
}
|
|
1923
|
-
catch {
|
|
1924
|
-
// If pattern is not valid regex, treat as literal
|
|
1925
|
-
regex = new RegExp(args.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
1926
|
-
}
|
|
1927
|
-
const includeGlob = args.include || null;
|
|
1928
|
-
const maxResults = 500;
|
|
1929
|
-
const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', '.next', 'vendor', '.venv']);
|
|
1930
|
-
const walk = (dir) => {
|
|
1931
|
-
if (results.length >= maxResults)
|
|
1932
|
-
return;
|
|
1933
|
-
let entries;
|
|
1934
|
-
try {
|
|
1935
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1936
|
-
}
|
|
1937
|
-
catch {
|
|
1938
|
-
return; // Permission denied or inaccessible
|
|
1939
|
-
}
|
|
1940
|
-
for (const entry of entries) {
|
|
1941
|
-
if (results.length >= maxResults)
|
|
1942
|
-
break;
|
|
1943
|
-
const fullPath = path.join(dir, entry.name);
|
|
1944
|
-
if (entry.isDirectory()) {
|
|
1945
|
-
if (!skipDirs.has(entry.name)) {
|
|
1946
|
-
walk(fullPath);
|
|
1947
|
-
}
|
|
1948
|
-
continue;
|
|
1949
|
-
}
|
|
1950
|
-
if (!entry.isFile())
|
|
1951
|
-
continue;
|
|
1952
|
-
// Check include pattern
|
|
1953
|
-
if (includeGlob) {
|
|
1954
|
-
const matches = minimatch
|
|
1955
|
-
? minimatch(entry.name, includeGlob)
|
|
1956
|
-
: entry.name.endsWith(includeGlob.replace('*', ''));
|
|
1957
|
-
if (!matches)
|
|
1958
|
-
continue;
|
|
1959
|
-
}
|
|
1960
|
-
// Skip binary files by extension
|
|
1961
|
-
const ext = path.extname(entry.name).toLowerCase();
|
|
1962
|
-
const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.zip', '.tar', '.gz', '.exe', '.dll', '.so', '.dylib', '.pdf', '.mp3', '.mp4', '.wav', '.avi', '.mov']);
|
|
1963
|
-
if (binaryExts.has(ext))
|
|
1964
|
-
continue;
|
|
1965
|
-
try {
|
|
1966
|
-
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
1967
|
-
const lines = content.split('\n');
|
|
1968
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1969
|
-
if (regex.test(lines[i])) {
|
|
1970
|
-
const relativePath = path.relative(this.cwd, fullPath).replace(/\\/g, '/');
|
|
1971
|
-
results.push(`${relativePath}:${i + 1}:${lines[i]}`);
|
|
1972
|
-
if (results.length >= maxResults)
|
|
1973
|
-
break;
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
catch {
|
|
1978
|
-
// Skip files that can't be read (binary, encoding issues, etc.)
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
};
|
|
1982
|
-
try {
|
|
1983
|
-
walk(searchPath);
|
|
1984
|
-
}
|
|
1985
|
-
catch (error) {
|
|
1986
|
-
return {
|
|
1987
|
-
success: false,
|
|
1988
|
-
error: `File search failed: ${error.message}`,
|
|
1989
|
-
metadata: { searchStatus: 'search_failed', backend: 'node-native' },
|
|
1990
|
-
};
|
|
1991
|
-
}
|
|
1992
|
-
if (results.length === 0) {
|
|
1993
|
-
return {
|
|
1994
|
-
success: true,
|
|
1995
|
-
output: 'No matches found',
|
|
1996
|
-
metadata: { searchStatus: 'search_no_matches', backend: 'node-native' },
|
|
1997
|
-
};
|
|
1998
|
-
}
|
|
1999
|
-
const truncationNote = results.length >= maxResults ? `\n...[truncated at ${maxResults} matches]` : '';
|
|
2000
|
-
return {
|
|
2001
|
-
success: true,
|
|
2002
|
-
output: results.join('\n') + truncationNote,
|
|
2003
|
-
metadata: { searchStatus: 'search_matches_found', backend: 'node-native' },
|
|
2004
|
-
};
|
|
2005
|
-
}
|
|
2006
|
-
listDir(args) {
|
|
2007
|
-
const dirPath = this.resolvePath(args.path);
|
|
2008
|
-
if (!fs.existsSync(dirPath)) {
|
|
2009
|
-
return { success: false, error: `Directory not found: ${args.path}` };
|
|
2010
|
-
}
|
|
2011
|
-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
2012
|
-
const output = entries.map(e => {
|
|
2013
|
-
const suffix = e.isDirectory() ? '/' : '';
|
|
2014
|
-
return e.name + suffix;
|
|
2015
|
-
}).join('\n');
|
|
2016
|
-
return { success: true, output };
|
|
2017
|
-
}
|
|
2018
|
-
glob(args) {
|
|
2019
|
-
const { globSync } = require('glob');
|
|
2020
|
-
try {
|
|
2021
|
-
const files = globSync(args.pattern, { cwd: this.cwd });
|
|
2022
|
-
return { success: true, output: files.join('\n') };
|
|
2023
|
-
}
|
|
2024
|
-
catch (error) {
|
|
2025
|
-
return { success: false, error: error.message };
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
git(args) {
|
|
2029
|
-
try {
|
|
2030
|
-
const output = execSync(`git ${args.args}`, {
|
|
2031
|
-
cwd: this.cwd,
|
|
2032
|
-
encoding: 'utf-8',
|
|
2033
|
-
timeout: 60000,
|
|
2034
|
-
});
|
|
2035
|
-
return { success: true, output: output.trim() };
|
|
2036
|
-
}
|
|
2037
|
-
catch (error) {
|
|
2038
|
-
return { success: false, error: error.stderr || error.message };
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
/**
|
|
2042
|
-
* Vigthoria Repository management tool
|
|
2043
|
-
* Allows AI to push, pull, list, share, and manage projects in the Vigthoria Repository
|
|
2044
|
-
*/
|
|
2045
|
-
async repo(args) {
|
|
2046
|
-
const action = args.action?.toLowerCase();
|
|
2047
|
-
const project = args.project;
|
|
2048
|
-
const visibility = args.visibility || 'public';
|
|
2049
|
-
const targetPath = args.path || this.cwd;
|
|
2050
|
-
const description = args.description || '';
|
|
2051
|
-
try {
|
|
2052
|
-
// Push action uses direct API call to Community server (bypasses interactive CLI)
|
|
2053
|
-
if (action === 'push') {
|
|
2054
|
-
if (!project) {
|
|
2055
|
-
return {
|
|
2056
|
-
success: false,
|
|
2057
|
-
error: 'Project name is required for push',
|
|
2058
|
-
suggestion: 'Provide a project name, e.g., repo action=push project=my-project'
|
|
2059
|
-
};
|
|
2060
|
-
}
|
|
2061
|
-
// Load auth config
|
|
2062
|
-
const configPath = path.join(process.env.HOME || '/root', '.config', 'vigthoria-cli', 'config.json');
|
|
2063
|
-
if (!fs.existsSync(configPath)) {
|
|
2064
|
-
return { success: false, error: 'Not logged in. Run vigthoria login first.' };
|
|
2065
|
-
}
|
|
2066
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
2067
|
-
const authToken = config.authToken;
|
|
2068
|
-
const apiBase = config.apiUrl || 'https://coder.vigthoria.io';
|
|
2069
|
-
if (!authToken) {
|
|
2070
|
-
return { success: false, error: 'No auth token found. Run vigthoria login first.' };
|
|
2071
|
-
}
|
|
2072
|
-
// Build files array
|
|
2073
|
-
let filesArray = [];
|
|
2074
|
-
if (args.files) {
|
|
2075
|
-
// AI passed files directly
|
|
2076
|
-
let filesInput;
|
|
2077
|
-
if (typeof args.files === 'string') {
|
|
2078
|
-
try {
|
|
2079
|
-
filesInput = JSON.parse(args.files);
|
|
2080
|
-
}
|
|
2081
|
-
catch {
|
|
2082
|
-
filesInput = args.files;
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
else {
|
|
2086
|
-
filesInput = args.files;
|
|
2087
|
-
}
|
|
2088
|
-
if (Array.isArray(filesInput)) {
|
|
2089
|
-
// Already in [{path, content}] format
|
|
2090
|
-
filesArray = filesInput.map((f) => ({ path: f.path || f.name || 'file', content: String(f.content || '') }));
|
|
2091
|
-
}
|
|
2092
|
-
else if (typeof filesInput === 'object' && filesInput !== null) {
|
|
2093
|
-
// {filename: content} format - convert
|
|
2094
|
-
for (const [filePath, content] of Object.entries(filesInput)) {
|
|
2095
|
-
filesArray.push({ path: filePath, content: String(content) });
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
// If no files from AI, read from disk
|
|
2100
|
-
if (filesArray.length === 0 && fs.existsSync(targetPath)) {
|
|
2101
|
-
const readDir = (dir, base) => {
|
|
2102
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2103
|
-
for (const entry of entries) {
|
|
2104
|
-
const fullPath = path.join(dir, entry.name);
|
|
2105
|
-
const relPath = path.relative(base, fullPath);
|
|
2106
|
-
// Skip hidden dirs, node_modules, common non-essential dirs
|
|
2107
|
-
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__')
|
|
2108
|
-
continue;
|
|
2109
|
-
if (entry.isDirectory()) {
|
|
2110
|
-
readDir(fullPath, base);
|
|
2111
|
-
}
|
|
2112
|
-
else if (entry.isFile()) {
|
|
2113
|
-
try {
|
|
2114
|
-
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
2115
|
-
filesArray.push({ path: relPath, content });
|
|
2116
|
-
}
|
|
2117
|
-
catch { /* skip binary/unreadable files */ }
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
};
|
|
2121
|
-
const stat = fs.statSync(targetPath);
|
|
2122
|
-
if (stat.isDirectory()) {
|
|
2123
|
-
readDir(targetPath, targetPath);
|
|
2124
|
-
}
|
|
2125
|
-
else {
|
|
2126
|
-
filesArray.push({ path: path.basename(targetPath), content: fs.readFileSync(targetPath, 'utf-8') });
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
if (filesArray.length === 0) {
|
|
2130
|
-
return { success: false, error: 'No files to push. Provide files parameter or a valid path.' };
|
|
2131
|
-
}
|
|
2132
|
-
// POST to Community API via Coder proxy (fallback to direct Community server)
|
|
2133
|
-
const body = JSON.stringify({
|
|
2134
|
-
projectName: project,
|
|
2135
|
-
description: description || `${project} - pushed via Vigthoria AI`,
|
|
2136
|
-
visibility,
|
|
2137
|
-
files: filesArray,
|
|
2138
|
-
commitMessage: `Push from Vigthoria AI: ${filesArray.length} file(s)`
|
|
2139
|
-
});
|
|
2140
|
-
const headers = {
|
|
2141
|
-
'Authorization': `Bearer ${authToken}`,
|
|
2142
|
-
'Content-Type': 'application/json'
|
|
2143
|
-
};
|
|
2144
|
-
// Try proxy first; direct Community server is only reachable when running on the
|
|
2145
|
-
// Vigthoria backend itself (isServerRuntime), never expose this fallback to remote users.
|
|
2146
|
-
const pushUrls = [`${apiBase}/api/community-repo/push`];
|
|
2147
|
-
if (isServerRuntime()) {
|
|
2148
|
-
const directHost = ['local', 'host'].join('');
|
|
2149
|
-
pushUrls.push(`http://${directHost}:9000/api/repo/push`);
|
|
2150
|
-
}
|
|
2151
|
-
let result;
|
|
2152
|
-
let lastError = '';
|
|
2153
|
-
for (const pushUrl of pushUrls) {
|
|
2154
|
-
try {
|
|
2155
|
-
const response = await fetch(pushUrl, { method: 'POST', headers, body });
|
|
2156
|
-
result = await response.json();
|
|
2157
|
-
if (response.ok && result.success)
|
|
2158
|
-
break;
|
|
2159
|
-
lastError = result.error || result.message || `Status ${response.status}`;
|
|
2160
|
-
result = null;
|
|
2161
|
-
}
|
|
2162
|
-
catch (e) {
|
|
2163
|
-
lastError = e.message;
|
|
2164
|
-
result = null;
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
if (!result || !result.success) {
|
|
2168
|
-
return {
|
|
2169
|
-
success: false,
|
|
2170
|
-
error: lastError || 'Push failed on all endpoints',
|
|
2171
|
-
suggestion: 'Check your authentication and try again. Run vigthoria login to refresh your token.'
|
|
2172
|
-
};
|
|
2173
|
-
}
|
|
2174
|
-
return {
|
|
2175
|
-
success: true,
|
|
2176
|
-
output: `Successfully pushed "${project}" to Vigthoria Community!\n` +
|
|
2177
|
-
`Action: ${result.action || 'created'}\n` +
|
|
2178
|
-
`Files: ${result.filesWritten || filesArray.length}\n` +
|
|
2179
|
-
`URL: ${result.url || `https://community.vigthoria.io/showcase/${result.projectId}`}`,
|
|
2180
|
-
metadata: { action: 'push', project, filesWritten: result.filesWritten || filesArray.length, url: result.url }
|
|
2181
|
-
};
|
|
2182
|
-
}
|
|
2183
|
-
// All other actions use CLI commands
|
|
2184
|
-
let command;
|
|
2185
|
-
switch (action) {
|
|
2186
|
-
case 'pull':
|
|
2187
|
-
if (!project) {
|
|
2188
|
-
return {
|
|
2189
|
-
success: false,
|
|
2190
|
-
error: 'Project name is required for pull',
|
|
2191
|
-
suggestion: 'Provide a project name, e.g., repo action=pull project=my-project'
|
|
2192
|
-
};
|
|
2193
|
-
}
|
|
2194
|
-
command = `vigthoria repo pull "${project}"`;
|
|
2195
|
-
break;
|
|
2196
|
-
case 'list':
|
|
2197
|
-
command = 'vigthoria repo list';
|
|
2198
|
-
break;
|
|
2199
|
-
case 'status':
|
|
2200
|
-
if (!project) {
|
|
2201
|
-
return {
|
|
2202
|
-
success: false,
|
|
2203
|
-
error: 'Project name is required for status',
|
|
2204
|
-
suggestion: 'Provide a project name, e.g., repo action=status project=my-project'
|
|
2205
|
-
};
|
|
2206
|
-
}
|
|
2207
|
-
command = `vigthoria repo status "${project}"`;
|
|
2208
|
-
break;
|
|
2209
|
-
case 'share':
|
|
2210
|
-
if (!project || !args.username) {
|
|
2211
|
-
return {
|
|
2212
|
-
success: false,
|
|
2213
|
-
error: 'Project name and username are required for share',
|
|
2214
|
-
suggestion: 'Provide both, e.g., repo action=share project=my-project username=collaborator permission=read'
|
|
2215
|
-
};
|
|
2216
|
-
}
|
|
2217
|
-
const permission = args.permission || 'read';
|
|
2218
|
-
command = `vigthoria repo share "${project}" "${args.username}" --permission ${permission}`;
|
|
2219
|
-
break;
|
|
2220
|
-
case 'delete':
|
|
2221
|
-
if (!project) {
|
|
2222
|
-
return {
|
|
2223
|
-
success: false,
|
|
2224
|
-
error: 'Project name is required for delete',
|
|
2225
|
-
suggestion: 'Provide a project name, e.g., repo action=delete project=my-project'
|
|
2226
|
-
};
|
|
2227
|
-
}
|
|
2228
|
-
command = `vigthoria repo delete "${project}" --force`;
|
|
2229
|
-
break;
|
|
2230
|
-
case 'clone':
|
|
2231
|
-
if (!project) {
|
|
2232
|
-
return {
|
|
2233
|
-
success: false,
|
|
2234
|
-
error: 'Project name is required for clone',
|
|
2235
|
-
suggestion: 'Provide a project name, e.g., repo action=clone project=my-project path=/path/to/target'
|
|
2236
|
-
};
|
|
2237
|
-
}
|
|
2238
|
-
command = `vigthoria repo clone "${project}" "${targetPath}"`;
|
|
2239
|
-
break;
|
|
2240
|
-
default:
|
|
2241
|
-
return {
|
|
2242
|
-
success: false,
|
|
2243
|
-
error: `Unknown repo action: ${action}`,
|
|
2244
|
-
suggestion: 'Available actions: push, pull, list, status, share, delete, clone'
|
|
2245
|
-
};
|
|
2246
|
-
}
|
|
2247
|
-
const output = execSync(command, {
|
|
2248
|
-
cwd: this.cwd,
|
|
2249
|
-
encoding: 'utf-8',
|
|
2250
|
-
timeout: 120000,
|
|
2251
|
-
env: { ...process.env, FORCE_COLOR: '0' }
|
|
2252
|
-
});
|
|
2253
|
-
return {
|
|
2254
|
-
success: true,
|
|
2255
|
-
output: output.trim(),
|
|
2256
|
-
metadata: { action, project }
|
|
2257
|
-
};
|
|
2258
|
-
}
|
|
2259
|
-
catch (error) {
|
|
2260
|
-
return {
|
|
2261
|
-
success: false,
|
|
2262
|
-
error: error.stderr || error.message,
|
|
2263
|
-
suggestion: 'Make sure you are logged in with vigthoria login and have the required permissions.'
|
|
2264
|
-
};
|
|
2265
|
-
}
|
|
2266
|
-
}
|
|
2267
|
-
/**
|
|
2268
|
-
* Fetch URL content - Cross-platform web fetching
|
|
2269
|
-
* Uses Node.js native fetch (available in Node 18+)
|
|
2270
|
-
*/
|
|
2271
|
-
async fetchUrl(args) {
|
|
2272
|
-
const url = args.url;
|
|
2273
|
-
const method = (args.method || 'GET').toUpperCase();
|
|
2274
|
-
// Validate URL
|
|
2275
|
-
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
2276
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'URL must start with http:// or https://', 'Provide a valid URL, e.g., https://example.com');
|
|
2277
|
-
}
|
|
2278
|
-
try {
|
|
2279
|
-
const fetchOptions = {
|
|
2280
|
-
method,
|
|
2281
|
-
headers: {
|
|
2282
|
-
'User-Agent': 'Vigthoria-CLI/1.5.7',
|
|
2283
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
2284
|
-
},
|
|
2285
|
-
};
|
|
2286
|
-
// Parse custom headers if provided
|
|
2287
|
-
if (args.headers) {
|
|
2288
|
-
try {
|
|
2289
|
-
const customHeaders = JSON.parse(args.headers);
|
|
2290
|
-
fetchOptions.headers = { ...fetchOptions.headers, ...customHeaders };
|
|
2291
|
-
}
|
|
2292
|
-
catch {
|
|
2293
|
-
this.logger.warn('Invalid headers JSON, using defaults');
|
|
2294
|
-
}
|
|
2295
|
-
}
|
|
2296
|
-
// Add body for POST/PUT requests
|
|
2297
|
-
if (args.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
2298
|
-
fetchOptions.body = args.body;
|
|
2299
|
-
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
2300
|
-
}
|
|
2301
|
-
// Use AbortController for timeout
|
|
2302
|
-
const controller = new AbortController();
|
|
2303
|
-
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
2304
|
-
fetchOptions.signal = controller.signal;
|
|
2305
|
-
const response = await fetch(url, fetchOptions);
|
|
2306
|
-
clearTimeout(timeout);
|
|
2307
|
-
if (!response.ok) {
|
|
2308
|
-
return this.createErrorResult(ToolErrorType.NETWORK_ERROR, `HTTP ${response.status}: ${response.statusText}`, `The server returned an error. Status: ${response.status}`);
|
|
2309
|
-
}
|
|
2310
|
-
let content = await response.text();
|
|
2311
|
-
// Extract content based on selector if provided (basic HTML extraction)
|
|
2312
|
-
if (args.selector && content.includes('<')) {
|
|
2313
|
-
const selector = args.selector.toLowerCase().trim();
|
|
2314
|
-
if (selector === 'title') {
|
|
2315
|
-
const match = content.match(/<title[^>]*>([^<]*)<\/title>/i);
|
|
2316
|
-
content = match ? match[1].trim() : 'No title found';
|
|
2317
|
-
}
|
|
2318
|
-
else if (selector === 'meta' || selector.includes('meta[')) {
|
|
2319
|
-
const matches = content.match(/<meta[^>]+>/gi) || [];
|
|
2320
|
-
content = matches.length > 0 ? matches.join('\n') : 'No meta tags found';
|
|
2321
|
-
}
|
|
2322
|
-
else if (selector === 'body' || selector === 'text') {
|
|
2323
|
-
// Extract readable text from body
|
|
2324
|
-
const match = content.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
2325
|
-
if (match) {
|
|
2326
|
-
content = match[1]
|
|
2327
|
-
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
2328
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
2329
|
-
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
|
|
2330
|
-
.replace(/<header[\s\S]*?<\/header>/gi, '')
|
|
2331
|
-
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
|
|
2332
|
-
.replace(/<[^>]+>/g, ' ')
|
|
2333
|
-
.replace(/\s+/g, ' ')
|
|
2334
|
-
.trim();
|
|
2335
|
-
}
|
|
2336
|
-
}
|
|
2337
|
-
else if (selector === 'links' || selector === 'a') {
|
|
2338
|
-
// Extract all links
|
|
2339
|
-
const linkRegex = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
|
|
2340
|
-
const links = [];
|
|
2341
|
-
let linkMatch;
|
|
2342
|
-
while ((linkMatch = linkRegex.exec(content)) !== null) {
|
|
2343
|
-
const href = linkMatch[1];
|
|
2344
|
-
const text = linkMatch[2].trim();
|
|
2345
|
-
if (text && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
2346
|
-
links.push(`${text}: ${href}`);
|
|
2347
|
-
}
|
|
2348
|
-
}
|
|
2349
|
-
content = links.length > 0 ? links.join('\n') : 'No links found';
|
|
2350
|
-
}
|
|
2351
|
-
else if (selector.includes(',')) {
|
|
2352
|
-
// Handle compound selectors like "h1, h2, h3"
|
|
2353
|
-
const tags = selector.split(',').map(s => s.trim());
|
|
2354
|
-
const allMatches = [];
|
|
2355
|
-
for (const tag of tags) {
|
|
2356
|
-
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'gi');
|
|
2357
|
-
let match;
|
|
2358
|
-
while ((match = regex.exec(content)) !== null) {
|
|
2359
|
-
const text = match[1].replace(/<[^>]+>/g, '').trim();
|
|
2360
|
-
if (text)
|
|
2361
|
-
allMatches.push(`[${tag}] ${text}`);
|
|
2362
|
-
}
|
|
2363
|
-
}
|
|
2364
|
-
content = allMatches.length > 0 ? allMatches.join('\n') : `No ${selector} tags found`;
|
|
2365
|
-
}
|
|
2366
|
-
else if (/^h[1-6]$/.test(selector)) {
|
|
2367
|
-
// Single heading selector
|
|
2368
|
-
const regex = new RegExp(`<${selector}[^>]*>([\\s\\S]*?)</${selector}>`, 'gi');
|
|
2369
|
-
const matches = [];
|
|
2370
|
-
let match;
|
|
2371
|
-
while ((match = regex.exec(content)) !== null) {
|
|
2372
|
-
const text = match[1].replace(/<[^>]+>/g, '').trim();
|
|
2373
|
-
if (text)
|
|
2374
|
-
matches.push(text);
|
|
2375
|
-
}
|
|
2376
|
-
content = matches.length > 0 ? matches.join('\n') : `No ${selector} tags found`;
|
|
2377
|
-
}
|
|
2378
|
-
else if (selector === 'nav' || selector === 'nav a') {
|
|
2379
|
-
// Extract navigation links
|
|
2380
|
-
const navMatch = content.match(/<nav[^>]*>([\s\S]*?)<\/nav>/gi);
|
|
2381
|
-
if (navMatch) {
|
|
2382
|
-
const linkRegex = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
|
|
2383
|
-
const links = [];
|
|
2384
|
-
for (const nav of navMatch) {
|
|
2385
|
-
let linkMatch;
|
|
2386
|
-
while ((linkMatch = linkRegex.exec(nav)) !== null) {
|
|
2387
|
-
const text = linkMatch[2].trim();
|
|
2388
|
-
if (text)
|
|
2389
|
-
links.push(`${text}: ${linkMatch[1]}`);
|
|
2390
|
-
}
|
|
2391
|
-
}
|
|
2392
|
-
content = links.length > 0 ? links.join('\n') : 'No navigation links found';
|
|
2393
|
-
}
|
|
2394
|
-
else {
|
|
2395
|
-
content = 'No navigation found';
|
|
2396
|
-
}
|
|
2397
|
-
}
|
|
2398
|
-
else if (selector === 'footer') {
|
|
2399
|
-
const match = content.match(/<footer[^>]*>([\s\S]*?)<\/footer>/i);
|
|
2400
|
-
content = match ? match[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : 'No footer found';
|
|
2401
|
-
}
|
|
2402
|
-
else if (selector === 'images' || selector === 'img') {
|
|
2403
|
-
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*(?:alt=["']([^"']*)["'])?[^>]*>/gi;
|
|
2404
|
-
const images = [];
|
|
2405
|
-
let imgMatch;
|
|
2406
|
-
while ((imgMatch = imgRegex.exec(content)) !== null) {
|
|
2407
|
-
const src = imgMatch[1];
|
|
2408
|
-
const alt = imgMatch[2] || 'no alt';
|
|
2409
|
-
images.push(`${alt}: ${src}`);
|
|
2410
|
-
}
|
|
2411
|
-
content = images.length > 0 ? images.join('\n') : 'No images found';
|
|
2412
|
-
}
|
|
2413
|
-
else {
|
|
2414
|
-
// Generic tag extraction
|
|
2415
|
-
const regex = new RegExp(`<${selector}[^>]*>([\\s\\S]*?)</${selector}>`, 'gi');
|
|
2416
|
-
const matches = [];
|
|
2417
|
-
let match;
|
|
2418
|
-
while ((match = regex.exec(content)) !== null) {
|
|
2419
|
-
matches.push(match[1].replace(/<[^>]+>/g, ' ').trim());
|
|
2420
|
-
}
|
|
2421
|
-
content = matches.length > 0 ? matches.join('\n\n---\n\n') : `No ${selector} elements found`;
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
// Truncate if too long
|
|
2425
|
-
if (content.length > 50000) {
|
|
2426
|
-
content = content.substring(0, 50000) + '\n... (truncated, content too long)';
|
|
2427
|
-
}
|
|
2428
|
-
return {
|
|
2429
|
-
success: true,
|
|
2430
|
-
output: content,
|
|
2431
|
-
metadata: {
|
|
2432
|
-
url,
|
|
2433
|
-
status: response.status,
|
|
2434
|
-
contentType: response.headers.get('content-type') || 'unknown',
|
|
2435
|
-
contentLength: content.length,
|
|
2436
|
-
},
|
|
2437
|
-
};
|
|
2438
|
-
}
|
|
2439
|
-
catch (error) {
|
|
2440
|
-
if (error.name === 'AbortError') {
|
|
2441
|
-
return this.createErrorResult(ToolErrorType.TIMEOUT, 'Request timed out after 30 seconds', 'The server took too long to respond. Try again or use a different URL.');
|
|
2442
|
-
}
|
|
2443
|
-
return this.createErrorResult(ToolErrorType.NETWORK_ERROR, error.message, 'Check your internet connection and ensure the URL is correct.');
|
|
2444
|
-
}
|
|
2445
|
-
}
|
|
2446
|
-
async browserTool(args) {
|
|
2447
|
-
const mode = (args.mode || '').toLowerCase();
|
|
2448
|
-
const url = args.url;
|
|
2449
|
-
if (!['capture', 'errors', 'test'].includes(mode)) {
|
|
2450
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid mode: ${args.mode}`, "mode must be one of: 'capture', 'errors', 'test'");
|
|
2451
|
-
}
|
|
2452
|
-
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) {
|
|
2453
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'URL must start with http:// or https://', 'Provide a valid URL, e.g., https://example.com');
|
|
2454
|
-
}
|
|
2455
|
-
const host = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_HOST || '127.0.0.1';
|
|
2456
|
-
const port = process.env.VIGTHORIA_DEVTOOLS_BRIDGE_PORT || '4016';
|
|
2457
|
-
const base = `http://${host}:${port}`;
|
|
2458
|
-
const toolName = mode === 'capture' ? 'capture_state' :
|
|
2459
|
-
mode === 'errors' ? 'get_page_errors' :
|
|
2460
|
-
'run_test';
|
|
2461
|
-
const params = { url };
|
|
2462
|
-
if (mode === 'capture' && (args.include_screenshot || '').toLowerCase() === 'false') {
|
|
2463
|
-
params.includeScreenshot = false;
|
|
2464
|
-
}
|
|
2465
|
-
if (mode === 'test') {
|
|
2466
|
-
params.testType = 'quick';
|
|
2467
|
-
}
|
|
2468
|
-
const controller = new AbortController();
|
|
2469
|
-
const timeoutMs = mode === 'test' ? 60000 : 45000;
|
|
2470
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
2471
|
-
try {
|
|
2472
|
-
const res = await fetch(`${base}/api/mcp/tools`, {
|
|
2473
|
-
method: 'POST',
|
|
2474
|
-
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Vigthoria-CLI-Browser/1.0' },
|
|
2475
|
-
body: JSON.stringify({ tool: toolName, params }),
|
|
2476
|
-
signal: controller.signal,
|
|
2477
|
-
});
|
|
2478
|
-
clearTimeout(timeout);
|
|
2479
|
-
if (!res.ok) {
|
|
2480
|
-
return this.createErrorResult(ToolErrorType.NETWORK_ERROR, `DevTools Bridge HTTP ${res.status}`, 'Ensure the bridge is running (vigthoria bridge status). On a developer workstation it must be installed and started locally.');
|
|
2481
|
-
}
|
|
2482
|
-
const data = await res.json();
|
|
2483
|
-
if (!data.success) {
|
|
2484
|
-
return this.createErrorResult(ToolErrorType.NETWORK_ERROR, data.error || 'DevTools Bridge returned unsuccessful response', 'Check the bridge logs.');
|
|
2485
|
-
}
|
|
2486
|
-
let payload;
|
|
2487
|
-
if (mode === 'capture' && data.result && typeof data.result === 'object') {
|
|
2488
|
-
const r = data.result;
|
|
2489
|
-
const trimmed = {
|
|
2490
|
-
url: r.url,
|
|
2491
|
-
timestamp: r.timestamp,
|
|
2492
|
-
metrics: r.metrics,
|
|
2493
|
-
performance: r.performance,
|
|
2494
|
-
html_excerpt: typeof r.html === 'string' ? r.html.slice(0, 4000) : undefined,
|
|
2495
|
-
screenshot_data_url_length: typeof r.screenshot === 'string' ? r.screenshot.length : 0,
|
|
2496
|
-
};
|
|
2497
|
-
payload = JSON.stringify(trimmed, null, 2);
|
|
2498
|
-
}
|
|
2499
|
-
else {
|
|
2500
|
-
payload = JSON.stringify(data.result, null, 2).slice(0, 8000);
|
|
2501
|
-
}
|
|
2502
|
-
return {
|
|
2503
|
-
success: true,
|
|
2504
|
-
output: payload,
|
|
2505
|
-
metadata: { tool: 'browser', mode, url },
|
|
2506
|
-
};
|
|
2507
|
-
}
|
|
2508
|
-
catch (e) {
|
|
2509
|
-
clearTimeout(timeout);
|
|
2510
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
2511
|
-
return this.createErrorResult(ToolErrorType.NETWORK_ERROR, `Browser tool failed: ${msg}`, "Verify the DevTools Bridge is reachable on host:port (defaults 127.0.0.1:4016).");
|
|
2512
|
-
}
|
|
2513
|
-
}
|
|
2514
|
-
/**
|
|
2515
|
-
* Execute command via SSH on remote server
|
|
2516
|
-
* Useful for running Unix commands from Windows
|
|
2517
|
-
*/
|
|
2518
|
-
async sshExec(args) {
|
|
2519
|
-
const command = args.command;
|
|
2520
|
-
const host = args.host || 'vigthoria-server';
|
|
2521
|
-
const timeout = args.timeout ? parseInt(args.timeout) * 1000 : 60000;
|
|
2522
|
-
const normalizedHost = String(host).trim().toLowerCase();
|
|
2523
|
-
if (!SSH_SAFE_HOST_PATTERN.test(normalizedHost)) {
|
|
2524
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, 'Invalid SSH host format', 'Host can only include letters, numbers, dots, and dashes.');
|
|
2525
|
-
}
|
|
2526
|
-
const allowedHosts = getSshAllowedHosts();
|
|
2527
|
-
if (!allowedHosts.has(normalizedHost)) {
|
|
2528
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `SSH host is not allowlisted: ${host}`, 'Add the host to VIGTHORIA_SSH_ALLOWED_HOSTS to permit access.');
|
|
2529
|
-
}
|
|
2530
|
-
// Security checks for SSH commands
|
|
2531
|
-
const blockedPatterns = [
|
|
2532
|
-
/\brm\s+-rf?\s+\//i, // Dangerous rm commands
|
|
2533
|
-
/\bsudo\s+rm/i, // Sudo rm
|
|
2534
|
-
/>\s*\/dev\/sd/i, // Writing to disk devices
|
|
2535
|
-
/\bdd\s+.*of=\/dev/i, // dd to devices
|
|
2536
|
-
/\bmkfs/i, // Format filesystems
|
|
2537
|
-
/shutdown|reboot|halt|poweroff/i, // System control
|
|
2538
|
-
];
|
|
2539
|
-
for (const pattern of blockedPatterns) {
|
|
2540
|
-
if (pattern.test(command)) {
|
|
2541
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, 'This command is blocked for security reasons', 'Dangerous system commands cannot be executed via SSH.');
|
|
2542
|
-
}
|
|
2543
|
-
}
|
|
2544
|
-
try {
|
|
2545
|
-
const os = require('os');
|
|
2546
|
-
const platform = os.platform();
|
|
2547
|
-
let sshCommand;
|
|
2548
|
-
let execOptions = {
|
|
2549
|
-
encoding: 'utf-8',
|
|
2550
|
-
timeout,
|
|
2551
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
2552
|
-
};
|
|
2553
|
-
if (platform === 'win32') {
|
|
2554
|
-
// On Windows, use the ssh command from OpenSSH
|
|
2555
|
-
sshCommand = `ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${host} "${command.replace(/"/g, '\\"')}"`;
|
|
2556
|
-
execOptions.shell = true;
|
|
2557
|
-
}
|
|
2558
|
-
else {
|
|
2559
|
-
// On Unix-like systems
|
|
2560
|
-
sshCommand = `ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${host} '${command.replace(/'/g, "'\\''")}'`;
|
|
2561
|
-
execOptions.shell = '/bin/sh';
|
|
2562
|
-
}
|
|
2563
|
-
const output = execSync(sshCommand, execOptions);
|
|
2564
|
-
return {
|
|
2565
|
-
success: true,
|
|
2566
|
-
output: output.trim(),
|
|
2567
|
-
metadata: { host, command },
|
|
2568
|
-
};
|
|
2569
|
-
}
|
|
2570
|
-
catch (error) {
|
|
2571
|
-
// Check for common SSH errors
|
|
2572
|
-
const errorMsg = error.stderr || error.message || '';
|
|
2573
|
-
if (errorMsg.includes('Connection refused') || errorMsg.includes('No route to host')) {
|
|
2574
|
-
return this.createErrorResult(ToolErrorType.NETWORK_ERROR, `Cannot connect to SSH host: ${host}`, 'Check that the SSH host is correct and the server is running.');
|
|
2575
|
-
}
|
|
2576
|
-
if (errorMsg.includes('Permission denied') || errorMsg.includes('authentication')) {
|
|
2577
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, 'SSH authentication failed', 'Make sure you have SSH key authentication set up for this host.');
|
|
2578
|
-
}
|
|
2579
|
-
if (error.killed) {
|
|
2580
|
-
return this.createErrorResult(ToolErrorType.TIMEOUT, `SSH command timed out after ${timeout / 1000}s`, 'Try a simpler command or increase the timeout.');
|
|
2581
|
-
}
|
|
2582
|
-
return {
|
|
2583
|
-
success: false,
|
|
2584
|
-
output: error.stdout || '',
|
|
2585
|
-
error: errorMsg,
|
|
2586
|
-
suggestion: 'Check the command syntax and ensure SSH access is configured.',
|
|
2587
|
-
};
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
/**
|
|
2591
|
-
* Resolve and SANITIZE path - prevent path traversal outside workspace
|
|
2592
|
-
* SECURITY: All paths MUST stay within the workspace (cwd)
|
|
2593
|
-
*/
|
|
2594
|
-
resolvePath(p) {
|
|
2595
|
-
const normalizedInput = String(p || '').trim().replace(/\\/g, '/');
|
|
2596
|
-
const workspaceRoot = path.normalize(this.cwd);
|
|
2597
|
-
const workspaceRootPosix = workspaceRoot.replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
2598
|
-
const workspaceBase = path.posix.basename(workspaceRootPosix);
|
|
2599
|
-
const stripWorkspacePrefix = (value) => {
|
|
2600
|
-
let candidate = String(value || '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
2601
|
-
if (!candidate) {
|
|
2602
|
-
return candidate;
|
|
2603
|
-
}
|
|
2604
|
-
const normalizedRootNoSlash = workspaceRootPosix.replace(/^\//, '');
|
|
2605
|
-
const prefixes = [
|
|
2606
|
-
`${workspaceRootPosix}/`,
|
|
2607
|
-
`${normalizedRootNoSlash}/`,
|
|
2608
|
-
`${workspaceBase}/`,
|
|
2609
|
-
];
|
|
2610
|
-
for (const prefix of prefixes) {
|
|
2611
|
-
if (candidate.startsWith(prefix)) {
|
|
2612
|
-
candidate = candidate.slice(prefix.length);
|
|
2613
|
-
}
|
|
2614
|
-
}
|
|
2615
|
-
const embeddedWorkspacePrefix = `/${workspaceBase}/`;
|
|
2616
|
-
const embeddedIndex = candidate.indexOf(embeddedWorkspacePrefix);
|
|
2617
|
-
if (embeddedIndex >= 0) {
|
|
2618
|
-
candidate = candidate.slice(embeddedIndex + embeddedWorkspacePrefix.length);
|
|
2619
|
-
}
|
|
2620
|
-
return candidate.replace(/^\//, '');
|
|
2621
|
-
};
|
|
2622
|
-
// Resolve the full path (handles both relative and absolute)
|
|
2623
|
-
let resolvedPath;
|
|
2624
|
-
if (path.isAbsolute(normalizedInput)) {
|
|
2625
|
-
resolvedPath = path.normalize(normalizedInput);
|
|
2626
|
-
}
|
|
2627
|
-
else {
|
|
2628
|
-
resolvedPath = path.normalize(path.join(this.cwd, stripWorkspacePrefix(normalizedInput)));
|
|
2629
|
-
}
|
|
2630
|
-
// SECURITY CHECK: Ensure path is within workspace
|
|
2631
|
-
if (!resolvedPath.startsWith(workspaceRoot + path.sep) && resolvedPath !== workspaceRoot) {
|
|
2632
|
-
// Path is outside workspace - force it back to workspace
|
|
2633
|
-
this.logger.warn(`Security: Blocked access to path outside workspace: ${p}`);
|
|
2634
|
-
// Return the sanitized relative path within workspace
|
|
2635
|
-
const basename = path.basename(stripWorkspacePrefix(normalizedInput) || normalizedInput);
|
|
2636
|
-
return path.join(this.cwd, basename);
|
|
2637
|
-
}
|
|
2638
|
-
return resolvedPath;
|
|
2639
|
-
}
|
|
2640
|
-
/**
|
|
2641
|
-
* Check if a path is within the allowed workspace
|
|
2642
|
-
*/
|
|
2643
|
-
isPathWithinWorkspace(p) {
|
|
2644
|
-
const resolvedPath = path.normalize(path.isAbsolute(p) ? p : path.join(this.cwd, p));
|
|
2645
|
-
const workspaceRoot = path.normalize(this.cwd);
|
|
2646
|
-
return resolvedPath.startsWith(workspaceRoot + path.sep) || resolvedPath === workspaceRoot;
|
|
2647
|
-
}
|
|
2648
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2649
|
-
// TASK (Sub-Agent) Tool
|
|
2650
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2651
|
-
async task(args) {
|
|
2652
|
-
const description = args.description;
|
|
2653
|
-
const workingDir = args.working_dir
|
|
2654
|
-
? this.resolvePath(args.working_dir)
|
|
2655
|
-
: this.cwd;
|
|
2656
|
-
if (!this.isPathWithinWorkspace(workingDir)) {
|
|
2657
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, 'Sub-agent working directory must be within the project workspace', 'Provide a relative path within the current project.');
|
|
2658
|
-
}
|
|
2659
|
-
this.logger.info(`Spawning sub-agent: ${description.substring(0, 80)}...`);
|
|
2660
|
-
// Create a scoped sub-agent with its own tool instance
|
|
2661
|
-
const subTools = new AgenticTools(this.logger, workingDir, this.permissionCallback, this.autoApprove);
|
|
2662
|
-
// Build a focused system prompt for the sub-agent
|
|
2663
|
-
const systemPrompt = [
|
|
2664
|
-
'You are a focused sub-agent spawned to complete a specific subtask.',
|
|
2665
|
-
'You have access to standard tools (read_file, write_file, edit_file, bash, grep, list_dir, glob, git). You cannot spawn sub-agents.',
|
|
2666
|
-
'Complete the task thoroughly and return a detailed result.',
|
|
2667
|
-
`Working directory: ${workingDir}`,
|
|
2668
|
-
'',
|
|
2669
|
-
AgenticTools.getToolsForPrompt().replace(/### task[\s\S]*?(?=###|$)/, ''), // Strip task from sub-agent
|
|
2670
|
-
].join('\n');
|
|
2671
|
-
const messages = [
|
|
2672
|
-
{ role: 'system', content: systemPrompt },
|
|
2673
|
-
{ role: 'user', content: description },
|
|
2674
|
-
];
|
|
2675
|
-
const maxSubTurns = 8;
|
|
2676
|
-
const results = [];
|
|
2677
|
-
try {
|
|
2678
|
-
for (let turn = 0; turn < maxSubTurns; turn++) {
|
|
2679
|
-
// Import api dynamically to avoid circular dependency
|
|
2680
|
-
const { APIClient } = await import('./api.js');
|
|
2681
|
-
const { Config } = await import('./config.js');
|
|
2682
|
-
const config = new Config();
|
|
2683
|
-
const api = new APIClient(config, this.logger);
|
|
2684
|
-
const response = await api.chat(messages, 'code');
|
|
2685
|
-
const assistantMessage = response.message || '';
|
|
2686
|
-
messages.push({ role: 'assistant', content: assistantMessage });
|
|
2687
|
-
const toolCalls = AgenticTools.parseToolCalls(assistantMessage);
|
|
2688
|
-
if (toolCalls.length === 0) {
|
|
2689
|
-
// Sub-agent finished - extract the final answer
|
|
2690
|
-
const finalAnswer = assistantMessage
|
|
2691
|
-
.replace(/```tool[\s\S]*?```/g, '')
|
|
2692
|
-
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '')
|
|
2693
|
-
.trim();
|
|
2694
|
-
results.push(finalAnswer);
|
|
2695
|
-
break;
|
|
2696
|
-
}
|
|
2697
|
-
// Execute each tool call
|
|
2698
|
-
for (const call of toolCalls) {
|
|
2699
|
-
const result = await subTools.execute(call);
|
|
2700
|
-
const summary = `Tool ${call.tool} ${result.success ? 'succeeded' : 'FAILED'}.` +
|
|
2701
|
-
(call.args.path ? `\nFile: ${call.args.path}` : '') +
|
|
2702
|
-
(result.output ? `\nOutput:\n${result.output.substring(0, 4000)}` : '') +
|
|
2703
|
-
(result.error ? `\nError: ${result.error}` : '');
|
|
2704
|
-
messages.push({ role: 'system', content: summary });
|
|
2705
|
-
results.push(`[${call.tool}] ${result.success ? '✓' : '✗'}`);
|
|
2706
|
-
}
|
|
2707
|
-
messages.push({
|
|
2708
|
-
role: 'system',
|
|
2709
|
-
content: 'Continue with your task. Use more tools if needed, or provide your final answer.',
|
|
2710
|
-
});
|
|
2711
|
-
}
|
|
2712
|
-
return {
|
|
2713
|
-
success: true,
|
|
2714
|
-
output: results.join('\n'),
|
|
2715
|
-
metadata: { subAgentTurns: results.length, workingDir },
|
|
2716
|
-
};
|
|
2717
|
-
}
|
|
2718
|
-
catch (error) {
|
|
2719
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Sub-agent failed: ${error.message}`, 'The sub-agent encountered an error. Try simplifying the task or running it directly.');
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2723
|
-
// MULTI_EDIT Tool - Atomic multi-file edits with rollback
|
|
2724
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2725
|
-
async multiEdit(args) {
|
|
2726
|
-
let edits;
|
|
2727
|
-
try {
|
|
2728
|
-
edits = JSON.parse(args.edits);
|
|
2729
|
-
if (!Array.isArray(edits) || edits.length === 0) {
|
|
2730
|
-
throw new Error('edits must be a non-empty array');
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
catch (parseError) {
|
|
2734
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Invalid edits JSON: ${parseError.message}`, 'Provide edits as a JSON array: [{"path": "file.ts", "old_text": "find", "new_text": "replace"}]');
|
|
2735
|
-
}
|
|
2736
|
-
// Validate all edits can proceed before modifying anything
|
|
2737
|
-
const contentMap = new Map();
|
|
2738
|
-
const backups = [];
|
|
2739
|
-
const resolvedEdits = [];
|
|
2740
|
-
for (let i = 0; i < edits.length; i++) {
|
|
2741
|
-
const edit = edits[i];
|
|
2742
|
-
if (!edit.path || typeof edit.old_text !== 'string' || typeof edit.new_text !== 'string') {
|
|
2743
|
-
return this.createErrorResult(ToolErrorType.INVALID_ARGS, `Edit ${i}: missing required fields (path, old_text, new_text)`, 'Each edit must have path, old_text, and new_text fields.');
|
|
2744
|
-
}
|
|
2745
|
-
const resolvedPath = this.resolvePath(edit.path);
|
|
2746
|
-
if (!this.isPathWithinWorkspace(resolvedPath)) {
|
|
2747
|
-
return this.createErrorResult(ToolErrorType.PERMISSION_DENIED, `Edit ${i}: path "${edit.path}" is outside workspace`, 'All files must be within the current project.');
|
|
2748
|
-
}
|
|
2749
|
-
if (!fs.existsSync(resolvedPath)) {
|
|
2750
|
-
return this.createErrorResult(ToolErrorType.FILE_NOT_FOUND, `Edit ${i}: file not found: ${edit.path}`, 'Use write_file to create new files instead.');
|
|
2751
|
-
}
|
|
2752
|
-
// Use contentMap to track cumulative edits to the same file
|
|
2753
|
-
if (!contentMap.has(resolvedPath)) {
|
|
2754
|
-
const diskContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
2755
|
-
contentMap.set(resolvedPath, diskContent);
|
|
2756
|
-
backups.push({ path: resolvedPath, content: diskContent });
|
|
2757
|
-
}
|
|
2758
|
-
const content = contentMap.get(resolvedPath);
|
|
2759
|
-
if (!content.includes(edit.old_text)) {
|
|
2760
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Edit ${i}: old_text not found in ${edit.path}`, `The text to replace was not found. Use read_file to verify the file contents.`);
|
|
2761
|
-
}
|
|
2762
|
-
// Check for multiple matches
|
|
2763
|
-
const matchCount = content.split(edit.old_text).length - 1;
|
|
2764
|
-
if (matchCount > 1) {
|
|
2765
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Edit ${i}: old_text matches ${matchCount} locations in ${edit.path}`, 'Make old_text more specific to match exactly one location. Include surrounding context.');
|
|
2766
|
-
}
|
|
2767
|
-
// Apply edit to contentMap so subsequent edits to same file see updated content
|
|
2768
|
-
contentMap.set(resolvedPath, content.replace(edit.old_text, edit.new_text));
|
|
2769
|
-
resolvedEdits.push({ resolvedPath, old_text: edit.old_text, new_text: edit.new_text, content });
|
|
2770
|
-
}
|
|
2771
|
-
// Apply all edits
|
|
2772
|
-
const applied = [];
|
|
2773
|
-
try {
|
|
2774
|
-
for (const edit of resolvedEdits) {
|
|
2775
|
-
const finalContent = contentMap.get(edit.resolvedPath) || edit.content.replace(edit.old_text, edit.new_text);
|
|
2776
|
-
fs.writeFileSync(edit.resolvedPath, finalContent, 'utf-8');
|
|
2777
|
-
applied.push(path.relative(this.cwd, edit.resolvedPath));
|
|
2778
|
-
}
|
|
2779
|
-
// Push undo operations for all edits
|
|
2780
|
-
for (const backup of backups) {
|
|
2781
|
-
this.undoStack.push({
|
|
2782
|
-
id: `multi_edit_${Date.now()}_${path.basename(backup.path)}`,
|
|
2783
|
-
tool: 'multi_edit',
|
|
2784
|
-
timestamp: Date.now(),
|
|
2785
|
-
filePath: backup.path,
|
|
2786
|
-
originalContent: backup.content,
|
|
2787
|
-
description: `multi_edit: ${path.relative(this.cwd, backup.path)}`,
|
|
2788
|
-
});
|
|
2789
|
-
}
|
|
2790
|
-
return {
|
|
2791
|
-
success: true,
|
|
2792
|
-
output: `${CH.success} Atomically edited ${applied.length} file(s):\n${applied.map(f => ` ✓ ${f}`).join('\n')}`,
|
|
2793
|
-
undoable: true,
|
|
2794
|
-
metadata: { filesEdited: applied.length, files: applied },
|
|
2795
|
-
};
|
|
2796
|
-
}
|
|
2797
|
-
catch (error) {
|
|
2798
|
-
// ROLLBACK: Restore all files from backups
|
|
2799
|
-
for (const backup of backups) {
|
|
2800
|
-
try {
|
|
2801
|
-
fs.writeFileSync(backup.path, backup.content, 'utf-8');
|
|
2802
|
-
}
|
|
2803
|
-
catch {
|
|
2804
|
-
// Best-effort rollback
|
|
2805
|
-
}
|
|
2806
|
-
}
|
|
2807
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Multi-edit failed, all changes rolled back: ${error.message}`, 'Check file permissions and disk space.');
|
|
2808
|
-
}
|
|
2809
|
-
}
|
|
2810
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2811
|
-
// CODEBASE_SEARCH Tool - Deep indexed codebase search
|
|
2812
|
-
// ═══════════════════════════════════════════════════════════════
|
|
2813
|
-
async codebaseSearch(args) {
|
|
2814
|
-
const query = args.query;
|
|
2815
|
-
const scope = args.scope || 'all';
|
|
2816
|
-
const includePattern = args.include || '';
|
|
2817
|
-
const maxResults = Math.min(parseInt(args.max_results || '30', 10), 100);
|
|
2818
|
-
const results = [];
|
|
2819
|
-
const seen = new Set();
|
|
2820
|
-
// Helper: collect files recursively respecting gitignore-like patterns
|
|
2821
|
-
const collectFiles = (dir, pattern) => {
|
|
2822
|
-
const files = [];
|
|
2823
|
-
const ignorePatterns = [
|
|
2824
|
-
'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
|
|
2825
|
-
'.venv', 'venv', '.tox', 'coverage', '.nyc_output', '.cache',
|
|
2826
|
-
'vendor', 'target', 'bin', 'obj', '.svn', '.hg',
|
|
2827
|
-
];
|
|
2828
|
-
const walk = (currentDir, depth) => {
|
|
2829
|
-
if (depth > 12)
|
|
2830
|
-
return; // Prevent infinite recursion
|
|
2831
|
-
let entries;
|
|
2832
|
-
try {
|
|
2833
|
-
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
2834
|
-
}
|
|
2835
|
-
catch {
|
|
2836
|
-
return;
|
|
2837
|
-
}
|
|
2838
|
-
for (const entry of entries) {
|
|
2839
|
-
if (ignorePatterns.includes(entry.name) || entry.name.startsWith('.'))
|
|
2840
|
-
continue;
|
|
2841
|
-
const fullPath = path.join(currentDir, entry.name);
|
|
2842
|
-
if (entry.isDirectory()) {
|
|
2843
|
-
walk(fullPath, depth + 1);
|
|
2844
|
-
}
|
|
2845
|
-
else if (entry.isFile()) {
|
|
2846
|
-
if (pattern) {
|
|
2847
|
-
const basename = entry.name;
|
|
2848
|
-
// Simple glob matching for extension patterns like *.ts, *.js
|
|
2849
|
-
const globPattern = pattern.replace(/\*\*\//g, '(.*/)?').replace(/\*/g, '[^/]*').replace(/\?/g, '.');
|
|
2850
|
-
if (new RegExp(globPattern, 'i').test(basename) || fullPath.includes(pattern.replace(/\*/g, ''))) {
|
|
2851
|
-
files.push(fullPath);
|
|
2852
|
-
}
|
|
2853
|
-
}
|
|
2854
|
-
else {
|
|
2855
|
-
files.push(fullPath);
|
|
2856
|
-
}
|
|
2857
|
-
}
|
|
2858
|
-
}
|
|
2859
|
-
};
|
|
2860
|
-
walk(dir, 0);
|
|
2861
|
-
return files;
|
|
2862
|
-
};
|
|
2863
|
-
try {
|
|
2864
|
-
const allFiles = collectFiles(this.cwd, includePattern || undefined);
|
|
2865
|
-
// SCOPE: files - search file names/paths
|
|
2866
|
-
if (scope === 'files' || scope === 'all') {
|
|
2867
|
-
const queryLower = query.toLowerCase();
|
|
2868
|
-
const queryParts = queryLower.split(/[\s_\-./]+/).filter(Boolean);
|
|
2869
|
-
for (const filePath of allFiles) {
|
|
2870
|
-
const relativePath = path.relative(this.cwd, filePath);
|
|
2871
|
-
const filenameLower = relativePath.toLowerCase();
|
|
2872
|
-
const matches = queryParts.every(part => filenameLower.includes(part));
|
|
2873
|
-
if (matches && !seen.has(relativePath)) {
|
|
2874
|
-
seen.add(relativePath);
|
|
2875
|
-
results.push(`[file] ${relativePath}`);
|
|
2876
|
-
}
|
|
2877
|
-
if (results.length >= maxResults)
|
|
2878
|
-
break;
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
|
-
// SCOPE: symbols - extract function/class/variable definitions
|
|
2882
|
-
if ((scope === 'symbols' || scope === 'all') && results.length < maxResults) {
|
|
2883
|
-
const symbolRegex = /(?:(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var|def|class)\s+)([A-Za-z_$][A-Za-z0-9_$]*)/g;
|
|
2884
|
-
const queryLower = query.toLowerCase();
|
|
2885
|
-
for (const filePath of allFiles) {
|
|
2886
|
-
if (results.length >= maxResults)
|
|
2887
|
-
break;
|
|
2888
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
2889
|
-
// Only parse code files
|
|
2890
|
-
if (!['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.cpp', '.h', '.cs', '.swift', '.kt'].includes(ext))
|
|
2891
|
-
continue;
|
|
2892
|
-
let content;
|
|
2893
|
-
try {
|
|
2894
|
-
const stat = fs.statSync(filePath);
|
|
2895
|
-
if (stat.size > 512 * 1024)
|
|
2896
|
-
continue; // Skip files > 512KB
|
|
2897
|
-
content = fs.readFileSync(filePath, 'utf-8');
|
|
2898
|
-
}
|
|
2899
|
-
catch {
|
|
2900
|
-
continue;
|
|
2901
|
-
}
|
|
2902
|
-
let match;
|
|
2903
|
-
symbolRegex.lastIndex = 0;
|
|
2904
|
-
while ((match = symbolRegex.exec(content)) !== null) {
|
|
2905
|
-
const symbolName = match[1];
|
|
2906
|
-
if (symbolName.toLowerCase().includes(queryLower) || queryLower.includes(symbolName.toLowerCase())) {
|
|
2907
|
-
const relativePath = path.relative(this.cwd, filePath);
|
|
2908
|
-
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
2909
|
-
const key = `${relativePath}:${symbolName}`;
|
|
2910
|
-
if (!seen.has(key)) {
|
|
2911
|
-
seen.add(key);
|
|
2912
|
-
results.push(`[symbol] ${symbolName} → ${relativePath}:${lineNum}`);
|
|
2913
|
-
}
|
|
2914
|
-
}
|
|
2915
|
-
if (results.length >= maxResults)
|
|
2916
|
-
break;
|
|
2917
|
-
}
|
|
2918
|
-
}
|
|
2919
|
-
}
|
|
2920
|
-
// SCOPE: content - full-text search using ripgrep or fallback
|
|
2921
|
-
if ((scope === 'content' || scope === 'all') && results.length < maxResults) {
|
|
2922
|
-
try {
|
|
2923
|
-
// Try ripgrep first (fast)
|
|
2924
|
-
const rgArgs = [
|
|
2925
|
-
'-i', '--no-heading', '--line-number',
|
|
2926
|
-
'--max-count', '3',
|
|
2927
|
-
'--max-filesize', '512K',
|
|
2928
|
-
'-g', '!node_modules', '-g', '!.git', '-g', '!dist', '-g', '!build',
|
|
2929
|
-
];
|
|
2930
|
-
if (includePattern)
|
|
2931
|
-
rgArgs.push('-g', includePattern);
|
|
2932
|
-
rgArgs.push('--', query, this.cwd);
|
|
2933
|
-
const isWin = process.platform === 'win32';
|
|
2934
|
-
const quote = (s) => isWin ? `"${s}"` : `'${s}'`;
|
|
2935
|
-
const rgOutput = execSync(`rg ${rgArgs.map(a => quote(a)).join(' ')}`, {
|
|
2936
|
-
encoding: 'utf-8',
|
|
2937
|
-
timeout: 15000,
|
|
2938
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
2939
|
-
}).trim();
|
|
2940
|
-
for (const line of rgOutput.split('\n').slice(0, maxResults - results.length)) {
|
|
2941
|
-
if (!line.trim())
|
|
2942
|
-
continue;
|
|
2943
|
-
const relativeLine = line.replace(this.cwd + path.sep, '').replace(this.cwd + '/', '');
|
|
2944
|
-
const lineKey = relativeLine.substring(0, 200);
|
|
2945
|
-
if (!seen.has(lineKey)) {
|
|
2946
|
-
seen.add(lineKey);
|
|
2947
|
-
results.push(`[content] ${relativeLine}`);
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
}
|
|
2951
|
-
catch {
|
|
2952
|
-
// Fallback: Node-native grep
|
|
2953
|
-
const queryLower = query.toLowerCase();
|
|
2954
|
-
for (const filePath of allFiles) {
|
|
2955
|
-
if (results.length >= maxResults)
|
|
2956
|
-
break;
|
|
2957
|
-
try {
|
|
2958
|
-
const stat = fs.statSync(filePath);
|
|
2959
|
-
if (stat.size > 256 * 1024)
|
|
2960
|
-
continue;
|
|
2961
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2962
|
-
const lines = content.split('\n');
|
|
2963
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2964
|
-
if (lines[i].toLowerCase().includes(queryLower)) {
|
|
2965
|
-
const relativePath = path.relative(this.cwd, filePath);
|
|
2966
|
-
const lineKey = `${relativePath}:${i + 1}`;
|
|
2967
|
-
if (!seen.has(lineKey)) {
|
|
2968
|
-
seen.add(lineKey);
|
|
2969
|
-
results.push(`[content] ${relativePath}:${i + 1}: ${lines[i].trim().substring(0, 120)}`);
|
|
2970
|
-
}
|
|
2971
|
-
if (results.length >= maxResults)
|
|
2972
|
-
break;
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
}
|
|
2976
|
-
catch {
|
|
2977
|
-
continue;
|
|
2978
|
-
}
|
|
2979
|
-
}
|
|
2980
|
-
}
|
|
2981
|
-
}
|
|
2982
|
-
if (results.length === 0) {
|
|
2983
|
-
return {
|
|
2984
|
-
success: true,
|
|
2985
|
-
output: `No results found for "${query}" in scope "${scope}".`,
|
|
2986
|
-
metadata: { searchStatus: 'search_no_matches', totalFiles: allFiles.length },
|
|
2987
|
-
};
|
|
2988
|
-
}
|
|
2989
|
-
return {
|
|
2990
|
-
success: true,
|
|
2991
|
-
output: `Found ${results.length} result(s) across ${allFiles.length} files:\n\n${results.join('\n')}`,
|
|
2992
|
-
metadata: { searchStatus: 'search_matches_found', resultCount: results.length, totalFiles: allFiles.length },
|
|
2993
|
-
};
|
|
2994
|
-
}
|
|
2995
|
-
catch (error) {
|
|
2996
|
-
return this.createErrorResult(ToolErrorType.EXECUTION_FAILED, `Codebase search failed: ${error.message}`, 'Try a simpler query or use grep for specific text patterns.');
|
|
2997
|
-
}
|
|
2998
|
-
}
|
|
2999
|
-
/**
|
|
3000
|
-
* Parse tool calls from AI response (Vigthoria Agent format)
|
|
3001
|
-
* Enhanced to handle various AI output formats including malformed JSON
|
|
3002
|
-
*/
|
|
3003
|
-
static parseToolCalls(text) {
|
|
3004
|
-
if (typeof text !== 'string' || text.length === 0) {
|
|
3005
|
-
return [];
|
|
3006
|
-
}
|
|
3007
|
-
const calls = [];
|
|
3008
|
-
let match;
|
|
3009
|
-
// Helper to extract balanced JSON from a position (handles nested braces)
|
|
3010
|
-
const extractBalancedJson = (str, startIdx) => {
|
|
3011
|
-
if (str[startIdx] !== '{')
|
|
3012
|
-
return null;
|
|
3013
|
-
let braceCount = 0;
|
|
3014
|
-
let inString = false;
|
|
3015
|
-
let escapeNext = false;
|
|
3016
|
-
for (let i = startIdx; i < str.length; i++) {
|
|
3017
|
-
const char = str[i];
|
|
3018
|
-
if (escapeNext) {
|
|
3019
|
-
escapeNext = false;
|
|
3020
|
-
continue;
|
|
3021
|
-
}
|
|
3022
|
-
if (char === '\\') {
|
|
3023
|
-
escapeNext = true;
|
|
3024
|
-
continue;
|
|
3025
|
-
}
|
|
3026
|
-
if (char === '"') {
|
|
3027
|
-
inString = !inString;
|
|
3028
|
-
continue;
|
|
3029
|
-
}
|
|
3030
|
-
if (!inString) {
|
|
3031
|
-
if (char === '{')
|
|
3032
|
-
braceCount++;
|
|
3033
|
-
else if (char === '}') {
|
|
3034
|
-
braceCount--;
|
|
3035
|
-
if (braceCount === 0) {
|
|
3036
|
-
return str.substring(startIdx, i + 1);
|
|
3037
|
-
}
|
|
3038
|
-
}
|
|
3039
|
-
}
|
|
3040
|
-
}
|
|
3041
|
-
return null;
|
|
3042
|
-
};
|
|
3043
|
-
// Helper to fix common JSON issues from AI outputs
|
|
3044
|
-
// IMPORTANT: Don't blindly replace quotes - it breaks code content
|
|
3045
|
-
const fixJson = (jsonStr) => {
|
|
3046
|
-
// First, escape newlines and control characters
|
|
3047
|
-
let fixed = jsonStr
|
|
3048
|
-
.replace(/\r/g, '') // Remove carriage returns
|
|
3049
|
-
.replace(/\t/g, '\\t'); // Escape tabs
|
|
3050
|
-
// Escape literal newlines inside strings (but not \n which is already escaped)
|
|
3051
|
-
// We need to be careful - only escape newlines that are inside quoted strings
|
|
3052
|
-
const parts = [];
|
|
3053
|
-
let inString = false;
|
|
3054
|
-
let currentPart = '';
|
|
3055
|
-
for (let i = 0; i < fixed.length; i++) {
|
|
3056
|
-
const char = fixed[i];
|
|
3057
|
-
const prevChar = i > 0 ? fixed[i - 1] : '';
|
|
3058
|
-
if (char === '"' && prevChar !== '\\') {
|
|
3059
|
-
inString = !inString;
|
|
3060
|
-
currentPart += char;
|
|
3061
|
-
}
|
|
3062
|
-
else if (char === '\n') {
|
|
3063
|
-
if (inString) {
|
|
3064
|
-
currentPart += '\\n'; // Escape the newline
|
|
3065
|
-
}
|
|
3066
|
-
else {
|
|
3067
|
-
currentPart += char; // Keep as-is outside strings
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
else {
|
|
3071
|
-
currentPart += char;
|
|
3072
|
-
}
|
|
3073
|
-
}
|
|
3074
|
-
fixed = currentPart;
|
|
3075
|
-
// Quote unquoted keys (only outside strings)
|
|
3076
|
-
fixed = fixed.replace(/([{,]\s*)(\w+)\s*:/g, '$1"$2":');
|
|
3077
|
-
// Remove trailing commas
|
|
3078
|
-
fixed = fixed.replace(/,\s*}/g, '}');
|
|
3079
|
-
fixed = fixed.replace(/,\s*]/g, ']');
|
|
3080
|
-
return fixed;
|
|
3081
|
-
};
|
|
3082
|
-
// Normalize tool name from various formats
|
|
3083
|
-
const normalizeToolName = (name) => {
|
|
3084
|
-
const normalized = name
|
|
3085
|
-
.replace(/^__/, '') // Remove leading underscores
|
|
3086
|
-
.replace(/__$/, '') // Remove trailing underscores
|
|
3087
|
-
.replace(/^execute_/i, '') // Remove execute_ prefix
|
|
3088
|
-
.replace(/_execute$/i, '') // Remove _execute suffix
|
|
3089
|
-
.toLowerCase();
|
|
3090
|
-
// Map common variations
|
|
3091
|
-
const toolMap = {
|
|
3092
|
-
'bash': 'bash',
|
|
3093
|
-
'shell': 'bash',
|
|
3094
|
-
'run': 'bash',
|
|
3095
|
-
'command': 'bash',
|
|
3096
|
-
'list_dir': 'list_dir',
|
|
3097
|
-
'list_directory': 'list_dir',
|
|
3098
|
-
'ls': 'list_dir',
|
|
3099
|
-
'dir': 'list_dir',
|
|
3100
|
-
'read_file': 'read_file',
|
|
3101
|
-
'readfile': 'read_file',
|
|
3102
|
-
'read': 'read_file',
|
|
3103
|
-
'write_file': 'write_file',
|
|
3104
|
-
'writefile': 'write_file',
|
|
3105
|
-
'write': 'write_file',
|
|
3106
|
-
'edit_file': 'edit_file',
|
|
3107
|
-
'editfile': 'edit_file',
|
|
3108
|
-
'edit': 'edit_file',
|
|
3109
|
-
};
|
|
3110
|
-
return toolMap[normalized] || normalized;
|
|
3111
|
-
};
|
|
3112
|
-
// Match <tool_call>...</tool_call> blocks
|
|
3113
|
-
const toolCallRegex = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
3114
|
-
while ((match = toolCallRegex.exec(text)) !== null) {
|
|
3115
|
-
try {
|
|
3116
|
-
const fixed = fixJson(match[1]);
|
|
3117
|
-
const parsed = JSON.parse(fixed);
|
|
3118
|
-
if (parsed.tool && parsed.args) {
|
|
3119
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3120
|
-
calls.push(parsed);
|
|
3121
|
-
}
|
|
3122
|
-
}
|
|
3123
|
-
catch (e) {
|
|
3124
|
-
// Invalid JSON, skip
|
|
3125
|
-
}
|
|
3126
|
-
}
|
|
3127
|
-
// Match ```tool format
|
|
3128
|
-
const codeBlockRegex = /```tool\s*\n([\s\S]*?)\n```/g;
|
|
3129
|
-
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
3130
|
-
try {
|
|
3131
|
-
const fixed = fixJson(match[1]);
|
|
3132
|
-
const parsed = JSON.parse(fixed);
|
|
3133
|
-
if (parsed.tool && parsed.args) {
|
|
3134
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3135
|
-
calls.push(parsed);
|
|
3136
|
-
}
|
|
3137
|
-
}
|
|
3138
|
-
catch (e) {
|
|
3139
|
-
// Invalid JSON, skip
|
|
3140
|
-
}
|
|
3141
|
-
}
|
|
3142
|
-
// Match ```json blocks with tool definitions
|
|
3143
|
-
const jsonBlockRegex = /```(?:json)?\s*\n?([\s\S]*?"tool"[\s\S]*?)\n?```/g;
|
|
3144
|
-
while ((match = jsonBlockRegex.exec(text)) !== null) {
|
|
3145
|
-
try {
|
|
3146
|
-
const fixed = fixJson(match[1]);
|
|
3147
|
-
const parsed = JSON.parse(fixed);
|
|
3148
|
-
if (parsed.tool && parsed.args) {
|
|
3149
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3150
|
-
// Prevent duplicates
|
|
3151
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3152
|
-
calls.push(parsed);
|
|
3153
|
-
}
|
|
3154
|
-
}
|
|
3155
|
-
}
|
|
3156
|
-
catch (e) {
|
|
3157
|
-
// Invalid JSON, skip
|
|
3158
|
-
}
|
|
3159
|
-
}
|
|
3160
|
-
// ROBUST PARSER: Find {"tool": at any position and extract balanced JSON
|
|
3161
|
-
// This handles multi-line content in write_file and nested structures
|
|
3162
|
-
const toolMarkerRegex = /\{"tool"\s*:/g;
|
|
3163
|
-
while ((match = toolMarkerRegex.exec(text)) !== null) {
|
|
3164
|
-
const startIdx = match.index;
|
|
3165
|
-
const jsonStr = extractBalancedJson(text, startIdx);
|
|
3166
|
-
if (jsonStr) {
|
|
3167
|
-
try {
|
|
3168
|
-
const parsed = JSON.parse(jsonStr);
|
|
3169
|
-
if (parsed.tool && parsed.args) {
|
|
3170
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3171
|
-
// Prevent duplicates
|
|
3172
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3173
|
-
calls.push(parsed);
|
|
3174
|
-
}
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
catch (e) {
|
|
3178
|
-
// Invalid JSON, try to fix it
|
|
3179
|
-
try {
|
|
3180
|
-
const fixed = fixJson(jsonStr);
|
|
3181
|
-
const parsed = JSON.parse(fixed);
|
|
3182
|
-
if (parsed.tool && parsed.args) {
|
|
3183
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3184
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3185
|
-
calls.push(parsed);
|
|
3186
|
-
}
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
3189
|
-
catch (e2) {
|
|
3190
|
-
// Still invalid - try more aggressive fixing for write_file
|
|
3191
|
-
if (jsonStr.includes('"write_file"') || jsonStr.includes('"content"')) {
|
|
3192
|
-
try {
|
|
3193
|
-
// More aggressive: escape all control characters
|
|
3194
|
-
const aggressiveFix = jsonStr
|
|
3195
|
-
.replace(/[\x00-\x1F]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'))
|
|
3196
|
-
.replace(/'/g, '"');
|
|
3197
|
-
const parsed = JSON.parse(aggressiveFix);
|
|
3198
|
-
if (parsed.tool && parsed.args) {
|
|
3199
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3200
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3201
|
-
calls.push(parsed);
|
|
3202
|
-
}
|
|
3203
|
-
}
|
|
3204
|
-
}
|
|
3205
|
-
catch (e3) {
|
|
3206
|
-
// Still invalid, skip
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3211
|
-
}
|
|
3212
|
-
}
|
|
3213
|
-
// Match inline JSON with "tool" key (various formats - tool before args)
|
|
3214
|
-
const inlineToolRegex = /\{[^{}]*"?tool"?\s*:\s*["']?([^"',}]+)["']?[^{}]*"?args"?\s*:\s*\{([^{}]*)\}[^{}]*\}/gi;
|
|
3215
|
-
while ((match = inlineToolRegex.exec(text)) !== null) {
|
|
3216
|
-
try {
|
|
3217
|
-
const fixed = fixJson(match[0]);
|
|
3218
|
-
const parsed = JSON.parse(fixed);
|
|
3219
|
-
if (parsed.tool && parsed.args) {
|
|
3220
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3221
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3222
|
-
calls.push(parsed);
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
}
|
|
3226
|
-
catch (e) {
|
|
3227
|
-
// Invalid JSON, skip
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
// Match inline JSON with "args" BEFORE "tool" (handles reversed order from some AI models)
|
|
3231
|
-
const inlineArgsFirstRegex = /\{[^{}]*"?args"?\s*:\s*\{([^{}]*)\}[^{}]*"?tool"?\s*:\s*["']?([^"',}]+)["']?[^{}]*\}/gi;
|
|
3232
|
-
while ((match = inlineArgsFirstRegex.exec(text)) !== null) {
|
|
3233
|
-
try {
|
|
3234
|
-
const fixed = fixJson(match[0]);
|
|
3235
|
-
const parsed = JSON.parse(fixed);
|
|
3236
|
-
if (parsed.tool && parsed.args) {
|
|
3237
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3238
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3239
|
-
calls.push(parsed);
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
catch (e) {
|
|
3244
|
-
// Invalid JSON, skip
|
|
3245
|
-
}
|
|
3246
|
-
}
|
|
3247
|
-
// Universal: Try to find any JSON object with both "tool" and "args" keys
|
|
3248
|
-
const universalJsonRegex = /\{[^{}]*(?:"tool"|"args")[^{}]*(?:"tool"|"args")[^{}]*\}/gi;
|
|
3249
|
-
while ((match = universalJsonRegex.exec(text)) !== null) {
|
|
3250
|
-
try {
|
|
3251
|
-
const fixed = fixJson(match[0]);
|
|
3252
|
-
const parsed = JSON.parse(fixed);
|
|
3253
|
-
if (parsed.tool && parsed.args) {
|
|
3254
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3255
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3256
|
-
calls.push(parsed);
|
|
3257
|
-
}
|
|
3258
|
-
}
|
|
3259
|
-
}
|
|
3260
|
-
catch (e) {
|
|
3261
|
-
// Invalid JSON, skip
|
|
3262
|
-
}
|
|
3263
|
-
}
|
|
3264
|
-
// Fallback: Try to parse each line as JSON (for plain JSON output without code blocks)
|
|
3265
|
-
// This handles cases where AI outputs just: {"args": {...}, "tool": "..."}
|
|
3266
|
-
const lines = text.split('\n');
|
|
3267
|
-
for (const line of lines) {
|
|
3268
|
-
const trimmed = line.trim();
|
|
3269
|
-
if (trimmed.startsWith('{') && trimmed.includes('"tool"') && trimmed.includes('"args"')) {
|
|
3270
|
-
try {
|
|
3271
|
-
// Try to find balanced braces
|
|
3272
|
-
let braceCount = 0;
|
|
3273
|
-
let startIdx = -1;
|
|
3274
|
-
let endIdx = -1;
|
|
3275
|
-
for (let i = 0; i < trimmed.length; i++) {
|
|
3276
|
-
if (trimmed[i] === '{') {
|
|
3277
|
-
if (startIdx === -1)
|
|
3278
|
-
startIdx = i;
|
|
3279
|
-
braceCount++;
|
|
3280
|
-
}
|
|
3281
|
-
else if (trimmed[i] === '}') {
|
|
3282
|
-
braceCount--;
|
|
3283
|
-
if (braceCount === 0 && startIdx !== -1) {
|
|
3284
|
-
endIdx = i + 1;
|
|
3285
|
-
break;
|
|
3286
|
-
}
|
|
3287
|
-
}
|
|
3288
|
-
}
|
|
3289
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
3290
|
-
const jsonStr = trimmed.substring(startIdx, endIdx);
|
|
3291
|
-
const fixed = fixJson(jsonStr);
|
|
3292
|
-
const parsed = JSON.parse(fixed);
|
|
3293
|
-
if (parsed.tool && parsed.args) {
|
|
3294
|
-
parsed.tool = normalizeToolName(parsed.tool);
|
|
3295
|
-
if (!calls.some(c => c.tool === parsed.tool && JSON.stringify(c.args) === JSON.stringify(parsed.args))) {
|
|
3296
|
-
calls.push(parsed);
|
|
3297
|
-
}
|
|
3298
|
-
}
|
|
3299
|
-
}
|
|
3300
|
-
}
|
|
3301
|
-
catch (e) {
|
|
3302
|
-
// Invalid JSON, skip
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
}
|
|
3306
|
-
// Parse Vigthoria V2 format: {"tool": "__BASH__", ...}
|
|
3307
|
-
const vigV2Regex = /"?tool"?\s*:\s*["']__?([A-Za-z_]+)__?["']/gi;
|
|
3308
|
-
while ((match = vigV2Regex.exec(text)) !== null) {
|
|
3309
|
-
try {
|
|
3310
|
-
const toolName = normalizeToolName(match[1]);
|
|
3311
|
-
// Extract args from nearby context
|
|
3312
|
-
const pathMatch = text.match(/"?(?:arg_)?path"?\s*:\s*["']([^"']+)["']/i);
|
|
3313
|
-
const cmdMatch = text.match(/"?command"?\s*:\s*(?:["']([^"']+)["']|\[\s*["']([^"']+)["']\s*\])/i);
|
|
3314
|
-
const contentMatch = text.match(/"?content"?\s*:\s*["']([^"']+)["']/i);
|
|
3315
|
-
const args = {};
|
|
3316
|
-
if (pathMatch)
|
|
3317
|
-
args.path = pathMatch[1];
|
|
3318
|
-
if (cmdMatch)
|
|
3319
|
-
args.command = cmdMatch[1] || cmdMatch[2];
|
|
3320
|
-
if (contentMatch)
|
|
3321
|
-
args.content = contentMatch[1];
|
|
3322
|
-
if (Object.keys(args).length > 0) {
|
|
3323
|
-
// Prevent duplicates
|
|
3324
|
-
if (!calls.some(c => c.tool === toolName && JSON.stringify(c.args) === JSON.stringify(args))) {
|
|
3325
|
-
calls.push({ tool: toolName, args });
|
|
3326
|
-
}
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
catch (e) {
|
|
3330
|
-
// Skip
|
|
3331
|
-
}
|
|
3332
|
-
}
|
|
3333
|
-
return calls;
|
|
3334
|
-
}
|
|
3335
|
-
/**
|
|
3336
|
-
* Get tools formatted for AI system prompt
|
|
3337
|
-
*/
|
|
3338
|
-
static getToolsForPrompt() {
|
|
3339
|
-
const tools = AgenticTools.getToolDefinitions();
|
|
3340
|
-
let prompt = `## AGENT MODE - YOU MUST USE TOOLS
|
|
3341
|
-
|
|
3342
|
-
⚠️ CRITICAL: You are in Agent Mode. You MUST use tools to interact with files and the system.
|
|
3343
|
-
DO NOT guess or hallucinate file contents. DO NOT make up directory structures.
|
|
3344
|
-
ALWAYS use read_file before discussing what's in a file.
|
|
3345
|
-
ALWAYS use list_dir before discussing what's in a directory.
|
|
3346
|
-
DO NOT ask the user to pick between actions you can already perform.
|
|
3347
|
-
WHEN A FILE IS BROKEN, INCOMPLETE, OR TRUNCATED: inspect it fully, fix it directly, and return the completed result.
|
|
3348
|
-
NEVER write placeholders like "rest of CSS", "implementation goes here", or incomplete HTML/JS fragments.
|
|
3349
|
-
IF STRUCTURE IS BROKEN: prefer rewriting the full file with write_file after reading enough context.
|
|
3350
|
-
|
|
3351
|
-
You have access to these tools:
|
|
3352
|
-
|
|
3353
|
-
`;
|
|
3354
|
-
for (const tool of tools) {
|
|
3355
|
-
prompt += `## ${tool.name}
|
|
3356
|
-
${tool.description}
|
|
3357
|
-
Parameters:
|
|
3358
|
-
${tool.parameters.map(p => ` - ${p.name}${p.required ? ' (required)' : ''}: ${p.description}`).join('\n')}
|
|
3359
|
-
|
|
3360
|
-
`;
|
|
3361
|
-
}
|
|
3362
|
-
prompt += `
|
|
3363
|
-
## How to Use Tools
|
|
3364
|
-
|
|
3365
|
-
To use a tool, output a JSON block in a code fence with "tool" language:
|
|
3366
|
-
|
|
3367
|
-
\`\`\`tool
|
|
3368
|
-
{"tool": "tool_name", "args": {"param1": "value1"}}
|
|
3369
|
-
\`\`\`
|
|
3370
|
-
|
|
3371
|
-
## MANDATORY TOOL USAGE (DO NOT SKIP!)
|
|
3372
|
-
|
|
3373
|
-
**When user asks "what's in this folder/directory":**
|
|
3374
|
-
→ FIRST use list_dir, THEN respond based on actual results
|
|
3375
|
-
|
|
3376
|
-
**When user asks "can you see/read/show me file X":**
|
|
3377
|
-
→ FIRST use read_file, THEN respond based on actual contents
|
|
3378
|
-
|
|
3379
|
-
**When user mentions a path like "C:\\some\\path" or "/some/path":**
|
|
3380
|
-
→ FIRST use list_dir to check if it exists, THEN respond
|
|
3381
|
-
|
|
3382
|
-
**NEVER say things like "Here's an overview of the contents..." without first using tools!**
|
|
3383
|
-
**NEVER describe files or code you haven't actually read with read_file!**
|
|
3384
|
-
|
|
3385
|
-
### Examples:
|
|
3386
|
-
|
|
3387
|
-
1. List directory contents:
|
|
3388
|
-
\`\`\`tool
|
|
3389
|
-
{"tool": "list_dir", "args": {"path": "."}}
|
|
3390
|
-
\`\`\`
|
|
3391
|
-
|
|
3392
|
-
2. Read a file:
|
|
3393
|
-
\`\`\`tool
|
|
3394
|
-
{"tool": "read_file", "args": {"path": "src/index.js"}}
|
|
3395
|
-
\`\`\`
|
|
3396
|
-
|
|
3397
|
-
3. Fetch a web page (full HTML):
|
|
3398
|
-
\`\`\`tool
|
|
3399
|
-
{"tool": "fetch_url", "args": {"url": "https://example.com"}}
|
|
3400
|
-
\`\`\`
|
|
3401
|
-
|
|
3402
|
-
4. Fetch specific content with selectors:
|
|
3403
|
-
\`\`\`tool
|
|
3404
|
-
{"tool": "fetch_url", "args": {"url": "https://example.com", "selector": "h1, h2, h3"}}
|
|
3405
|
-
\`\`\`
|
|
3406
|
-
Available selectors: title, body, text, links, nav, footer, images, h1, h2, h3, h1/h2/h3 (compound)
|
|
3407
|
-
|
|
3408
|
-
5. Run command on YOUR configured SSH server (requires user SSH setup, NOT Vigthoria servers):
|
|
3409
|
-
\`\`\`tool
|
|
3410
|
-
{"tool": "ssh_exec", "args": {"command": "curl -s https://example.com | head -20", "host": "your-server"}}
|
|
3411
|
-
\`\`\`
|
|
3412
|
-
Note: ssh_exec is for users who have their own servers configured. It does NOT connect to Vigthoria infrastructure.
|
|
3413
|
-
|
|
3414
|
-
6. Write a file:
|
|
3415
|
-
\`\`\`tool
|
|
3416
|
-
{"tool": "write_file", "args": {"path": "hello.py", "content": "print('Hello World')"}}
|
|
3417
|
-
\`\`\`
|
|
3418
|
-
|
|
3419
|
-
## CRITICAL RULES:
|
|
3420
|
-
|
|
3421
|
-
### ABSOLUTELY NO HALLUCINATION (READ THIS CAREFULLY):
|
|
3422
|
-
- ONLY use information from tool results you just received in THIS conversation
|
|
3423
|
-
- DO NOT make up names, organizations, or content that wasn't in the fetched data
|
|
3424
|
-
- DO NOT read random files from the workspace expecting them to contain relevant data
|
|
3425
|
-
- If you fetch a website, your analysis MUST be based ONLY on what you just fetched
|
|
3426
|
-
- If the user asks about websites A and B, ONLY discuss A and B - don't invent Site C
|
|
3427
|
-
- NEVER create fictional organizations, services, or content
|
|
3428
|
-
- If you're unsure about something, say "Based on the fetched content, I can see..." not "This organization does..."
|
|
3429
|
-
|
|
3430
|
-
### Strategic Planning (VERY IMPORTANT):
|
|
3431
|
-
- PLAN before acting - don't issue many redundant tool calls
|
|
3432
|
-
- For web comparisons: Fetch each URL ONCE with no selector to get full HTML, then analyze locally
|
|
3433
|
-
- Do NOT fetch the same URL multiple times with different selectors - fetch once and parse the result
|
|
3434
|
-
- Think step-by-step: 1) Gather data, 2) Analyze it, 3) Present findings
|
|
3435
|
-
- If comparing two things, fetch both ONCE, then write a REAL comparison with specific differences
|
|
3436
|
-
- Maximum 2-4 tool calls per step is usually enough - don't spam 10+ calls at once
|
|
3437
|
-
- Do NOT use list_dir or read_file when the task is about fetching websites - those are for LOCAL files only
|
|
3438
|
-
|
|
3439
|
-
### Cross-Platform Compatibility:
|
|
3440
|
-
- On Windows, Unix commands (head, tail, grep, awk, sed, wc) are NOT available
|
|
3441
|
-
- Use \`fetch_url\` for web requests instead of curl|grep
|
|
3442
|
-
- Use \`ssh_exec\` to run Unix commands on the Vigthoria server if needed
|
|
3443
|
-
- Use \`read_file\` instead of cat
|
|
3444
|
-
- Use \`list_dir\` instead of ls
|
|
3445
|
-
|
|
3446
|
-
### Handling Tool Failures:
|
|
3447
|
-
- If a tool fails, REPORT the failure honestly to the user
|
|
3448
|
-
- NEVER make up or hallucinate content when a tool fails
|
|
3449
|
-
- If you cannot access a URL, say "I was unable to fetch the URL because..."
|
|
3450
|
-
- If a command fails, explain what happened and suggest alternatives
|
|
3451
|
-
- Do NOT write analysis or comparison reports if you couldn't gather the actual data
|
|
3452
|
-
|
|
3453
|
-
### Comparison Tasks:
|
|
3454
|
-
- When asked to compare websites/files, you MUST produce actual specific differences
|
|
3455
|
-
- Don't just describe each site separately - show what Site A has that Site B is missing
|
|
3456
|
-
- Use concrete examples: "Site A has a Team page at /team.html, Site B does not"
|
|
3457
|
-
- If you need sub-pages, fetch them in follow-up steps after analyzing the main page
|
|
3458
|
-
- BASE YOUR COMPARISON ONLY ON THE DATA YOU FETCHED - not on random files in the workspace
|
|
3459
|
-
- Quote actual text from the fetched content to support your claims
|
|
3460
|
-
|
|
3461
|
-
### File Access:
|
|
3462
|
-
- You can ONLY access files within the current project workspace
|
|
3463
|
-
- Use relative paths (e.g., "src/file.js", "app.py", "./config.json")
|
|
3464
|
-
- Never try to access system files or directories outside the workspace
|
|
3465
|
-
- Do NOT read files unrelated to the user's request (e.g., don't read "comparison.md" when asked to fetch websites)
|
|
3466
|
-
- When comparing WEBSITES, use fetch_url - do NOT use read_file or list_dir
|
|
3467
|
-
|
|
3468
|
-
### Tool Names:
|
|
3469
|
-
- Use ONLY these exact tool names: list_dir, read_file, write_file, edit_file, bash, grep, glob, git, repo, fetch_url, ssh_exec, task, multi_edit, codebase_search
|
|
3470
|
-
- The JSON must be valid with double quotes for all keys and string values
|
|
3471
|
-
- After tool execution, you will receive results and can continue with the next step
|
|
3472
|
-
- Explain what you're doing before using tools
|
|
3473
|
-
|
|
3474
|
-
### Sub-Agent Delegation (task tool):
|
|
3475
|
-
- Use the task tool to delegate complex subtasks to an independent sub-agent
|
|
3476
|
-
- The sub-agent has its own tools and context, runs autonomously, and returns results
|
|
3477
|
-
- Great for: parallel research, investigating side questions, exploring unfamiliar code
|
|
3478
|
-
- Example:
|
|
3479
|
-
\`\`\`tool
|
|
3480
|
-
{"tool": "task", "args": {"description": "Find all API endpoints in the backend and list their HTTP methods and paths"}}
|
|
3481
|
-
\`\`\`
|
|
3482
|
-
|
|
3483
|
-
### Atomic Multi-File Edits (multi_edit tool):
|
|
3484
|
-
- Use multi_edit to apply multiple edits atomically - all succeed or all roll back
|
|
3485
|
-
- Pass edits as a JSON array in the edits parameter
|
|
3486
|
-
- Example:
|
|
3487
|
-
\`\`\`tool
|
|
3488
|
-
{"tool": "multi_edit", "args": {"edits": "[{\\"path\\": \\"src/config.ts\\", \\"old_text\\": \\"port: 3000\\", \\"new_text\\": \\"port: 8080\\"}, {\\"path\\": \\"src/server.ts\\", \\"old_text\\": \\"listen(3000)\\", \\"new_text\\": \\"listen(8080)\\"}]"}}
|
|
3489
|
-
\`\`\`
|
|
3490
|
-
|
|
3491
|
-
### Deep Codebase Search (codebase_search tool):
|
|
3492
|
-
- Use codebase_search for large projects to find symbols, files, or content across the ENTIRE codebase
|
|
3493
|
-
- Not limited by workspace snapshot - searches all files recursively
|
|
3494
|
-
- Scopes: "symbols" (functions/classes), "files" (filenames), "content" (full-text), "all" (default)
|
|
3495
|
-
- Example:
|
|
3496
|
-
\`\`\`tool
|
|
3497
|
-
{"tool": "codebase_search", "args": {"query": "handleAuthentication", "scope": "symbols"}}
|
|
3498
|
-
\`\`\`
|
|
3499
|
-
`;
|
|
3500
|
-
return prompt;
|
|
3501
|
-
}
|
|
3502
|
-
}
|