dave-code 1.0.4 → 1.1.0
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/README.md +16 -1
- package/bin/aiClient.js +658 -164
- package/bin/cliMenu.js +127 -185
- package/bin/commandRouter.js +50 -0
- package/bin/configManager.js +85 -20
- package/bin/contextManager.js +151 -0
- package/bin/index.js +809 -449
- package/bin/planManager.js +239 -0
- package/bin/runtimeEvents.js +62 -0
- package/bin/sessionManager.js +166 -0
- package/bin/terminalRenderer.js +283 -0
- package/bin/toolRuntime.js +1052 -0
- package/package.json +5 -2
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
import { exec as execCommand } from 'child_process';
|
|
6
|
+
|
|
7
|
+
export const SUPPORTED_TOOLS = new Set([
|
|
8
|
+
'LIST_DIR',
|
|
9
|
+
'READ_FILE',
|
|
10
|
+
'SEARCH_GREP',
|
|
11
|
+
'EDIT_FILE',
|
|
12
|
+
'WRITE_FILE',
|
|
13
|
+
'MAKE_DIR',
|
|
14
|
+
'MOVE_PATH',
|
|
15
|
+
'DELETE_PATH',
|
|
16
|
+
'RUN_COMMAND'
|
|
17
|
+
]);
|
|
18
|
+
export const MUTATING_TOOLS = new Set([
|
|
19
|
+
'EDIT_FILE', 'WRITE_FILE', 'MAKE_DIR', 'MOVE_PATH', 'DELETE_PATH', 'RUN_COMMAND'
|
|
20
|
+
]);
|
|
21
|
+
export const TOOL_DEFINITIONS = [
|
|
22
|
+
{ name: 'LIST_DIR', description: 'List one directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
23
|
+
{ name: 'READ_FILE', description: 'Read a UTF-8 text file or an inclusive line range.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, startLine: { type: 'integer', minimum: 1 }, endLine: { type: 'integer', minimum: 1 } }, required: ['path'], additionalProperties: false } },
|
|
24
|
+
{ name: 'SEARCH_GREP', description: 'Search for an exact text string in workspace text files.', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], additionalProperties: false } },
|
|
25
|
+
{ name: 'EDIT_FILE', description: 'Replace exact, unique text in an existing file.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'], additionalProperties: false } },
|
|
26
|
+
{ name: 'WRITE_FILE', description: 'Create or completely replace a UTF-8 text file.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false } },
|
|
27
|
+
{ name: 'MAKE_DIR', description: 'Create a directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
28
|
+
{ name: 'MOVE_PATH', description: 'Move or rename a workspace path.', inputSchema: { type: 'object', properties: { source: { type: 'string' }, destination: { type: 'string' } }, required: ['source', 'destination'], additionalProperties: false } },
|
|
29
|
+
{ name: 'DELETE_PATH', description: 'Delete a file or an empty directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
30
|
+
{ name: 'RUN_COMMAND', description: 'Run a command from the workspace root after user confirmation.', inputSchema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false } }
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function getToolDefinitions(mode = 'chat') {
|
|
34
|
+
return TOOL_DEFINITIONS.filter(tool => mode === 'code' || !MUTATING_TOOLS.has(tool.name));
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_TEXT_EXTS = new Set([
|
|
37
|
+
'.js', '.json', '.txt', '.md', '.html', '.css', '.yml', '.yaml',
|
|
38
|
+
'.sh', '.bat', '.cmd', '.py', '.cpp', '.h', '.c', '.go', '.rs',
|
|
39
|
+
'.java', '.ts', '.tsx', '.jsx'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function decodeEntities(value) {
|
|
43
|
+
return String(value || '')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/&/g, '&');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildEditArgument(filePath, oldText, newText, replaceAll = false) {
|
|
51
|
+
return `${filePath}\n<<<SEARCH\n${oldText}\n===REPLACE\n${newText}\n>>>END${replaceAll ? '\nALL' : ''}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseDSMLToolCall(text) {
|
|
55
|
+
const trimmed = String(text || '').trim();
|
|
56
|
+
if (!/^<||DSML||tool_calls>[\s\S]*<\/||DSML||tool_calls>$/.test(trimmed)) return null;
|
|
57
|
+
const invokePattern = /<||DSML||invoke\s+name="([^"]+)">([\s\S]*?)<\/||DSML||invoke>/g;
|
|
58
|
+
const invokes = [...trimmed.matchAll(invokePattern)];
|
|
59
|
+
if (invokes.length === 0) return null;
|
|
60
|
+
if (invokes.length !== 1) {
|
|
61
|
+
return { error: 'Tool parse error: exactly one DSML invocation is allowed.' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const invoke = invokes[0];
|
|
65
|
+
const sourceName = invoke[1];
|
|
66
|
+
const body = invoke[2];
|
|
67
|
+
const params = {};
|
|
68
|
+
const parameterPattern = /<||DSML||parameter\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/||DSML||parameter>/g;
|
|
69
|
+
for (const match of body.matchAll(parameterPattern)) {
|
|
70
|
+
params[match[1]] = decodeEntities(match[2]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const key = sourceName.toLowerCase();
|
|
74
|
+
const filePath = String(params.filePath || params.path || params.file_path || '').trim();
|
|
75
|
+
let toolName;
|
|
76
|
+
let toolArg;
|
|
77
|
+
|
|
78
|
+
if (key === 'read') {
|
|
79
|
+
toolName = 'READ_FILE';
|
|
80
|
+
const offset = Math.max(1, Number.parseInt(params.offset || '1', 10) || 1);
|
|
81
|
+
const limit = Math.max(1, Number.parseInt(params.limit || '0', 10) || 0);
|
|
82
|
+
toolArg = limit ? `${filePath}:${offset}-${offset + limit - 1}` : filePath;
|
|
83
|
+
} else if (key === 'write') {
|
|
84
|
+
toolName = 'WRITE_FILE';
|
|
85
|
+
toolArg = `${filePath}\n${params.content || params.text || ''}`;
|
|
86
|
+
} else if (key === 'edit' || key === 'strreplace') {
|
|
87
|
+
toolName = 'EDIT_FILE';
|
|
88
|
+
toolArg = buildEditArgument(
|
|
89
|
+
filePath,
|
|
90
|
+
params.oldString || params.old_string || params.oldText || '',
|
|
91
|
+
params.newString || params.new_string || params.newText || '',
|
|
92
|
+
String(params.replaceAll || params.replace_all || '').toLowerCase() === 'true'
|
|
93
|
+
);
|
|
94
|
+
} else if (key === 'bash' || key === 'shell' || key === 'runcommand') {
|
|
95
|
+
toolName = 'RUN_COMMAND';
|
|
96
|
+
toolArg = params.command || params.cmd || '';
|
|
97
|
+
} else if (key === 'grep' || key === 'search') {
|
|
98
|
+
toolName = 'SEARCH_GREP';
|
|
99
|
+
toolArg = params.pattern || params.query || params.text || '';
|
|
100
|
+
} else if (key === 'glob' || key === 'list' || key === 'listdir') {
|
|
101
|
+
toolName = 'LIST_DIR';
|
|
102
|
+
toolArg = params.path || params.directory || '.';
|
|
103
|
+
} else if (key === 'delete' || key === 'remove') {
|
|
104
|
+
toolName = 'DELETE_PATH';
|
|
105
|
+
toolArg = filePath;
|
|
106
|
+
} else if (key === 'move' || key === 'rename') {
|
|
107
|
+
toolName = 'MOVE_PATH';
|
|
108
|
+
toolArg = `${params.source || params.from || filePath}\n${params.destination || params.to || ''}`;
|
|
109
|
+
} else if (key === 'mkdir' || key === 'makedir') {
|
|
110
|
+
toolName = 'MAKE_DIR';
|
|
111
|
+
toolArg = filePath || params.directory || '';
|
|
112
|
+
} else {
|
|
113
|
+
return { error: `Tool parse error: unsupported DSML tool "${sourceName}".` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
toolName,
|
|
118
|
+
toolArg,
|
|
119
|
+
match: invoke,
|
|
120
|
+
recovered: true,
|
|
121
|
+
sourceFormat: 'dsml',
|
|
122
|
+
remainingCalls: 0,
|
|
123
|
+
thoughtText: ''
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createToolTagStreamFilter() {
|
|
128
|
+
let mode = 'pending';
|
|
129
|
+
let buffer = '';
|
|
130
|
+
|
|
131
|
+
function push(chunk) {
|
|
132
|
+
const text = String(chunk || '');
|
|
133
|
+
if (!text) return '';
|
|
134
|
+
if (mode === 'text') return text;
|
|
135
|
+
if (mode === 'reasoning') {
|
|
136
|
+
buffer += text;
|
|
137
|
+
const closeIndex = buffer.toLowerCase().indexOf('</think>');
|
|
138
|
+
if (closeIndex === -1) return '';
|
|
139
|
+
const remainder = buffer.slice(closeIndex + 8);
|
|
140
|
+
buffer = '';
|
|
141
|
+
mode = 'pending';
|
|
142
|
+
return push(remainder);
|
|
143
|
+
}
|
|
144
|
+
buffer += text;
|
|
145
|
+
const trimmed = buffer.trimStart();
|
|
146
|
+
const lower = trimmed.toLowerCase();
|
|
147
|
+
const upper = trimmed.toUpperCase();
|
|
148
|
+
|
|
149
|
+
if (!trimmed) return '';
|
|
150
|
+
if (lower.startsWith('<think>')) {
|
|
151
|
+
mode = 'reasoning';
|
|
152
|
+
const closeIndex = lower.indexOf('</think>');
|
|
153
|
+
if (closeIndex !== -1) {
|
|
154
|
+
const remainder = trimmed.slice(closeIndex + 8);
|
|
155
|
+
buffer = '';
|
|
156
|
+
mode = 'pending';
|
|
157
|
+
return push(remainder);
|
|
158
|
+
}
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
if ('<think>'.startsWith(lower) || 'reasoning_content:'.startsWith(lower)) return '';
|
|
162
|
+
if (lower.startsWith('reasoning_content:')) {
|
|
163
|
+
mode = 'reasoning';
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
const toolPrefixes = Array.from(SUPPORTED_TOOLS).map(tool => `<<${tool}:`);
|
|
167
|
+
if (toolPrefixes.some(prefix => prefix.startsWith(upper))) return '';
|
|
168
|
+
if (toolPrefixes.some(prefix => upper.startsWith(prefix))) {
|
|
169
|
+
mode = 'tool';
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
if (trimmed.startsWith('<<')) {
|
|
173
|
+
if (/^<<[A-Z_]+\s*:/.test(trimmed)) mode = 'tool';
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
if ('<<'.startsWith(trimmed)) return '';
|
|
177
|
+
|
|
178
|
+
mode = 'text';
|
|
179
|
+
const output = buffer;
|
|
180
|
+
buffer = '';
|
|
181
|
+
return output;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function finish() {
|
|
185
|
+
const trimmed = buffer.trimStart().toLowerCase();
|
|
186
|
+
if (mode === 'pending' && !trimmed.startsWith('<<') && !trimmed.startsWith('<think') && !trimmed.startsWith('reasoning_content:')) {
|
|
187
|
+
mode = 'text';
|
|
188
|
+
const output = buffer;
|
|
189
|
+
buffer = '';
|
|
190
|
+
return output;
|
|
191
|
+
}
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
push,
|
|
197
|
+
finish,
|
|
198
|
+
isTool: () => mode === 'tool' || /^<<[A-Z_]+\s*:/.test(buffer.trimStart())
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function parseToolCall(reply = '') {
|
|
203
|
+
const text = String(reply).trim();
|
|
204
|
+
if (text.startsWith('<||DSML||tool_calls>')) {
|
|
205
|
+
const dsml = parseDSMLToolCall(text);
|
|
206
|
+
if (dsml) return dsml;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!text.startsWith('<<')) return null;
|
|
210
|
+
const match = text.match(/^<<([A-Z_]+):\s*([\s\S]*)>>$/);
|
|
211
|
+
if (!match) return { error: 'Tool parse error: legacy calls must occupy the complete message as <<TOOL_NAME: arguments>>.' };
|
|
212
|
+
if (/<<[A-Z_]+\s*:/.test(match[2])) {
|
|
213
|
+
return { error: 'Tool parse error: exactly one legacy tool call is allowed.' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const toolName = match[1];
|
|
217
|
+
if (!SUPPORTED_TOOLS.has(toolName)) {
|
|
218
|
+
return {
|
|
219
|
+
error: `Tool parse error: unsupported tool "${toolName}". Supported tools: ${Array.from(SUPPORTED_TOOLS).join(', ')}.`
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
toolName,
|
|
225
|
+
toolArg: match[2],
|
|
226
|
+
match,
|
|
227
|
+
recovered: false,
|
|
228
|
+
thoughtText: ''
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function isInsidePath(childPath, rootPath) {
|
|
233
|
+
const relative = path.relative(rootPath, childPath);
|
|
234
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function canonicalExistingPath(targetPath) {
|
|
238
|
+
return fs.realpathSync.native(targetPath);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function canonicalRoot(workspaceRoot) {
|
|
242
|
+
const root = canonicalExistingPath(path.resolve(workspaceRoot));
|
|
243
|
+
return process.platform === 'win32' ? root.toLowerCase() : root;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function comparablePath(value) {
|
|
247
|
+
const normalized = path.resolve(value);
|
|
248
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function nearestExistingAncestor(targetPath) {
|
|
252
|
+
let current = path.resolve(targetPath);
|
|
253
|
+
while (!fs.existsSync(current)) {
|
|
254
|
+
const parent = path.dirname(current);
|
|
255
|
+
if (parent === current) break;
|
|
256
|
+
current = parent;
|
|
257
|
+
}
|
|
258
|
+
return current;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function resolveWorkspacePath(rawPath, workspaceRoot) {
|
|
262
|
+
const trimmed = String(rawPath || '').trim();
|
|
263
|
+
if (!trimmed) {
|
|
264
|
+
throw new Error('Path is empty.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const resolved = path.isAbsolute(trimmed)
|
|
268
|
+
? path.resolve(trimmed)
|
|
269
|
+
: path.resolve(workspaceRoot, trimmed);
|
|
270
|
+
const root = path.resolve(workspaceRoot);
|
|
271
|
+
|
|
272
|
+
if (!isInsidePath(resolved, root)) {
|
|
273
|
+
throw new Error(`Access outside workspace is blocked: ${trimmed}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const realRoot = canonicalRoot(workspaceRoot);
|
|
277
|
+
const ancestor = nearestExistingAncestor(resolved);
|
|
278
|
+
const realAncestor = comparablePath(canonicalExistingPath(ancestor));
|
|
279
|
+
if (!isInsidePath(realAncestor, realRoot)) {
|
|
280
|
+
throw new Error(`Workspace path resolves through a link outside the workspace: ${trimmed}`);
|
|
281
|
+
}
|
|
282
|
+
if (fs.existsSync(resolved)) {
|
|
283
|
+
const realTarget = comparablePath(canonicalExistingPath(resolved));
|
|
284
|
+
if (!isInsidePath(realTarget, realRoot)) {
|
|
285
|
+
throw new Error(`Workspace path resolves outside the workspace: ${trimmed}`);
|
|
286
|
+
}
|
|
287
|
+
return resolved;
|
|
288
|
+
}
|
|
289
|
+
return resolved;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function resolveExistingWorkspacePath(rawPath, workspaceRoot) {
|
|
293
|
+
const value = String(rawPath || '').trim();
|
|
294
|
+
let resolved;
|
|
295
|
+
let originalError;
|
|
296
|
+
try {
|
|
297
|
+
resolved = resolveWorkspacePath(value, workspaceRoot);
|
|
298
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
originalError = error;
|
|
301
|
+
}
|
|
302
|
+
const fileName = value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1);
|
|
303
|
+
const suggestions = [];
|
|
304
|
+
if (fileName && !['.', '..'].includes(fileName)) {
|
|
305
|
+
const visit = (directory, depth) => {
|
|
306
|
+
if (depth > 6 || suggestions.length >= 5) return;
|
|
307
|
+
let entries = [];
|
|
308
|
+
try {
|
|
309
|
+
entries = fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
310
|
+
} catch {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
if (suggestions.length >= 5) break;
|
|
315
|
+
if (['node_modules', '.git', '.gemini'].includes(entry.name)) continue;
|
|
316
|
+
const fullPath = path.join(directory, entry.name);
|
|
317
|
+
if (entry.isDirectory()) visit(fullPath, depth + 1);
|
|
318
|
+
else if (entry.isFile() && entry.name === fileName) suggestions.push(path.relative(workspaceRoot, fullPath));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
visit(workspaceRoot, 0);
|
|
322
|
+
}
|
|
323
|
+
const hint = suggestions.length
|
|
324
|
+
? ` Candidate path${suggestions.length === 1 ? '' : 's'}: ${suggestions.join(', ')}. Retry with one exact workspace-relative path.`
|
|
325
|
+
: '';
|
|
326
|
+
if (originalError) throw new Error(`${originalError.message}${hint}`);
|
|
327
|
+
throw new Error(`Path does not exist inside workspace: ${value}.${hint}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function isSensitivePath(filePath) {
|
|
331
|
+
const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
|
|
332
|
+
const base = path.posix.basename(normalized);
|
|
333
|
+
return base === '.env' || base.startsWith('.env.') ||
|
|
334
|
+
['.npmrc', '.netrc'].includes(base) || base.startsWith('credentials') || base.startsWith('secrets') ||
|
|
335
|
+
/^(id_rsa|id_ed25519)(\.|$)/.test(base) ||
|
|
336
|
+
/\.(pem|key|p12|pfx|jks|keystore|crt|cer)$/.test(base) ||
|
|
337
|
+
normalized.split('/').includes('.git');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function contentHash(content) {
|
|
341
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function looksBinary(filePath) {
|
|
345
|
+
const handle = await fs.promises.open(filePath, 'r');
|
|
346
|
+
try {
|
|
347
|
+
const buffer = Buffer.alloc(8192);
|
|
348
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
349
|
+
return buffer.subarray(0, bytesRead).includes(0);
|
|
350
|
+
} finally {
|
|
351
|
+
await handle.close();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function readLineRange(filePath, startLine, endLine) {
|
|
356
|
+
const input = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
357
|
+
const reader = readline.createInterface({ input, crlfDelay: Infinity });
|
|
358
|
+
const lines = [];
|
|
359
|
+
let current = 0;
|
|
360
|
+
try {
|
|
361
|
+
for await (const line of reader) {
|
|
362
|
+
current++;
|
|
363
|
+
if (current >= startLine && current <= endLine) lines.push(line);
|
|
364
|
+
if (current >= endLine) break;
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
reader.close();
|
|
368
|
+
input.destroy();
|
|
369
|
+
}
|
|
370
|
+
return { content: lines.join('\n'), lineCount: lines.length };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function truncateToolResult(text, maxChars = 12000) {
|
|
374
|
+
const clean = String(text || '');
|
|
375
|
+
if (clean.length <= maxChars) return clean;
|
|
376
|
+
return `${clean.slice(0, maxChars)}\n\n[Tool result truncated: ${clean.length - maxChars} characters omitted. Use narrower search terms or READ_FILE line ranges.]`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseReadArgument(toolArg) {
|
|
380
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
381
|
+
const startLine = Math.max(1, Number.parseInt(toolArg.startLine ?? toolArg.start_line ?? '1', 10) || 1);
|
|
382
|
+
const rawEnd = toolArg.endLine ?? toolArg.end_line;
|
|
383
|
+
const endLine = rawEnd === undefined || rawEnd === null ? null : Math.max(1, Number.parseInt(rawEnd, 10) || 1);
|
|
384
|
+
if (endLine !== null && endLine < startLine) {
|
|
385
|
+
throw new Error('Invalid line range: end line must be greater than or equal to start line.');
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
filePath: String(toolArg.path ?? toolArg.filePath ?? ''),
|
|
389
|
+
startLine,
|
|
390
|
+
endLine,
|
|
391
|
+
hasRange: endLine !== null
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
let filePath = String(toolArg || '').trim();
|
|
395
|
+
let startLine = 1;
|
|
396
|
+
let endLine = null;
|
|
397
|
+
const rangeMatch = filePath.match(/:(\d+)-(\d+)$/);
|
|
398
|
+
if (rangeMatch) {
|
|
399
|
+
filePath = filePath.slice(0, -rangeMatch[0].length);
|
|
400
|
+
startLine = parseInt(rangeMatch[1], 10);
|
|
401
|
+
endLine = parseInt(rangeMatch[2], 10);
|
|
402
|
+
}
|
|
403
|
+
if (endLine !== null && endLine < startLine) {
|
|
404
|
+
throw new Error('Invalid line range: end line must be greater than or equal to start line.');
|
|
405
|
+
}
|
|
406
|
+
return { filePath, startLine, endLine, hasRange: !!rangeMatch };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function buildWritePreview(filePath, oldContent, newContent, existed) {
|
|
410
|
+
const relName = path.basename(filePath);
|
|
411
|
+
if (!existed) {
|
|
412
|
+
return [
|
|
413
|
+
`Create ${relName}`,
|
|
414
|
+
previewLines(newContent, '+')
|
|
415
|
+
].join('\n');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (oldContent === newContent) {
|
|
419
|
+
return `${relName}: no content changes.`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const oldLines = oldContent.split('\n');
|
|
423
|
+
const newLines = newContent.split('\n');
|
|
424
|
+
let prefix = 0;
|
|
425
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) {
|
|
426
|
+
prefix++;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let suffix = 0;
|
|
430
|
+
while (
|
|
431
|
+
suffix + prefix < oldLines.length &&
|
|
432
|
+
suffix + prefix < newLines.length &&
|
|
433
|
+
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
434
|
+
) {
|
|
435
|
+
suffix++;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const oldChanged = oldLines.slice(prefix, oldLines.length - suffix);
|
|
439
|
+
const newChanged = newLines.slice(prefix, newLines.length - suffix);
|
|
440
|
+
const beforeLine = Math.max(1, prefix + 1);
|
|
441
|
+
const chunks = [
|
|
442
|
+
`Update ${relName}`,
|
|
443
|
+
`Changed around line ${beforeLine}:`
|
|
444
|
+
];
|
|
445
|
+
chunks.push(previewLines(oldChanged.join('\n'), '-', 40));
|
|
446
|
+
chunks.push(previewLines(newChanged.join('\n'), '+', 40));
|
|
447
|
+
return chunks.filter(Boolean).join('\n');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function previewLines(text, marker, maxLines = 60) {
|
|
451
|
+
const lines = String(text || '').split('\n');
|
|
452
|
+
const shown = lines.slice(0, maxLines).map(line => `${marker} ${line}`).join('\n');
|
|
453
|
+
if (lines.length <= maxLines) return shown;
|
|
454
|
+
return `${shown}\n... ${lines.length - maxLines} more lines omitted`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseEditArgument(toolArg) {
|
|
458
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
459
|
+
return {
|
|
460
|
+
filePath: toolArg.path || toolArg.filePath || '',
|
|
461
|
+
oldText: toolArg.oldText ?? toolArg.oldString ?? '',
|
|
462
|
+
newText: toolArg.newText ?? toolArg.newString ?? '',
|
|
463
|
+
replaceAll: toolArg.replaceAll === true
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const raw = String(toolArg || '');
|
|
467
|
+
if (raw.trimStart().startsWith('{')) {
|
|
468
|
+
const parsed = JSON.parse(raw);
|
|
469
|
+
return {
|
|
470
|
+
filePath: parsed.path || parsed.filePath || '',
|
|
471
|
+
oldText: parsed.oldText ?? parsed.oldString ?? '',
|
|
472
|
+
newText: parsed.newText ?? parsed.newString ?? '',
|
|
473
|
+
replaceAll: parsed.replaceAll === true
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
478
|
+
const firstNewline = normalized.indexOf('\n');
|
|
479
|
+
if (firstNewline === -1) throw new Error('EDIT_FILE requires a path and SEARCH/REPLACE blocks.');
|
|
480
|
+
const filePath = normalized.slice(0, firstNewline).trim();
|
|
481
|
+
const body = normalized.slice(firstNewline + 1);
|
|
482
|
+
const searchMarker = '<<<SEARCH\n';
|
|
483
|
+
const replaceMarker = '\n===REPLACE\n';
|
|
484
|
+
const endMarker = '\n>>>END';
|
|
485
|
+
if (!body.startsWith(searchMarker)) throw new Error('EDIT_FILE is missing <<<SEARCH.');
|
|
486
|
+
const replaceIndex = body.indexOf(replaceMarker, searchMarker.length);
|
|
487
|
+
const endIndex = body.lastIndexOf(endMarker);
|
|
488
|
+
if (replaceIndex === -1 || endIndex === -1 || endIndex < replaceIndex) {
|
|
489
|
+
throw new Error('EDIT_FILE requires ===REPLACE and >>>END markers.');
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
filePath,
|
|
493
|
+
oldText: body.slice(searchMarker.length, replaceIndex),
|
|
494
|
+
newText: body.slice(replaceIndex + replaceMarker.length, endIndex),
|
|
495
|
+
replaceAll: /\nALL\s*$/.test(body.slice(endIndex + endMarker.length))
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function countOccurrences(content, search) {
|
|
500
|
+
if (!search) return 0;
|
|
501
|
+
let count = 0;
|
|
502
|
+
let offset = 0;
|
|
503
|
+
while ((offset = content.indexOf(search, offset)) !== -1) {
|
|
504
|
+
count++;
|
|
505
|
+
offset += search.length;
|
|
506
|
+
}
|
|
507
|
+
return count;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseMoveArgument(toolArg) {
|
|
511
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
512
|
+
return {
|
|
513
|
+
source: String(toolArg.source || toolArg.from || '').trim(),
|
|
514
|
+
destination: String(toolArg.destination || toolArg.to || '').trim()
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const raw = String(toolArg || '').trim();
|
|
518
|
+
if (raw.startsWith('{')) {
|
|
519
|
+
const parsed = JSON.parse(raw);
|
|
520
|
+
return {
|
|
521
|
+
source: parsed.source || parsed.from || '',
|
|
522
|
+
destination: parsed.destination || parsed.to || ''
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const [source = '', destination = ''] = raw.split(/\r?\n/, 2);
|
|
526
|
+
return { source: source.trim(), destination: destination.trim() };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function runWorkspaceCommand(command, cwd, signal) {
|
|
530
|
+
return new Promise(resolve => {
|
|
531
|
+
const child = execCommand(command, { cwd, timeout: 60000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
532
|
+
signal?.removeEventListener('abort', abort);
|
|
533
|
+
resolve({
|
|
534
|
+
exitCode: typeof error?.code === 'number' ? error.code : (error ? 1 : 0),
|
|
535
|
+
stdout: String(stdout || ''),
|
|
536
|
+
stderr: String(stderr || ''),
|
|
537
|
+
error
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
const abort = () => child.kill();
|
|
541
|
+
if (signal?.aborted) abort();
|
|
542
|
+
else signal?.addEventListener('abort', abort, { once: true });
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function executeToolCall({
|
|
547
|
+
toolName,
|
|
548
|
+
toolArg,
|
|
549
|
+
workspaceRoot,
|
|
550
|
+
toolCallId = '',
|
|
551
|
+
emit = () => {},
|
|
552
|
+
requestPermission = async () => false,
|
|
553
|
+
lang = 'cn',
|
|
554
|
+
mode = 'chat',
|
|
555
|
+
signal,
|
|
556
|
+
getActiveEffort,
|
|
557
|
+
effortPresets,
|
|
558
|
+
runFileDigestionWorkflow
|
|
559
|
+
}) {
|
|
560
|
+
const preset = effortPresets[getActiveEffort()] || effortPresets.high;
|
|
561
|
+
const maxToolChars = Math.max(8000, Math.min(30000, preset.maxReadLines * 35));
|
|
562
|
+
const cn = lang === 'cn';
|
|
563
|
+
const startedAt = Date.now();
|
|
564
|
+
|
|
565
|
+
function event(type, data = {}) {
|
|
566
|
+
emit(type, { tool: toolName, toolCallId, ...data });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function success(modelResult, displaySummary, data = {}) {
|
|
570
|
+
const durationMs = Date.now() - startedAt;
|
|
571
|
+
event('tool.completed', { displaySummary, durationMs, ...data });
|
|
572
|
+
return { ok: true, result: modelResult, modelResult, displaySummary, status: 'Done', data };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function failure(error) {
|
|
576
|
+
const modelResult = `Tool ${toolName} failed: ${error.message}`;
|
|
577
|
+
const displaySummary = cn ? `${toolName} 失败 · ${error.message}` : `${toolName} failed · ${error.message}`;
|
|
578
|
+
event('tool.failed', { displaySummary, error: error.message, durationMs: Date.now() - startedAt });
|
|
579
|
+
return { ok: false, result: modelResult, modelResult, displaySummary, status: 'Failed' };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
if (MUTATING_TOOLS.has(toolName) && mode !== 'code') {
|
|
584
|
+
throw new Error(`Workspace mutation is blocked in ${mode} mode. Use /code <request> to authorize one coding turn.`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (toolName === 'LIST_DIR') {
|
|
588
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
589
|
+
const resolvedPath = resolveExistingWorkspacePath(String(rawPath || '.').trim() || '.', workspaceRoot);
|
|
590
|
+
const relPath = path.relative(workspaceRoot, resolvedPath) || '.';
|
|
591
|
+
event('tool.started', { target: relPath, label: cn ? `正在查看 ${relPath}` : `Listing ${relPath}` });
|
|
592
|
+
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
|
593
|
+
const rows = await Promise.all(entries.map(async entry => {
|
|
594
|
+
if (entry.isDirectory()) return `${entry.name} (Dir)`;
|
|
595
|
+
if (entry.isSymbolicLink()) return `${entry.name} (Symlink)`;
|
|
596
|
+
const stat = await fs.promises.stat(path.join(resolvedPath, entry.name));
|
|
597
|
+
return `${entry.name} (File, ${stat.size}B)`;
|
|
598
|
+
}));
|
|
599
|
+
const result = rows.join('\n') || '(Empty directory)';
|
|
600
|
+
return success(
|
|
601
|
+
truncateToolResult(result, maxToolChars),
|
|
602
|
+
cn ? `已查看 ${relPath} · ${entries.length} 项` : `Listed ${relPath} · ${entries.length} items`,
|
|
603
|
+
{ target: relPath, itemCount: entries.length }
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (toolName === 'READ_FILE') {
|
|
608
|
+
const { filePath, startLine, endLine, hasRange } = parseReadArgument(toolArg);
|
|
609
|
+
const resolvedPath = resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
610
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
611
|
+
const rangeText = hasRange ? `:${startLine}-${endLine}` : '';
|
|
612
|
+
const target = `${relPath}${rangeText}`;
|
|
613
|
+
const realReadTarget = fs.realpathSync.native(resolvedPath);
|
|
614
|
+
if (isSensitivePath(relPath) || isSensitivePath(realReadTarget)) {
|
|
615
|
+
const displaySummary = cn ? `敏感文件读取确认 · ${relPath}` : `Sensitive file read · ${relPath}`;
|
|
616
|
+
event('permission.requested', { kind: 'sensitive-read', target: relPath, displaySummary });
|
|
617
|
+
const allowed = await requestPermission({
|
|
618
|
+
kind: 'sensitive-read',
|
|
619
|
+
prompt: cn
|
|
620
|
+
? `读取 ${relPath} 会将内容发送给模型,是否允许?(y/n): `
|
|
621
|
+
: `Reading ${relPath} sends its content to the model. Allow? (y/n): `,
|
|
622
|
+
displaySummary
|
|
623
|
+
});
|
|
624
|
+
event('permission.resolved', { kind: 'sensitive-read', target: relPath, allowed, displaySummary });
|
|
625
|
+
if (!allowed) {
|
|
626
|
+
const modelResult = `Sensitive file read denied by user: ${relPath}`;
|
|
627
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
event('tool.started', { target, label: cn ? `正在读取 ${target}` : `Reading ${target}` });
|
|
631
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
632
|
+
if (!stat.isFile()) throw new Error(`READ_FILE target is not a file: ${relPath}`);
|
|
633
|
+
if (await looksBinary(resolvedPath)) throw new Error(`READ_FILE refuses binary content: ${relPath}`);
|
|
634
|
+
|
|
635
|
+
if (hasRange) {
|
|
636
|
+
if (endLine - startLine + 1 > preset.maxReadLines) {
|
|
637
|
+
throw new Error(`Requested range exceeds the ${preset.maxReadLines}-line limit.`);
|
|
638
|
+
}
|
|
639
|
+
const ranged = await readLineRange(resolvedPath, startLine, endLine);
|
|
640
|
+
return success(
|
|
641
|
+
truncateToolResult(ranged.content, maxToolChars),
|
|
642
|
+
cn ? `已读取 ${target} · ${ranged.lineCount} 行` : `Read ${target} · ${ranged.lineCount} lines`,
|
|
643
|
+
{ target, lineCount: ranged.lineCount, totalLines: null }
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (stat.size > 2 * 1024 * 1024) {
|
|
648
|
+
throw new Error(`File is too large for a full read (${stat.size} bytes). Use READ_FILE with a targeted line range.`);
|
|
649
|
+
}
|
|
650
|
+
const content = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
651
|
+
const lines = content.split('\n');
|
|
652
|
+
|
|
653
|
+
if (getActiveEffort() === 'ultracode' && lines.length > 1000) {
|
|
654
|
+
const totalChunks = Math.ceil(lines.length / 2000);
|
|
655
|
+
const displaySummary = cn
|
|
656
|
+
? `消化 ${path.basename(resolvedPath)} · ${lines.length} 行 · ${totalChunks} 个分片`
|
|
657
|
+
: `Digest ${path.basename(resolvedPath)} · ${lines.length} lines · ${totalChunks} chunks`;
|
|
658
|
+
event('permission.requested', { kind: 'digest', target: relPath, displaySummary });
|
|
659
|
+
const proceed = await requestPermission({
|
|
660
|
+
kind: 'digest',
|
|
661
|
+
prompt: cn
|
|
662
|
+
? `消化 "${path.basename(resolvedPath)}"(${lines.length} 行,约 ${totalChunks} 次 API 调用)?(y/n): `
|
|
663
|
+
: `Digest "${path.basename(resolvedPath)}" (${lines.length} lines, about ${totalChunks} API calls)? (y/n): `,
|
|
664
|
+
displaySummary
|
|
665
|
+
});
|
|
666
|
+
event('permission.resolved', {
|
|
667
|
+
kind: 'digest',
|
|
668
|
+
target: relPath,
|
|
669
|
+
allowed: proceed,
|
|
670
|
+
displaySummary: proceed
|
|
671
|
+
? (cn ? '已允许大文件消化' : 'Large-file digestion allowed')
|
|
672
|
+
: (cn ? '已取消大文件消化' : 'Large-file digestion cancelled')
|
|
673
|
+
});
|
|
674
|
+
if (proceed) {
|
|
675
|
+
const report = await runFileDigestionWorkflow(resolvedPath, {
|
|
676
|
+
emit,
|
|
677
|
+
toolCallId,
|
|
678
|
+
toolName,
|
|
679
|
+
lang
|
|
680
|
+
});
|
|
681
|
+
return success(
|
|
682
|
+
truncateToolResult(report, maxToolChars),
|
|
683
|
+
cn ? `已消化 ${relPath} · ${totalChunks} 个分片` : `Digested ${relPath} · ${totalChunks} chunks`,
|
|
684
|
+
{ target: relPath, lineCount: lines.length, chunks: totalChunks }
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
const modelResult = `[Read skipped by user. Use <<READ_FILE: ${relPath}:start-end>> for targeted ranges.]`;
|
|
688
|
+
return {
|
|
689
|
+
ok: true,
|
|
690
|
+
cancelled: true,
|
|
691
|
+
result: modelResult,
|
|
692
|
+
modelResult,
|
|
693
|
+
displaySummary: cn ? `已取消读取 ${relPath}` : `Cancelled reading ${relPath}`,
|
|
694
|
+
status: 'Cancelled'
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (lines.length > preset.maxReadLines) {
|
|
699
|
+
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
700
|
+
const modelResult = truncateToolResult(`[File has ${lines.length} lines. Showing first ${preset.maxReadLines}. Use <<READ_FILE: ${relPath}:start-end>> for exact ranges.]\n\n${truncated}`, maxToolChars);
|
|
701
|
+
return success(
|
|
702
|
+
modelResult,
|
|
703
|
+
cn ? `已读取 ${relPath} · 前 ${preset.maxReadLines}/${lines.length} 行` : `Read ${relPath} · first ${preset.maxReadLines}/${lines.length} lines`,
|
|
704
|
+
{ target: relPath, lineCount: preset.maxReadLines, totalLines: lines.length, truncated: true }
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return success(
|
|
709
|
+
truncateToolResult(content, maxToolChars),
|
|
710
|
+
cn ? `已读取 ${relPath} · ${lines.length} 行` : `Read ${relPath} · ${lines.length} lines`,
|
|
711
|
+
{ target: relPath, lineCount: lines.length, totalLines: lines.length }
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (toolName === 'EDIT_FILE') {
|
|
716
|
+
const { filePath, oldText, newText, replaceAll } = parseEditArgument(toolArg);
|
|
717
|
+
if (!filePath) throw new Error('EDIT_FILE path is empty.');
|
|
718
|
+
if (!oldText) throw new Error('EDIT_FILE search text is empty.');
|
|
719
|
+
const resolvedPath = resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
720
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
721
|
+
const oldContent = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
722
|
+
const expectedHash = contentHash(oldContent);
|
|
723
|
+
const eol = oldContent.includes('\r\n') ? '\r\n' : '\n';
|
|
724
|
+
const normalizedContent = oldContent.replace(/\r\n/g, '\n');
|
|
725
|
+
const normalizedOld = String(oldText).replace(/\r\n/g, '\n');
|
|
726
|
+
const normalizedNew = String(newText).replace(/\r\n/g, '\n');
|
|
727
|
+
const matches = countOccurrences(normalizedContent, normalizedOld);
|
|
728
|
+
if (matches === 0) {
|
|
729
|
+
throw new Error(`EDIT_FILE search text was not found in ${relPath}. Read the exact current lines and retry.`);
|
|
730
|
+
}
|
|
731
|
+
if (matches > 1 && !replaceAll) {
|
|
732
|
+
throw new Error(`EDIT_FILE search text matched ${matches} locations in ${relPath}. Include more surrounding context or set replaceAll.`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const updatedNormalized = replaceAll
|
|
736
|
+
? normalizedContent.split(normalizedOld).join(normalizedNew)
|
|
737
|
+
: normalizedContent.replace(normalizedOld, normalizedNew);
|
|
738
|
+
const updatedContent = eol === '\r\n' ? updatedNormalized.replace(/\n/g, '\r\n') : updatedNormalized;
|
|
739
|
+
const preview = buildWritePreview(resolvedPath, oldContent, updatedContent, true);
|
|
740
|
+
const displaySummary = cn
|
|
741
|
+
? `修改 ${relPath} · ${replaceAll ? matches : 1} 处`
|
|
742
|
+
: `Edit ${relPath} · ${replaceAll ? matches : 1} replacement${matches === 1 ? '' : 's'}`;
|
|
743
|
+
event('permission.requested', { kind: 'edit', target: relPath, displaySummary, preview });
|
|
744
|
+
const proceed = await requestPermission({
|
|
745
|
+
kind: 'edit',
|
|
746
|
+
prompt: cn ? '应用此局部修改?(y/n): ' : 'Apply this focused edit? (y/n): ',
|
|
747
|
+
displaySummary,
|
|
748
|
+
preview
|
|
749
|
+
});
|
|
750
|
+
event('permission.resolved', {
|
|
751
|
+
kind: 'edit',
|
|
752
|
+
target: relPath,
|
|
753
|
+
allowed: proceed,
|
|
754
|
+
displaySummary: proceed
|
|
755
|
+
? (cn ? '已确认局部修改' : 'Focused edit approved')
|
|
756
|
+
: (cn ? `已取消修改 ${relPath}` : `Cancelled edit to ${relPath}`)
|
|
757
|
+
});
|
|
758
|
+
if (!proceed) {
|
|
759
|
+
const modelResult = `Edit cancelled by user: ${relPath}`;
|
|
760
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
event('tool.started', { target: relPath, label: cn ? `正在修改 ${relPath}` : `Editing ${relPath}` });
|
|
764
|
+
resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
765
|
+
const currentContent = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
766
|
+
if (contentHash(currentContent) !== expectedHash) {
|
|
767
|
+
throw new Error(`${relPath} changed while awaiting confirmation. Read the file again before editing.`);
|
|
768
|
+
}
|
|
769
|
+
await fs.promises.writeFile(resolvedPath, updatedContent, 'utf8');
|
|
770
|
+
const modelResult = `Successfully edited ${relPath}: ${replaceAll ? matches : 1} replacement(s).`;
|
|
771
|
+
return success(modelResult, cn ? `已修改 ${relPath} · ${replaceAll ? matches : 1} 处` : `Edited ${relPath} · ${replaceAll ? matches : 1} replacement(s)`, {
|
|
772
|
+
target: relPath,
|
|
773
|
+
replacements: replaceAll ? matches : 1
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (toolName === 'WRITE_FILE') {
|
|
778
|
+
let filePathPart;
|
|
779
|
+
let fileContentPart;
|
|
780
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
781
|
+
filePathPart = String(toolArg.path || toolArg.filePath || '');
|
|
782
|
+
fileContentPart = String(toolArg.content ?? '');
|
|
783
|
+
} else {
|
|
784
|
+
const rawArg = String(toolArg || '');
|
|
785
|
+
const firstNewline = rawArg.indexOf('\n');
|
|
786
|
+
filePathPart = firstNewline === -1 ? rawArg : rawArg.slice(0, firstNewline);
|
|
787
|
+
fileContentPart = firstNewline === -1 ? '' : rawArg.slice(firstNewline + 1);
|
|
788
|
+
}
|
|
789
|
+
const resolvedPath = resolveWorkspacePath(filePathPart.trim(), workspaceRoot);
|
|
790
|
+
const existed = fs.existsSync(resolvedPath);
|
|
791
|
+
const oldContent = existed ? await fs.promises.readFile(resolvedPath, 'utf8') : '';
|
|
792
|
+
const expectedHash = existed ? contentHash(oldContent) : null;
|
|
793
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
794
|
+
const preview = buildWritePreview(resolvedPath, oldContent, fileContentPart, existed);
|
|
795
|
+
const displaySummary = cn
|
|
796
|
+
? `${existed ? '修改' : '创建'} ${relPath}`
|
|
797
|
+
: `${existed ? 'Update' : 'Create'} ${relPath}`;
|
|
798
|
+
event('permission.requested', { kind: 'write', target: relPath, displaySummary, preview });
|
|
799
|
+
const proceed = await requestPermission({
|
|
800
|
+
kind: 'write',
|
|
801
|
+
prompt: cn ? '应用此文件更改?(y/n): ' : 'Apply this file change? (y/n): ',
|
|
802
|
+
displaySummary,
|
|
803
|
+
preview
|
|
804
|
+
});
|
|
805
|
+
event('permission.resolved', {
|
|
806
|
+
kind: 'write',
|
|
807
|
+
target: relPath,
|
|
808
|
+
allowed: proceed,
|
|
809
|
+
displaySummary: proceed
|
|
810
|
+
? (cn ? '已确认文件更改' : 'File change approved')
|
|
811
|
+
: (cn ? `已取消修改 ${relPath}` : `Cancelled change to ${relPath}`)
|
|
812
|
+
});
|
|
813
|
+
if (!proceed) {
|
|
814
|
+
const modelResult = `Write cancelled by user: ${relPath}`;
|
|
815
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
event('tool.started', { target: relPath, label: cn ? `正在写入 ${relPath}` : `Writing ${relPath}` });
|
|
819
|
+
resolveWorkspacePath(filePathPart.trim(), workspaceRoot);
|
|
820
|
+
if (existed) {
|
|
821
|
+
const currentContent = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
822
|
+
if (contentHash(currentContent) !== expectedHash) {
|
|
823
|
+
throw new Error(`${relPath} changed while awaiting confirmation. Read the file again before writing.`);
|
|
824
|
+
}
|
|
825
|
+
} else if (fs.existsSync(resolvedPath)) {
|
|
826
|
+
throw new Error(`${relPath} was created while awaiting confirmation. Read it before writing.`);
|
|
827
|
+
}
|
|
828
|
+
await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
829
|
+
await fs.promises.writeFile(resolvedPath, fileContentPart, 'utf8');
|
|
830
|
+
const modelResult = `Successfully wrote file: ${relPath}`;
|
|
831
|
+
return success(
|
|
832
|
+
modelResult,
|
|
833
|
+
cn ? `已${existed ? '修改' : '创建'} ${relPath}` : `${existed ? 'Updated' : 'Created'} ${relPath}`,
|
|
834
|
+
{ target: relPath, created: !existed, bytes: Buffer.byteLength(fileContentPart, 'utf8') }
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (toolName === 'MAKE_DIR') {
|
|
839
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
840
|
+
const resolvedPath = resolveWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
841
|
+
const directoryExisted = fs.existsSync(resolvedPath);
|
|
842
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
843
|
+
const displaySummary = cn ? `创建目录 ${relPath}` : `Create directory ${relPath}`;
|
|
844
|
+
event('permission.requested', { kind: 'mkdir', target: relPath, displaySummary });
|
|
845
|
+
const proceed = await requestPermission({
|
|
846
|
+
kind: 'mkdir',
|
|
847
|
+
prompt: cn ? `创建目录 ${relPath}?(y/n): ` : `Create directory ${relPath}? (y/n): `,
|
|
848
|
+
displaySummary
|
|
849
|
+
});
|
|
850
|
+
event('permission.resolved', { kind: 'mkdir', target: relPath, allowed: proceed, displaySummary });
|
|
851
|
+
if (!proceed) {
|
|
852
|
+
const modelResult = `Directory creation cancelled by user: ${relPath}`;
|
|
853
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
854
|
+
}
|
|
855
|
+
event('tool.started', { target: relPath, label: cn ? `正在创建目录 ${relPath}` : `Creating directory ${relPath}` });
|
|
856
|
+
resolveWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
857
|
+
if (!directoryExisted && fs.existsSync(resolvedPath)) {
|
|
858
|
+
throw new Error(`${relPath} appeared while awaiting confirmation. Inspect it before continuing.`);
|
|
859
|
+
}
|
|
860
|
+
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
|
861
|
+
return success(
|
|
862
|
+
`Successfully created directory: ${relPath}`,
|
|
863
|
+
cn ? `已创建目录 ${relPath}` : `Created directory ${relPath}`,
|
|
864
|
+
{ target: relPath }
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (toolName === 'MOVE_PATH') {
|
|
869
|
+
const { source, destination } = parseMoveArgument(toolArg);
|
|
870
|
+
if (!source || !destination) throw new Error('MOVE_PATH requires source and destination paths.');
|
|
871
|
+
const sourcePath = resolveExistingWorkspacePath(source, workspaceRoot);
|
|
872
|
+
const destinationPath = resolveWorkspacePath(destination, workspaceRoot);
|
|
873
|
+
const sourceIdentity = await fs.promises.lstat(sourcePath);
|
|
874
|
+
const sourceRel = path.relative(workspaceRoot, sourcePath);
|
|
875
|
+
const destinationRel = path.relative(workspaceRoot, destinationPath);
|
|
876
|
+
if (fs.existsSync(destinationPath)) throw new Error(`MOVE_PATH destination already exists: ${destinationRel}`);
|
|
877
|
+
const displaySummary = cn ? `移动 ${sourceRel} → ${destinationRel}` : `Move ${sourceRel} -> ${destinationRel}`;
|
|
878
|
+
event('permission.requested', { kind: 'move', target: sourceRel, displaySummary });
|
|
879
|
+
const proceed = await requestPermission({
|
|
880
|
+
kind: 'move',
|
|
881
|
+
prompt: cn ? `移动到 ${destinationRel}?(y/n): ` : `Move to ${destinationRel}? (y/n): `,
|
|
882
|
+
displaySummary
|
|
883
|
+
});
|
|
884
|
+
event('permission.resolved', { kind: 'move', target: sourceRel, allowed: proceed, displaySummary: proceed ? displaySummary : (cn ? '已取消移动' : 'Move cancelled') });
|
|
885
|
+
if (!proceed) {
|
|
886
|
+
const modelResult = `Move cancelled by user: ${sourceRel}`;
|
|
887
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
888
|
+
}
|
|
889
|
+
event('tool.started', { target: sourceRel, label: cn ? `正在移动 ${sourceRel}` : `Moving ${sourceRel}` });
|
|
890
|
+
resolveExistingWorkspacePath(source, workspaceRoot);
|
|
891
|
+
resolveWorkspacePath(destination, workspaceRoot);
|
|
892
|
+
if (fs.existsSync(destinationPath)) throw new Error(`${destinationRel} appeared while awaiting confirmation.`);
|
|
893
|
+
const currentSourceIdentity = await fs.promises.lstat(sourcePath);
|
|
894
|
+
if (currentSourceIdentity.dev !== sourceIdentity.dev || currentSourceIdentity.ino !== sourceIdentity.ino || currentSourceIdentity.mtimeMs !== sourceIdentity.mtimeMs || currentSourceIdentity.size !== sourceIdentity.size) {
|
|
895
|
+
throw new Error(`${sourceRel} changed while awaiting confirmation. Inspect it again before moving.`);
|
|
896
|
+
}
|
|
897
|
+
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
898
|
+
await fs.promises.rename(sourcePath, destinationPath);
|
|
899
|
+
return success(`Successfully moved ${sourceRel} to ${destinationRel}`, displaySummary, { source: sourceRel, destination: destinationRel });
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (toolName === 'DELETE_PATH') {
|
|
903
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
904
|
+
const resolvedPath = resolveExistingWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
905
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
906
|
+
if (!relPath) throw new Error('Deleting the workspace root is not allowed.');
|
|
907
|
+
const stat = await fs.promises.lstat(resolvedPath);
|
|
908
|
+
const deleteIdentity = { dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
909
|
+
if (stat.isDirectory()) {
|
|
910
|
+
const entries = await fs.promises.readdir(resolvedPath);
|
|
911
|
+
if (entries.length > 0) throw new Error('DELETE_PATH only removes files or empty directories.');
|
|
912
|
+
}
|
|
913
|
+
const displaySummary = cn ? `删除 ${relPath}` : `Delete ${relPath}`;
|
|
914
|
+
event('permission.requested', { kind: 'delete', target: relPath, displaySummary });
|
|
915
|
+
const proceed = await requestPermission({
|
|
916
|
+
kind: 'delete',
|
|
917
|
+
prompt: cn ? `确认删除 ${relPath}?(y/n): ` : `Delete ${relPath}? (y/n): `,
|
|
918
|
+
displaySummary
|
|
919
|
+
});
|
|
920
|
+
event('permission.resolved', { kind: 'delete', target: relPath, allowed: proceed, displaySummary: proceed ? displaySummary : (cn ? '已取消删除' : 'Delete cancelled') });
|
|
921
|
+
if (!proceed) {
|
|
922
|
+
const modelResult = `Delete cancelled by user: ${relPath}`;
|
|
923
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
924
|
+
}
|
|
925
|
+
event('tool.started', { target: relPath, label: cn ? `正在删除 ${relPath}` : `Deleting ${relPath}` });
|
|
926
|
+
resolveExistingWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
927
|
+
const currentDeleteIdentity = await fs.promises.lstat(resolvedPath);
|
|
928
|
+
if (currentDeleteIdentity.dev !== deleteIdentity.dev || currentDeleteIdentity.ino !== deleteIdentity.ino || currentDeleteIdentity.mtimeMs !== deleteIdentity.mtimeMs || currentDeleteIdentity.size !== deleteIdentity.size) {
|
|
929
|
+
throw new Error(`${relPath} changed while awaiting confirmation. Inspect it again before deleting.`);
|
|
930
|
+
}
|
|
931
|
+
if (stat.isDirectory()) await fs.promises.rmdir(resolvedPath);
|
|
932
|
+
else await fs.promises.unlink(resolvedPath);
|
|
933
|
+
return success(`Successfully deleted: ${relPath}`, cn ? `已删除 ${relPath}` : `Deleted ${relPath}`, { target: relPath });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (toolName === 'RUN_COMMAND') {
|
|
937
|
+
const rawCommand = toolArg && typeof toolArg === 'object' ? toolArg.command : toolArg;
|
|
938
|
+
const command = String(rawCommand || '').trim();
|
|
939
|
+
if (!command) throw new Error('RUN_COMMAND command is empty.');
|
|
940
|
+
const displaySummary = cn ? `运行命令: ${command}` : `Run command: ${command}`;
|
|
941
|
+
event('permission.requested', { kind: 'command', displaySummary, preview: `$ ${command}` });
|
|
942
|
+
const proceed = await requestPermission({
|
|
943
|
+
kind: 'command',
|
|
944
|
+
prompt: cn
|
|
945
|
+
? '此命令不受沙箱限制,可能影响工作区外部。仍要运行?(y/n): '
|
|
946
|
+
: 'This command is not sandboxed and may affect paths outside the workspace. Run it? (y/n): ',
|
|
947
|
+
displaySummary,
|
|
948
|
+
preview: `$ ${command}`
|
|
949
|
+
});
|
|
950
|
+
event('permission.resolved', { kind: 'command', allowed: proceed, displaySummary: proceed ? (cn ? '已允许命令执行' : 'Command approved') : (cn ? '已取消命令' : 'Command cancelled') });
|
|
951
|
+
if (!proceed) {
|
|
952
|
+
const modelResult = `Command cancelled by user: ${command}`;
|
|
953
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
954
|
+
}
|
|
955
|
+
event('tool.started', { label: cn ? `正在运行 ${command}` : `Running ${command}` });
|
|
956
|
+
const commandResult = await runWorkspaceCommand(command, workspaceRoot, signal);
|
|
957
|
+
const combined = [
|
|
958
|
+
`Command: ${command}`,
|
|
959
|
+
`Exit code: ${commandResult.exitCode}`,
|
|
960
|
+
commandResult.stdout ? `stdout:\n${commandResult.stdout}` : '',
|
|
961
|
+
commandResult.stderr ? `stderr:\n${commandResult.stderr}` : ''
|
|
962
|
+
].filter(Boolean).join('\n');
|
|
963
|
+
const modelResult = truncateToolResult(combined, maxToolChars);
|
|
964
|
+
if (commandResult.exitCode !== 0) {
|
|
965
|
+
const failedSummary = cn ? `命令失败 · 退出码 ${commandResult.exitCode}` : `Command failed · exit ${commandResult.exitCode}`;
|
|
966
|
+
event('tool.failed', { displaySummary: failedSummary, error: commandResult.stderr || commandResult.error?.message || failedSummary, durationMs: Date.now() - startedAt });
|
|
967
|
+
return { ok: false, result: modelResult, modelResult, displaySummary: failedSummary, status: 'Failed', data: { exitCode: commandResult.exitCode } };
|
|
968
|
+
}
|
|
969
|
+
return success(modelResult, cn ? '命令执行完成' : 'Command completed', { exitCode: 0 });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (toolName === 'SEARCH_GREP') {
|
|
973
|
+
const rawQuery = toolArg && typeof toolArg === 'object' ? toolArg.query : toolArg;
|
|
974
|
+
const query = String(rawQuery || '').trim();
|
|
975
|
+
if (!query) throw new Error('Search query is empty.');
|
|
976
|
+
event('tool.started', { query, label: cn ? `正在搜索 “${query}”` : `Searching "${query}"` });
|
|
977
|
+
const results = [];
|
|
978
|
+
let totalMatches = 0;
|
|
979
|
+
let searchWasPartial = false;
|
|
980
|
+
const maxTotalMatches = 50;
|
|
981
|
+
|
|
982
|
+
async function searchGrep(dir, depth = 0) {
|
|
983
|
+
if (depth > 6) {
|
|
984
|
+
searchWasPartial = true;
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (totalMatches >= maxTotalMatches) return;
|
|
988
|
+
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
|
|
989
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
990
|
+
for (const entry of entries) {
|
|
991
|
+
if (totalMatches >= maxTotalMatches) break;
|
|
992
|
+
const file = entry.name;
|
|
993
|
+
if (file === 'node_modules' || file === '.git' || file === '.gemini' || file === 'package-lock.json') continue;
|
|
994
|
+
const fullPath = path.join(dir, file);
|
|
995
|
+
if (isSensitivePath(path.relative(workspaceRoot, fullPath))) continue;
|
|
996
|
+
try {
|
|
997
|
+
if (entry.isDirectory()) {
|
|
998
|
+
await searchGrep(fullPath, depth + 1);
|
|
999
|
+
} else if (entry.isFile()) {
|
|
1000
|
+
const stat = await fs.promises.stat(fullPath);
|
|
1001
|
+
const ext = path.extname(file).toLowerCase();
|
|
1002
|
+
if (!DEFAULT_TEXT_EXTS.has(ext) && stat.size >= 100000) continue;
|
|
1003
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
1004
|
+
if (!content.includes(query)) continue;
|
|
1005
|
+
const lines = content.split('\n');
|
|
1006
|
+
const fileMatches = [];
|
|
1007
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1008
|
+
if (lines[i].includes(query)) {
|
|
1009
|
+
const text = lines[i].trim();
|
|
1010
|
+
fileMatches.push({ lineNum: i + 1, text: text.length > 140 ? `${text.slice(0, 137)}...` : text });
|
|
1011
|
+
totalMatches++;
|
|
1012
|
+
if (totalMatches >= maxTotalMatches) break;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (fileMatches.length > 0) {
|
|
1016
|
+
results.push({ file: path.relative(workspaceRoot, fullPath), matches: fileMatches });
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
searchWasPartial = true;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
await searchGrep(workspaceRoot);
|
|
1026
|
+
if (results.length === 0) {
|
|
1027
|
+
const modelResult = `No matches found for "${query}"${searchWasPartial ? '\n[Search was partial because some paths were too deep or unreadable.]' : ''}`;
|
|
1028
|
+
return success(modelResult, cn ? `搜索 “${query}” · 无结果` : `Searched "${query}" · no matches`, { query, matchCount: 0, partial: searchWasPartial });
|
|
1029
|
+
}
|
|
1030
|
+
let output = 'Found matches:\n';
|
|
1031
|
+
for (const result of results) {
|
|
1032
|
+
output += `- ${result.file}:\n`;
|
|
1033
|
+
for (const match of result.matches) {
|
|
1034
|
+
output += ` Line ${match.lineNum}: ${match.text}\n`;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (totalMatches >= maxTotalMatches) {
|
|
1038
|
+
output += `[Search truncated at ${maxTotalMatches} matches. Use a narrower query if needed.]\n`;
|
|
1039
|
+
}
|
|
1040
|
+
if (searchWasPartial) output += '[Search was partial because some paths were too deep or unreadable.]\n';
|
|
1041
|
+
return success(
|
|
1042
|
+
truncateToolResult(output, maxToolChars),
|
|
1043
|
+
cn ? `搜索 “${query}” · ${totalMatches} 处` : `Searched "${query}" · ${totalMatches} matches`,
|
|
1044
|
+
{ query, matchCount: totalMatches, truncated: totalMatches >= maxTotalMatches, partial: searchWasPartial }
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return failure(new Error(`Unknown tool: ${toolName}`));
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
return failure(error);
|
|
1051
|
+
}
|
|
1052
|
+
}
|