dave-code 1.1.0 → 1.2.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 +25 -6
- package/bin/aiClient.js +292 -67
- package/bin/check.js +11 -0
- package/bin/cliMenu.js +205 -68
- package/bin/commandRouter.js +20 -0
- package/bin/configManager.js +71 -47
- package/bin/contextManager.js +107 -91
- package/bin/index.js +1413 -243
- package/bin/markdownRenderer.js +264 -0
- package/bin/memoryManager.js +182 -0
- package/bin/planManager.js +61 -9
- package/bin/projectNotebookManager.js +839 -0
- package/bin/runtimeEvents.js +42 -0
- package/bin/scanManager.js +561 -0
- package/bin/sessionManager.js +17 -1
- package/bin/terminalRenderer.js +543 -125
- package/bin/textWidth.js +194 -0
- package/bin/thunderManager.js +302 -0
- package/bin/thunderOrchestrator.js +263 -0
- package/bin/thunderPrompts.js +53 -0
- package/bin/thunderRenderer.js +200 -0
- package/bin/toolRuntime.js +688 -133
- package/package.json +3 -5
package/bin/toolRuntime.js
CHANGED
|
@@ -2,35 +2,161 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
import readline from 'readline';
|
|
5
|
-
import {
|
|
5
|
+
import { execFile, spawn } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const backgroundTasks = new Map();
|
|
8
|
+
const workspaceShellCwds = new Map();
|
|
9
|
+
const workspaceShellSessions = new Map();
|
|
10
|
+
const workspaceTodos = new Map();
|
|
11
|
+
const mutationHistory = new Map();
|
|
12
|
+
|
|
13
|
+
async function recordMutation(workspaceRoot, entry) {
|
|
14
|
+
const history = mutationHistory.get(workspaceRoot) || [];
|
|
15
|
+
if (typeof entry.content === 'string' || Buffer.isBuffer(entry.content)) {
|
|
16
|
+
const backupDir = path.join(workspaceRoot, '.rollback-tmp');
|
|
17
|
+
await fs.promises.mkdir(backupDir, { recursive: true });
|
|
18
|
+
const backupPath = path.join(backupDir, crypto.createHash('sha256').update(`${Date.now()}:${entry.path}`).digest('hex'));
|
|
19
|
+
await fs.promises.writeFile(backupPath, entry.content, typeof entry.content === 'string' ? 'utf8' : undefined);
|
|
20
|
+
entry.backupPath = backupPath;
|
|
21
|
+
delete entry.content;
|
|
22
|
+
}
|
|
23
|
+
history.push({ ...entry, at: Date.now() });
|
|
24
|
+
mutationHistory.set(workspaceRoot, history.slice(-100));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function undoLastMutation(workspaceRoot) {
|
|
28
|
+
const history = mutationHistory.get(workspaceRoot) || [];
|
|
29
|
+
const entry = history.pop();
|
|
30
|
+
if (!entry) return { ok: false, message: 'No mutation is available to undo.' };
|
|
31
|
+
mutationHistory.set(workspaceRoot, history);
|
|
32
|
+
const target = entry.path ? resolveWorkspacePath(entry.path, workspaceRoot) : null;
|
|
33
|
+
if (entry.kind === 'write') {
|
|
34
|
+
if (entry.existed) await fs.promises.writeFile(target, await fs.promises.readFile(entry.backupPath, 'utf8'), 'utf8');
|
|
35
|
+
else if (fs.existsSync(target)) await fs.promises.unlink(target);
|
|
36
|
+
} else if (entry.kind === 'move') {
|
|
37
|
+
const source = resolveWorkspacePath(entry.source, workspaceRoot);
|
|
38
|
+
const destination = resolveExistingWorkspacePath(entry.destination, workspaceRoot);
|
|
39
|
+
await fs.promises.mkdir(path.dirname(source), { recursive: true });
|
|
40
|
+
await fs.promises.rename(destination, source);
|
|
41
|
+
} else if (entry.kind === 'delete') {
|
|
42
|
+
if (entry.directory) await fs.promises.mkdir(target, { recursive: true });
|
|
43
|
+
else {
|
|
44
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
45
|
+
await fs.promises.copyFile(entry.backupPath, target);
|
|
46
|
+
}
|
|
47
|
+
} else if (entry.kind === 'mkdir' && !entry.existed && fs.existsSync(target)) {
|
|
48
|
+
await fs.promises.rmdir(target);
|
|
49
|
+
}
|
|
50
|
+
if (entry.backupPath && fs.existsSync(entry.backupPath)) await fs.promises.unlink(entry.backupPath);
|
|
51
|
+
return { ok: true, message: `Undid ${entry.kind}: ${entry.path || `${entry.source} -> ${entry.destination}`}` };
|
|
52
|
+
}
|
|
6
53
|
|
|
7
54
|
export const SUPPORTED_TOOLS = new Set([
|
|
8
55
|
'LIST_DIR',
|
|
56
|
+
'INSPECT_FILE',
|
|
9
57
|
'READ_FILE',
|
|
58
|
+
'READ_NOTEBOOK',
|
|
10
59
|
'SEARCH_GREP',
|
|
60
|
+
'GLOB_FILES',
|
|
11
61
|
'EDIT_FILE',
|
|
12
62
|
'WRITE_FILE',
|
|
13
63
|
'MAKE_DIR',
|
|
14
64
|
'MOVE_PATH',
|
|
15
65
|
'DELETE_PATH',
|
|
16
66
|
'RUN_COMMAND'
|
|
67
|
+
,'READ_TASK_OUTPUT'
|
|
68
|
+
,'KILL_TASK'
|
|
69
|
+
,'UPDATE_TODOS'
|
|
17
70
|
]);
|
|
18
71
|
export const MUTATING_TOOLS = new Set([
|
|
19
|
-
'EDIT_FILE', 'WRITE_FILE', 'MAKE_DIR', 'MOVE_PATH', 'DELETE_PATH', 'RUN_COMMAND'
|
|
72
|
+
'EDIT_FILE', 'WRITE_FILE', 'MAKE_DIR', 'MOVE_PATH', 'DELETE_PATH', 'RUN_COMMAND', 'KILL_TASK'
|
|
20
73
|
]);
|
|
21
74
|
export const TOOL_DEFINITIONS = [
|
|
22
75
|
{ name: 'LIST_DIR', description: 'List one directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
23
|
-
{ name: '
|
|
24
|
-
{ name: '
|
|
76
|
+
{ name: 'INSPECT_FILE', description: 'Return a compact structural outline with imports, symbols, and line numbers. Prefer this before reading an unfamiliar file.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
77
|
+
{ name: 'READ_FILE', description: 'Read a UTF-8 text file or inclusive line range. Large results are paged to the active context budget; continue with the returned cursor.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, startLine: { type: 'integer', minimum: 1 }, endLine: { type: 'integer', minimum: 1 }, cursor: { type: 'integer', minimum: 1 } }, required: ['path'], additionalProperties: false } },
|
|
78
|
+
{ name: 'READ_NOTEBOOK', description: 'Read the private project notebook on demand. With no sections or query, return only its compact catalog.', inputSchema: { type: 'object', properties: { sections: { type: 'array', items: { type: 'string' }, maxItems: 12 }, query: { type: 'string' }, maxTokens: { type: 'integer', minimum: 100, maximum: 4000 } }, additionalProperties: false } },
|
|
79
|
+
{ name: 'SEARCH_GREP', description: 'Search workspace files with ripgrep-compatible regex, glob, context, case, and output controls.', inputSchema: { type: 'object', properties: { pattern: { type: 'string' }, query: { type: 'string' }, glob: { type: 'string' }, outputMode: { type: 'string', enum: ['content', 'files_with_matches', 'count'] }, contextLines: { type: 'integer', minimum: 0, maximum: 20 }, caseInsensitive: { type: 'boolean' }, literal: { type: 'boolean' } }, additionalProperties: false } },
|
|
80
|
+
{ name: 'GLOB_FILES', description: 'List workspace files matching a glob, newest modified first.', inputSchema: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'], additionalProperties: false } },
|
|
25
81
|
{ 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
82
|
{ 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
83
|
{ name: 'MAKE_DIR', description: 'Create a directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
28
84
|
{ 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
85
|
{ 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
|
|
86
|
+
{ name: 'RUN_COMMAND', description: 'Run a command in the workspace shell after user confirmation, optionally in the background.', inputSchema: { type: 'object', properties: { command: { type: 'string' }, runInBackground: { type: 'boolean' }, timeoutSeconds: { type: 'integer', minimum: 1, maximum: 600 } }, required: ['command'], additionalProperties: false } },
|
|
87
|
+
{ name: 'READ_TASK_OUTPUT', description: 'Read captured output and status for a background command.', inputSchema: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'], additionalProperties: false } },
|
|
88
|
+
{ name: 'KILL_TASK', description: 'Stop a background command after confirmation.', inputSchema: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'], additionalProperties: false } }
|
|
89
|
+
,{ name: 'UPDATE_TODOS', description: 'Publish the current long-task checklist for the user.', inputSchema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } }, required: ['content', 'status'], additionalProperties: false }, maxItems: 30 } }, required: ['todos'], additionalProperties: false } }
|
|
31
90
|
];
|
|
32
91
|
|
|
92
|
+
export const SCAN_TOOL_DEFINITIONS = [{
|
|
93
|
+
name: 'COMMIT_CONTEXT_PLAN',
|
|
94
|
+
description: 'Commit the active-context token budget and advisory starting files for the formal repository task.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
budgetTokens: { type: 'integer', minimum: 4096 },
|
|
99
|
+
contextBudgetTokens: { type: 'integer', minimum: 4096 },
|
|
100
|
+
turnBudgetTokens: { type: 'integer', minimum: 4096 },
|
|
101
|
+
rationale: { type: 'string' },
|
|
102
|
+
files: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
items: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
path: { type: 'string' },
|
|
108
|
+
reason: { type: 'string' },
|
|
109
|
+
strategy: { type: 'string', enum: ['outline', 'targeted', 'full'] },
|
|
110
|
+
required: { type: 'boolean' },
|
|
111
|
+
estimatedTokens: { type: 'integer', minimum: 0 }
|
|
112
|
+
},
|
|
113
|
+
required: ['path'],
|
|
114
|
+
additionalProperties: false
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
notebook: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
action: { type: 'string', enum: ['reuse', 'build', 'update'] },
|
|
121
|
+
sections: { type: 'array', items: { type: 'string' }, maxItems: 12 },
|
|
122
|
+
notebookBudgetTokens: { type: 'integer', minimum: 0 }
|
|
123
|
+
},
|
|
124
|
+
additionalProperties: false
|
|
125
|
+
},
|
|
126
|
+
recommendedFiles: {
|
|
127
|
+
type: 'array',
|
|
128
|
+
items: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: { path: { type: 'string' }, reason: { type: 'string' }, strategy: { type: 'string' } },
|
|
131
|
+
required: ['path'], additionalProperties: false
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
notebookReuse: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
rationale: { type: 'string' },
|
|
138
|
+
trustedSections: { type: 'array', items: { type: 'string' } },
|
|
139
|
+
verifyFiles: {
|
|
140
|
+
type: 'array',
|
|
141
|
+
items: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: { path: { type: 'string' }, reason: { type: 'string' }, strategy: { type: 'string' } },
|
|
144
|
+
required: ['path', 'reason'], additionalProperties: false
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
stalePaths: { type: 'array', items: { type: 'string' } }
|
|
148
|
+
},
|
|
149
|
+
additionalProperties: false
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
required: ['rationale'],
|
|
153
|
+
additionalProperties: false
|
|
154
|
+
}
|
|
155
|
+
}];
|
|
156
|
+
|
|
33
157
|
export function getToolDefinitions(mode = 'chat') {
|
|
158
|
+
if (mode === 'note') return [];
|
|
159
|
+
if (mode === 'scan') return SCAN_TOOL_DEFINITIONS;
|
|
34
160
|
return TOOL_DEFINITIONS.filter(tool => mode === 'code' || !MUTATING_TOOLS.has(tool.name));
|
|
35
161
|
}
|
|
36
162
|
const DEFAULT_TEXT_EXTS = new Set([
|
|
@@ -51,6 +177,62 @@ function buildEditArgument(filePath, oldText, newText, replaceAll = false) {
|
|
|
51
177
|
return `${filePath}\n<<<SEARCH\n${oldText}\n===REPLACE\n${newText}\n>>>END${replaceAll ? '\nALL' : ''}`;
|
|
52
178
|
}
|
|
53
179
|
|
|
180
|
+
export function containsToolProtocolLeak(reply = '') {
|
|
181
|
+
const text = String(reply || '');
|
|
182
|
+
if (!text.trim()) return false;
|
|
183
|
+
if (/<[^>\r\n]{0,120}DSML[^>\r\n]{0,120}(?:tool_calls|invoke|parameter)[^>\r\n]*>/i.test(text)) return true;
|
|
184
|
+
return /<<\s*(?:LIST_DIR|INSPECT_FILE|READ_FILE|READ_NOTEBOOK|SEARCH_GREP|GLOB_FILES|EDIT_FILE|WRITE_FILE|MAKE_DIR|MOVE_PATH|DELETE_PATH|RUN_COMMAND|READ_TASK_OUTPUT|KILL_TASK|UPDATE_TODOS|COMMIT_CONTEXT_PLAN)\s*:/i.test(text);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resolveBudgetFinishResponse({
|
|
188
|
+
reply = '',
|
|
189
|
+
nativeToolCalls = [],
|
|
190
|
+
repairAttempts = 0,
|
|
191
|
+
lang = 'en',
|
|
192
|
+
reason = 'budget'
|
|
193
|
+
} = {}) {
|
|
194
|
+
const leakedToolRequest = nativeToolCalls.length > 0 || containsToolProtocolLeak(reply);
|
|
195
|
+
if (!leakedToolRequest) return { action: 'accept', reply: String(reply || '') };
|
|
196
|
+
if (repairAttempts < 1) return { action: 'retry', reply: '' };
|
|
197
|
+
return {
|
|
198
|
+
action: 'fallback',
|
|
199
|
+
reply: reason === 'no-progress'
|
|
200
|
+
? (lang === 'cn'
|
|
201
|
+
? '已停止无进展的重复读取。模型在工具关闭后仍尝试重复调用,相关协议内容已被拦截;当前证据不足以生成可靠的完整结论。'
|
|
202
|
+
: 'The no-progress read loop was stopped. The model still attempted another tool call after tools were disabled, so the protocol output was blocked; the current evidence is insufficient for a reliable complete conclusion.')
|
|
203
|
+
: (lang === 'cn'
|
|
204
|
+
? '已按要求停止继续读取。模型仍尝试调用工具,相关协议内容已被拦截;基于当前证据无法生成可靠的完整结论。'
|
|
205
|
+
: 'Further reading has stopped as requested. The model still attempted a tool call, so the protocol output was blocked; the current evidence is insufficient for a reliable complete conclusion.')
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function stableToolValue(value) {
|
|
210
|
+
if (Array.isArray(value)) return value.map(stableToolValue);
|
|
211
|
+
if (!value || typeof value !== 'object') return value;
|
|
212
|
+
return Object.fromEntries(
|
|
213
|
+
Object.keys(value).sort().map(key => [key, stableToolValue(value[key])])
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createToolCallSignature(toolName, toolArg) {
|
|
218
|
+
return `${String(toolName || '').toUpperCase()}:${JSON.stringify(stableToolValue(toolArg || {}))}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function resolveRepeatedToolRequest({
|
|
222
|
+
signature = '',
|
|
223
|
+
previousSignature = '',
|
|
224
|
+
identicalResultCount = 0,
|
|
225
|
+
blockedSignature = ''
|
|
226
|
+
} = {}) {
|
|
227
|
+
if (!signature || signature !== previousSignature || identicalResultCount < 2) {
|
|
228
|
+
return { action: 'allow' };
|
|
229
|
+
}
|
|
230
|
+
if (blockedSignature !== signature) {
|
|
231
|
+
return { action: 'redirect', blockedSignature: signature };
|
|
232
|
+
}
|
|
233
|
+
return { action: 'finish', blockedSignature: signature };
|
|
234
|
+
}
|
|
235
|
+
|
|
54
236
|
function parseDSMLToolCall(text) {
|
|
55
237
|
const trimmed = String(text || '').trim();
|
|
56
238
|
if (!/^<||DSML||tool_calls>[\s\S]*<\/||DSML||tool_calls>$/.test(trimmed)) return null;
|
|
@@ -352,22 +534,41 @@ async function looksBinary(filePath) {
|
|
|
352
534
|
}
|
|
353
535
|
}
|
|
354
536
|
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
537
|
+
function buildFileOutline(content, relPath, maxItems = 120) {
|
|
538
|
+
const lines = String(content || '').split('\n');
|
|
539
|
+
const items = [];
|
|
540
|
+
const patterns = [
|
|
541
|
+
/^\s*(?:import\s|export\s+.*\sfrom\s|const\s+\w+\s*=\s*require\s*\()/,
|
|
542
|
+
/^\s*(?:export\s+)?(?:async\s+)?function\s+[\w$]+/,
|
|
543
|
+
/^\s*(?:export\s+)?class\s+[\w$]+/,
|
|
544
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+[\w$]+\s*=\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>/,
|
|
545
|
+
/^\s*(?:def|class)\s+[\w_]+/,
|
|
546
|
+
/^\s*(?:pub\s+)?(?:async\s+)?fn\s+[\w_]+/,
|
|
547
|
+
/^\s*(?:func|type|interface|struct|enum)\s+[\w_]+/,
|
|
548
|
+
/^\s*#{1,6}\s+\S/
|
|
549
|
+
];
|
|
550
|
+
for (let index = 0; index < lines.length && items.length < maxItems; index++) {
|
|
551
|
+
const line = lines[index].trimEnd();
|
|
552
|
+
if (patterns.some(pattern => pattern.test(line))) {
|
|
553
|
+
items.push(`${index + 1}: ${line.trim().slice(0, 220)}`);
|
|
365
554
|
}
|
|
366
|
-
} finally {
|
|
367
|
-
reader.close();
|
|
368
|
-
input.destroy();
|
|
369
555
|
}
|
|
370
|
-
|
|
556
|
+
const header = `[File outline: ${relPath} · ${lines.length} lines · ${Buffer.byteLength(content, 'utf8')} bytes]`;
|
|
557
|
+
if (items.length === 0) return `${header}\nNo recognizable declarations. Use SEARCH_GREP or a targeted READ_FILE range.`;
|
|
558
|
+
return `${header}\n${items.join('\n')}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function trackerEntry(readTracker, relPath, identity) {
|
|
562
|
+
if (!readTracker?.files) return null;
|
|
563
|
+
const current = readTracker.files.get(relPath);
|
|
564
|
+
if (current?.identity === identity) return current;
|
|
565
|
+
const next = { identity, full: false, outline: false, ranges: [], rangeContents: new Map() };
|
|
566
|
+
readTracker.files.set(relPath, next);
|
|
567
|
+
return next;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function rangeAlreadyRead(entry, startLine, endLine) {
|
|
571
|
+
return Boolean(entry?.full || entry?.ranges?.some(([start, end]) => start <= startLine && end >= endLine));
|
|
371
572
|
}
|
|
372
573
|
|
|
373
574
|
export function truncateToolResult(text, maxChars = 12000) {
|
|
@@ -378,7 +579,8 @@ export function truncateToolResult(text, maxChars = 12000) {
|
|
|
378
579
|
|
|
379
580
|
function parseReadArgument(toolArg) {
|
|
380
581
|
if (toolArg && typeof toolArg === 'object') {
|
|
381
|
-
const
|
|
582
|
+
const rawStart = toolArg.cursor ?? toolArg.startLine ?? toolArg.start_line;
|
|
583
|
+
const startLine = Math.max(1, Number.parseInt(rawStart ?? '1', 10) || 1);
|
|
382
584
|
const rawEnd = toolArg.endLine ?? toolArg.end_line;
|
|
383
585
|
const endLine = rawEnd === undefined || rawEnd === null ? null : Math.max(1, Number.parseInt(rawEnd, 10) || 1);
|
|
384
586
|
if (endLine !== null && endLine < startLine) {
|
|
@@ -388,7 +590,7 @@ function parseReadArgument(toolArg) {
|
|
|
388
590
|
filePath: String(toolArg.path ?? toolArg.filePath ?? ''),
|
|
389
591
|
startLine,
|
|
390
592
|
endLine,
|
|
391
|
-
hasRange: endLine !== null
|
|
593
|
+
hasRange: rawStart !== undefined || endLine !== null
|
|
392
594
|
};
|
|
393
595
|
}
|
|
394
596
|
let filePath = String(toolArg || '').trim();
|
|
@@ -406,6 +608,36 @@ function parseReadArgument(toolArg) {
|
|
|
406
608
|
return { filePath, startLine, endLine, hasRange: !!rangeMatch };
|
|
407
609
|
}
|
|
408
610
|
|
|
611
|
+
async function readLinePage(filePath, startLine, endLine, maxChars) {
|
|
612
|
+
const input = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
613
|
+
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
614
|
+
const lines = [];
|
|
615
|
+
let lineNumber = 0;
|
|
616
|
+
let chars = 0;
|
|
617
|
+
let nextCursor = null;
|
|
618
|
+
try {
|
|
619
|
+
for await (const line of rl) {
|
|
620
|
+
lineNumber++;
|
|
621
|
+
if (lineNumber < startLine) continue;
|
|
622
|
+
if (endLine !== null && lineNumber > endLine) break;
|
|
623
|
+
const rendered = `${lineNumber}: ${line}`;
|
|
624
|
+
if (lines.length && chars + rendered.length + 1 > maxChars) {
|
|
625
|
+
nextCursor = lineNumber;
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
lines.push(rendered);
|
|
629
|
+
chars += rendered.length + 1;
|
|
630
|
+
}
|
|
631
|
+
} finally {
|
|
632
|
+
rl.close();
|
|
633
|
+
input.destroy();
|
|
634
|
+
}
|
|
635
|
+
const suffix = nextCursor
|
|
636
|
+
? `\n\n[Continuation cursor: ${nextCursor}. Call READ_FILE with the same path and cursor ${nextCursor}${endLine !== null ? ` (original endLine ${endLine})` : ''}.]`
|
|
637
|
+
: '';
|
|
638
|
+
return { content: lines.join('\n') + suffix, lineCount: lines.length, nextCursor, lastLine: lineNumber };
|
|
639
|
+
}
|
|
640
|
+
|
|
409
641
|
function buildWritePreview(filePath, oldContent, newContent, existed) {
|
|
410
642
|
const relName = path.basename(filePath);
|
|
411
643
|
if (!existed) {
|
|
@@ -419,32 +651,49 @@ function buildWritePreview(filePath, oldContent, newContent, existed) {
|
|
|
419
651
|
return `${relName}: no content changes.`;
|
|
420
652
|
}
|
|
421
653
|
|
|
422
|
-
const oldLines = oldContent.split('\n');
|
|
423
|
-
const newLines = newContent.split('\n');
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
654
|
+
const oldLines = oldContent.replace(/\r\n/g, '\n').split('\n');
|
|
655
|
+
const newLines = newContent.replace(/\r\n/g, '\n').split('\n');
|
|
656
|
+
const rows = oldLines.length + 1;
|
|
657
|
+
const cols = newLines.length + 1;
|
|
658
|
+
if (rows * cols > 2_000_000) {
|
|
659
|
+
return [`Update ${relName}`, '@@ large-file diff @@', previewLines(oldContent, '-', 40), previewLines(newContent, '+', 40)].join('\n');
|
|
427
660
|
}
|
|
428
|
-
|
|
429
|
-
let
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
434
|
-
) {
|
|
435
|
-
suffix++;
|
|
661
|
+
const lcs = Array.from({ length: rows }, () => new Uint32Array(cols));
|
|
662
|
+
for (let i = oldLines.length - 1; i >= 0; i--) {
|
|
663
|
+
for (let j = newLines.length - 1; j >= 0; j--) {
|
|
664
|
+
lcs[i][j] = oldLines[i] === newLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
665
|
+
}
|
|
436
666
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
667
|
+
const ops = [];
|
|
668
|
+
let i = 0;
|
|
669
|
+
let j = 0;
|
|
670
|
+
let oldLine = 1;
|
|
671
|
+
let newLine = 1;
|
|
672
|
+
while (i < oldLines.length || j < newLines.length) {
|
|
673
|
+
if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
|
|
674
|
+
ops.push({ type: ' ', text: oldLines[i], oldLine: oldLine++, newLine: newLine++ }); i++; j++;
|
|
675
|
+
} else if (j < newLines.length && (i >= oldLines.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
|
676
|
+
ops.push({ type: '+', text: newLines[j++], oldLine: null, newLine: newLine++ });
|
|
677
|
+
} else {
|
|
678
|
+
ops.push({ type: '-', text: oldLines[i++], oldLine: oldLine++, newLine: null });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const changed = ops.map((op, index) => op.type === ' ' ? -1 : index).filter(index => index >= 0);
|
|
682
|
+
const groups = [];
|
|
683
|
+
for (const index of changed) {
|
|
684
|
+
const group = groups.at(-1);
|
|
685
|
+
if (!group || index - group.at(-1) > 6) groups.push([index]);
|
|
686
|
+
else group.push(index);
|
|
687
|
+
}
|
|
688
|
+
const hunks = groups.map(group => {
|
|
689
|
+
const slice = ops.slice(Math.max(0, group[0] - 3), Math.min(ops.length, group.at(-1) + 4));
|
|
690
|
+
const oldStart = slice.find(op => op.oldLine !== null)?.oldLine ?? oldLine;
|
|
691
|
+
const newStart = slice.find(op => op.newLine !== null)?.newLine ?? newLine;
|
|
692
|
+
const oldCount = slice.filter(op => op.type !== '+').length;
|
|
693
|
+
const newCount = slice.filter(op => op.type !== '-').length;
|
|
694
|
+
return [`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, ...slice.map(op => `${op.type}${op.text}`)].join('\n');
|
|
695
|
+
});
|
|
696
|
+
return [`Update ${relName}`, ...hunks].join('\n');
|
|
448
697
|
}
|
|
449
698
|
|
|
450
699
|
function previewLines(text, marker, maxLines = 60) {
|
|
@@ -526,16 +775,12 @@ function parseMoveArgument(toolArg) {
|
|
|
526
775
|
return { source: source.trim(), destination: destination.trim() };
|
|
527
776
|
}
|
|
528
777
|
|
|
529
|
-
function
|
|
530
|
-
return new Promise(resolve => {
|
|
531
|
-
const child =
|
|
778
|
+
function runExecutable(file, args, cwd, signal, maxBuffer = 8 * 1024 * 1024) {
|
|
779
|
+
return new Promise((resolve, reject) => {
|
|
780
|
+
const child = execFile(file, args, { cwd, windowsHide: true, maxBuffer }, (error, stdout, stderr) => {
|
|
532
781
|
signal?.removeEventListener('abort', abort);
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
stdout: String(stdout || ''),
|
|
536
|
-
stderr: String(stderr || ''),
|
|
537
|
-
error
|
|
538
|
-
});
|
|
782
|
+
if (error && error.code === 'ENOENT') reject(error);
|
|
783
|
+
else resolve({ exitCode: typeof error?.code === 'number' ? error.code : (error ? 1 : 0), stdout: String(stdout || ''), stderr: String(stderr || ''), error });
|
|
539
784
|
});
|
|
540
785
|
const abort = () => child.kill();
|
|
541
786
|
if (signal?.aborted) abort();
|
|
@@ -543,6 +788,118 @@ function runWorkspaceCommand(command, cwd, signal) {
|
|
|
543
788
|
});
|
|
544
789
|
}
|
|
545
790
|
|
|
791
|
+
async function automaticSyntaxCheck(filePath, workspaceRoot, signal) {
|
|
792
|
+
if (!['.js', '.mjs'].includes(path.extname(filePath).toLowerCase())) return '';
|
|
793
|
+
const checked = await runExecutable(process.execPath, ['--check', filePath], workspaceRoot, signal);
|
|
794
|
+
return checked.exitCode === 0
|
|
795
|
+
? '\nAutomatic validation: node --check passed.'
|
|
796
|
+
: `\nAutomatic validation failed (file was not rolled back):\n${checked.stderr || checked.stdout}`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function persistentShell(workspaceRoot) {
|
|
800
|
+
const current = workspaceShellSessions.get(workspaceRoot);
|
|
801
|
+
if (current && !current.child.killed && current.child.exitCode === null) return current;
|
|
802
|
+
const windows = process.platform === 'win32';
|
|
803
|
+
const child = windows
|
|
804
|
+
? spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], { cwd: workspaceRoot, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
805
|
+
: spawn(process.env.SHELL || '/bin/sh', [], { cwd: workspaceRoot, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
806
|
+
const state = { child, windows, queue: Promise.resolve() };
|
|
807
|
+
child.unref();
|
|
808
|
+
child.stdin?.unref?.();
|
|
809
|
+
child.stdout?.unref?.();
|
|
810
|
+
child.stderr?.unref?.();
|
|
811
|
+
child.on('exit', () => { if (workspaceShellSessions.get(workspaceRoot) === state) workspaceShellSessions.delete(workspaceRoot); });
|
|
812
|
+
workspaceShellSessions.set(workspaceRoot, state);
|
|
813
|
+
return state;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export async function closeWorkspaceShell(workspaceRoot) {
|
|
817
|
+
const state = workspaceShellSessions.get(workspaceRoot);
|
|
818
|
+
if (state) {
|
|
819
|
+
workspaceShellSessions.delete(workspaceRoot);
|
|
820
|
+
if (state.child.exitCode === null && !state.child.killed) {
|
|
821
|
+
await new Promise(resolve => {
|
|
822
|
+
const timer = setTimeout(resolve, 2000);
|
|
823
|
+
state.child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
824
|
+
state.child.kill();
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
workspaceShellCwds.delete(workspaceRoot);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function runWorkspaceCommand(command, workspaceRoot, signal, timeoutMs = 120000) {
|
|
832
|
+
const state = persistentShell(workspaceRoot);
|
|
833
|
+
const run = () => new Promise(resolve => {
|
|
834
|
+
const marker = `__DAVE_COMMAND_DONE_${crypto.randomBytes(8).toString('hex')}__:`;
|
|
835
|
+
const limit = 8 * 1024 * 1024;
|
|
836
|
+
let stdout = '';
|
|
837
|
+
let stderr = '';
|
|
838
|
+
let truncated = false;
|
|
839
|
+
let settled = false;
|
|
840
|
+
const append = (target, chunk) => {
|
|
841
|
+
const next = target + String(chunk || '');
|
|
842
|
+
if (next.length <= limit) return next;
|
|
843
|
+
truncated = true;
|
|
844
|
+
return next.slice(0, limit);
|
|
845
|
+
};
|
|
846
|
+
const cleanup = () => {
|
|
847
|
+
clearTimeout(timer);
|
|
848
|
+
state.child.stdout.off('data', onStdout);
|
|
849
|
+
state.child.stderr.off('data', onStderr);
|
|
850
|
+
state.child.off('exit', onExit);
|
|
851
|
+
signal?.removeEventListener('abort', abort);
|
|
852
|
+
};
|
|
853
|
+
const finish = (exitCode, extraError = '') => {
|
|
854
|
+
if (settled) return;
|
|
855
|
+
settled = true;
|
|
856
|
+
cleanup();
|
|
857
|
+
resolve({
|
|
858
|
+
exitCode,
|
|
859
|
+
stdout: `${stdout.replace(new RegExp(`\\r?\\n?${marker}-?\\d+\\r?\\n?`), '')}${truncated ? '\n[Command output truncated at 8 MiB.]' : ''}`,
|
|
860
|
+
stderr: `${stderr}${extraError}`,
|
|
861
|
+
truncated
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
const onStdout = chunk => {
|
|
865
|
+
stdout = append(stdout, chunk);
|
|
866
|
+
const match = stdout.match(new RegExp(`${marker}(-?\\d+)`));
|
|
867
|
+
if (match) finish(Number(match[1]) || 0);
|
|
868
|
+
};
|
|
869
|
+
const onStderr = chunk => { stderr = append(stderr, chunk); };
|
|
870
|
+
const onExit = code => finish(code ?? 1, '\nPersistent shell exited unexpectedly.');
|
|
871
|
+
const abort = () => { state.child.kill(); finish(130, '\nCommand cancelled.'); };
|
|
872
|
+
const timer = setTimeout(() => { state.child.kill(); finish(124, `\nCommand timed out after ${timeoutMs}ms.`); }, timeoutMs);
|
|
873
|
+
state.child.stdout.on('data', onStdout);
|
|
874
|
+
state.child.stderr.on('data', onStderr);
|
|
875
|
+
state.child.once('exit', onExit);
|
|
876
|
+
if (signal?.aborted) return abort();
|
|
877
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
878
|
+
const trailer = state.windows
|
|
879
|
+
? `\nif ($?) { $daveCode = 0 } elseif ($LASTEXITCODE -is [int]) { $daveCode = $LASTEXITCODE } else { $daveCode = 1 }; [Console]::Out.WriteLine('${marker}' + $daveCode)\n`
|
|
880
|
+
: `\ndave_code=$?; printf '${marker}%s\\n' "$dave_code"\n`;
|
|
881
|
+
state.child.stdin.write(`${command}\n${trailer}`);
|
|
882
|
+
});
|
|
883
|
+
const task = state.queue.then(run, run);
|
|
884
|
+
state.queue = task.catch(() => {});
|
|
885
|
+
return task;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function startBackgroundCommand(command, cwd) {
|
|
889
|
+
const taskId = `task-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
890
|
+
const child = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
891
|
+
const task = { id: taskId, command, cwd, child, output: '', exitCode: null, startedAt: Date.now() };
|
|
892
|
+
const append = chunk => {
|
|
893
|
+
task.output += String(chunk || '');
|
|
894
|
+
if (task.output.length > 8 * 1024 * 1024) task.output = `[Earlier output truncated]\n${task.output.slice(-8 * 1024 * 1024)}`;
|
|
895
|
+
};
|
|
896
|
+
child.stdout?.on('data', append);
|
|
897
|
+
child.stderr?.on('data', append);
|
|
898
|
+
child.on('exit', code => { task.exitCode = code ?? 1; task.endedAt = Date.now(); });
|
|
899
|
+
backgroundTasks.set(taskId, task);
|
|
900
|
+
return task;
|
|
901
|
+
}
|
|
902
|
+
|
|
546
903
|
export async function executeToolCall({
|
|
547
904
|
toolName,
|
|
548
905
|
toolArg,
|
|
@@ -553,12 +910,15 @@ export async function executeToolCall({
|
|
|
553
910
|
lang = 'cn',
|
|
554
911
|
mode = 'chat',
|
|
555
912
|
signal,
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
913
|
+
readTracker,
|
|
914
|
+
permissionPolicy = { mode: 'ask', rules: [] },
|
|
915
|
+
contextPlan,
|
|
916
|
+
remainingBudgetTokens,
|
|
917
|
+
readNotebook = async () => null
|
|
559
918
|
}) {
|
|
560
|
-
const
|
|
561
|
-
const
|
|
919
|
+
const budgetTokens = Math.max(4096, Number(contextPlan?.budgetTokens) || 24000);
|
|
920
|
+
const remaining = Math.max(500, Number(remainingBudgetTokens) || budgetTokens);
|
|
921
|
+
const maxToolChars = Math.max(4000, Math.min(240000, Math.floor(Math.min(budgetTokens, remaining) * 2.8)));
|
|
562
922
|
const cn = lang === 'cn';
|
|
563
923
|
const startedAt = Date.now();
|
|
564
924
|
|
|
@@ -579,11 +939,50 @@ export async function executeToolCall({
|
|
|
579
939
|
return { ok: false, result: modelResult, modelResult, displaySummary, status: 'Failed' };
|
|
580
940
|
}
|
|
581
941
|
|
|
942
|
+
const sessionAllowedTools = permissionPolicy.allowedTools instanceof Set
|
|
943
|
+
? permissionPolicy.allowedTools
|
|
944
|
+
: (permissionPolicy.allowedTools = new Set(permissionPolicy.allowedTools || []));
|
|
945
|
+
|
|
946
|
+
function ruleMatches(rule, request) {
|
|
947
|
+
const match = String(rule || '').match(/^([A-Z_]+)\((.*)\)$/);
|
|
948
|
+
if (!match || match[1] !== toolName) return false;
|
|
949
|
+
const value = toolName === 'RUN_COMMAND'
|
|
950
|
+
? String(toolArg?.command ?? toolArg ?? '')
|
|
951
|
+
: String(request.target || toolArg?.path || toolArg?.filePath || toolArg?.source || '');
|
|
952
|
+
const escaped = match[2].replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '§§').replace(/\*/g, '[^/\\\\]*').replace(/§§/g, '.*');
|
|
953
|
+
return new RegExp(`^${escaped}$`, process.platform === 'win32' ? 'i' : '').test(value.replace(/\\/g, '/'));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function authorize(request) {
|
|
957
|
+
if (!MUTATING_TOOLS.has(toolName) || ['DELETE_PATH', 'MOVE_PATH'].includes(toolName)) {
|
|
958
|
+
return Boolean(await requestPermission(request));
|
|
959
|
+
}
|
|
960
|
+
if (sessionAllowedTools.has(toolName)) return true;
|
|
961
|
+
if ((permissionPolicy.rules || []).some(rule => ruleMatches(rule, request))) return true;
|
|
962
|
+
const sessionPrompt = String(request.prompt || '').replace(/\(y\/n\)/gi, '(y/a/n)');
|
|
963
|
+
const decision = await requestPermission({ ...request, prompt: sessionPrompt, allowSession: true });
|
|
964
|
+
const allowed = decision === true || decision === 'always' || decision === 'a';
|
|
965
|
+
if (allowed && (permissionPolicy.mode === 'acceptEdits' || decision === 'always' || decision === 'a')) {
|
|
966
|
+
sessionAllowedTools.add(toolName);
|
|
967
|
+
}
|
|
968
|
+
return allowed;
|
|
969
|
+
}
|
|
970
|
+
|
|
582
971
|
try {
|
|
583
972
|
if (MUTATING_TOOLS.has(toolName) && mode !== 'code') {
|
|
584
973
|
throw new Error(`Workspace mutation is blocked in ${mode} mode. Use /code <request> to authorize one coding turn.`);
|
|
585
974
|
}
|
|
586
975
|
|
|
976
|
+
if (toolName === 'UPDATE_TODOS') {
|
|
977
|
+
const todos = Array.isArray(toolArg?.todos) ? toolArg.todos.slice(0, 30).map(todo => ({
|
|
978
|
+
content: String(todo.content || '').trim().slice(0, 300),
|
|
979
|
+
status: ['pending', 'in_progress', 'completed'].includes(todo.status) ? todo.status : 'pending'
|
|
980
|
+
})).filter(todo => todo.content) : [];
|
|
981
|
+
workspaceTodos.set(workspaceRoot, todos);
|
|
982
|
+
event('todos.updated', { todos });
|
|
983
|
+
return success(`Updated task checklist: ${todos.length} items.`, cn ? `已更新任务清单 · ${todos.length} 项` : `Updated checklist · ${todos.length} items`, { count: todos.length });
|
|
984
|
+
}
|
|
985
|
+
|
|
587
986
|
if (toolName === 'LIST_DIR') {
|
|
588
987
|
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
589
988
|
const resolvedPath = resolveExistingWorkspacePath(String(rawPath || '.').trim() || '.', workspaceRoot);
|
|
@@ -604,17 +1003,48 @@ export async function executeToolCall({
|
|
|
604
1003
|
);
|
|
605
1004
|
}
|
|
606
1005
|
|
|
1006
|
+
if (toolName === 'INSPECT_FILE') {
|
|
1007
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
1008
|
+
const resolvedPath = resolveExistingWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
1009
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
1010
|
+
if (isSensitivePath(relPath) || isSensitivePath(fs.realpathSync.native(resolvedPath))) {
|
|
1011
|
+
throw new Error(`INSPECT_FILE refuses sensitive paths: ${relPath}`);
|
|
1012
|
+
}
|
|
1013
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
1014
|
+
if (!stat.isFile()) throw new Error(`INSPECT_FILE target is not a file: ${relPath}`);
|
|
1015
|
+
if (stat.size > 2 * 1024 * 1024) throw new Error(`File is too large to inspect: ${stat.size} bytes.`);
|
|
1016
|
+
if (await looksBinary(resolvedPath)) throw new Error(`INSPECT_FILE refuses binary content: ${relPath}`);
|
|
1017
|
+
const identity = `${stat.size}:${stat.mtimeMs}`;
|
|
1018
|
+
const tracked = trackerEntry(readTracker, relPath, identity);
|
|
1019
|
+
if (tracked?.outline) {
|
|
1020
|
+
return success(
|
|
1021
|
+
tracked.outlineContent || `[Cached outline unavailable; inspect ${relPath} again.]`,
|
|
1022
|
+
cn ? `已复用 ${relPath} 的文件大纲` : `Reused outline for ${relPath}`,
|
|
1023
|
+
{ target: relPath, cached: true, outline: true }
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
event('tool.started', { target: relPath, label: cn ? `正在分析 ${relPath} 的结构` : `Inspecting structure of ${relPath}` });
|
|
1027
|
+
const content = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
1028
|
+
const outline = buildFileOutline(content, relPath);
|
|
1029
|
+
if (tracked) { tracked.outline = true; tracked.outlineContent = outline; }
|
|
1030
|
+
return success(
|
|
1031
|
+
outline,
|
|
1032
|
+
cn ? `已建立 ${relPath} 的文件大纲` : `Outlined ${relPath}`,
|
|
1033
|
+
{ target: relPath, lineCount: content.split('\n').length, outline: true }
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
607
1037
|
if (toolName === 'READ_FILE') {
|
|
608
1038
|
const { filePath, startLine, endLine, hasRange } = parseReadArgument(toolArg);
|
|
609
1039
|
const resolvedPath = resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
610
1040
|
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
611
|
-
const rangeText = hasRange ? `:${startLine}-${endLine}` : '';
|
|
1041
|
+
const rangeText = hasRange ? `:${startLine}-${endLine ?? 'EOF'}` : '';
|
|
612
1042
|
const target = `${relPath}${rangeText}`;
|
|
613
1043
|
const realReadTarget = fs.realpathSync.native(resolvedPath);
|
|
614
1044
|
if (isSensitivePath(relPath) || isSensitivePath(realReadTarget)) {
|
|
615
1045
|
const displaySummary = cn ? `敏感文件读取确认 · ${relPath}` : `Sensitive file read · ${relPath}`;
|
|
616
1046
|
event('permission.requested', { kind: 'sensitive-read', target: relPath, displaySummary });
|
|
617
|
-
const allowed = await
|
|
1047
|
+
const allowed = await authorize({
|
|
618
1048
|
kind: 'sensitive-read',
|
|
619
1049
|
prompt: cn
|
|
620
1050
|
? `读取 ${relPath} 会将内容发送给模型,是否允许?(y/n): `
|
|
@@ -631,87 +1061,83 @@ export async function executeToolCall({
|
|
|
631
1061
|
const stat = await fs.promises.stat(resolvedPath);
|
|
632
1062
|
if (!stat.isFile()) throw new Error(`READ_FILE target is not a file: ${relPath}`);
|
|
633
1063
|
if (await looksBinary(resolvedPath)) throw new Error(`READ_FILE refuses binary content: ${relPath}`);
|
|
1064
|
+
const identity = `${stat.size}:${stat.mtimeMs}`;
|
|
1065
|
+
const tracked = trackerEntry(readTracker, relPath, identity);
|
|
634
1066
|
|
|
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);
|
|
1067
|
+
if (!hasRange && tracked?.full) {
|
|
640
1068
|
return success(
|
|
641
|
-
|
|
642
|
-
cn ?
|
|
643
|
-
{ target
|
|
1069
|
+
tracked.fullContent,
|
|
1070
|
+
cn ? `已复用 ${relPath} · 文件未变化` : `Reused ${relPath} · unchanged`,
|
|
1071
|
+
{ target: relPath, cached: true }
|
|
644
1072
|
);
|
|
645
1073
|
}
|
|
646
1074
|
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
});
|
|
1075
|
+
if (hasRange) {
|
|
1076
|
+
const rangeKey = `${startLine}:${endLine ?? '*'}`;
|
|
1077
|
+
const cachedRange = tracked?.rangeContents?.get(rangeKey);
|
|
1078
|
+
if (cachedRange) {
|
|
681
1079
|
return success(
|
|
682
|
-
|
|
683
|
-
cn ?
|
|
684
|
-
{ target
|
|
1080
|
+
cachedRange,
|
|
1081
|
+
cn ? `已复用 ${target} · 文件未变化` : `Reused ${target} · unchanged`,
|
|
1082
|
+
{ target, cached: true }
|
|
685
1083
|
);
|
|
686
1084
|
}
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1085
|
+
const ranged = await readLinePage(resolvedPath, startLine, endLine, maxToolChars);
|
|
1086
|
+
const rangedResult = ranged.content;
|
|
1087
|
+
if (tracked) {
|
|
1088
|
+
tracked.ranges.push([startLine, ranged.nextCursor ? ranged.nextCursor - 1 : ranged.lastLine]);
|
|
1089
|
+
if (!tracked.rangeContents) tracked.rangeContents = new Map();
|
|
1090
|
+
tracked.rangeContents.set(rangeKey, rangedResult);
|
|
1091
|
+
}
|
|
1092
|
+
return success(
|
|
1093
|
+
rangedResult,
|
|
1094
|
+
cn ? `已读取 ${target} · ${ranged.lineCount} 行` : `Read ${target} · ${ranged.lineCount} lines`,
|
|
1095
|
+
{ target, lineCount: ranged.lineCount, totalLines: null, nextCursor: ranged.nextCursor }
|
|
1096
|
+
);
|
|
696
1097
|
}
|
|
697
1098
|
|
|
698
|
-
if (
|
|
699
|
-
const
|
|
700
|
-
|
|
1099
|
+
if (stat.size > maxToolChars) {
|
|
1100
|
+
const page = await readLinePage(resolvedPath, 1, null, maxToolChars);
|
|
1101
|
+
if (tracked) {
|
|
1102
|
+
tracked.full = false;
|
|
1103
|
+
tracked.ranges.push([1, page.nextCursor ? page.nextCursor - 1 : page.lastLine]);
|
|
1104
|
+
tracked.rangeContents.set('1:*', page.content);
|
|
1105
|
+
}
|
|
701
1106
|
return success(
|
|
702
|
-
|
|
703
|
-
cn ? `已读取 ${relPath} ·
|
|
704
|
-
{ target: relPath, lineCount:
|
|
1107
|
+
page.content,
|
|
1108
|
+
cn ? `已读取 ${relPath} · ${page.lineCount} 行${page.nextCursor ? ' · 可继续' : ''}` : `Read ${relPath} · ${page.lineCount} lines${page.nextCursor ? ' · continuation available' : ''}`,
|
|
1109
|
+
{ target: relPath, lineCount: page.lineCount, totalLines: null, nextCursor: page.nextCursor, paged: true }
|
|
705
1110
|
);
|
|
706
1111
|
}
|
|
707
|
-
|
|
1112
|
+
const content = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
1113
|
+
const lines = content.split('\n');
|
|
1114
|
+
const fullResult = truncateToolResult(content, maxToolChars);
|
|
1115
|
+
if (tracked) { tracked.full = true; tracked.fullContent = fullResult; }
|
|
708
1116
|
return success(
|
|
709
|
-
|
|
1117
|
+
fullResult,
|
|
710
1118
|
cn ? `已读取 ${relPath} · ${lines.length} 行` : `Read ${relPath} · ${lines.length} lines`,
|
|
711
1119
|
{ target: relPath, lineCount: lines.length, totalLines: lines.length }
|
|
712
1120
|
);
|
|
713
1121
|
}
|
|
714
1122
|
|
|
1123
|
+
if (toolName === 'READ_NOTEBOOK') {
|
|
1124
|
+
event('tool.started', { label: cn ? '正在按需读取项目笔记' : 'Reading project notebook on demand' });
|
|
1125
|
+
const request = toolArg && typeof toolArg === 'object' ? toolArg : {};
|
|
1126
|
+
const requestedMax = Math.max(100, Math.min(4000, Number(request.maxTokens) || 2000));
|
|
1127
|
+
const allowedMax = Math.max(100, Math.min(requestedMax, remaining));
|
|
1128
|
+
const result = await readNotebook({
|
|
1129
|
+
sections: Array.isArray(request.sections) ? request.sections.map(String).slice(0, 12) : [],
|
|
1130
|
+
query: String(request.query || ''),
|
|
1131
|
+
maxTokens: allowedMax
|
|
1132
|
+
});
|
|
1133
|
+
if (!result) throw new Error('No private project notebook is available for this workspace.');
|
|
1134
|
+
return success(
|
|
1135
|
+
truncateToolResult(String(result), Math.min(maxToolChars, allowedMax * 4)),
|
|
1136
|
+
cn ? `已读取项目笔记 · 上限 ${allowedMax} Token` : `Read project notebook · up to ${allowedMax} tokens`,
|
|
1137
|
+
{ maxTokens: allowedMax }
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
715
1141
|
if (toolName === 'EDIT_FILE') {
|
|
716
1142
|
const { filePath, oldText, newText, replaceAll } = parseEditArgument(toolArg);
|
|
717
1143
|
if (!filePath) throw new Error('EDIT_FILE path is empty.');
|
|
@@ -724,6 +1150,20 @@ export async function executeToolCall({
|
|
|
724
1150
|
const normalizedContent = oldContent.replace(/\r\n/g, '\n');
|
|
725
1151
|
const normalizedOld = String(oldText).replace(/\r\n/g, '\n');
|
|
726
1152
|
const normalizedNew = String(newText).replace(/\r\n/g, '\n');
|
|
1153
|
+
if (readTracker?.files) {
|
|
1154
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
1155
|
+
const identity = `${stat.size}:${stat.mtimeMs}`;
|
|
1156
|
+
const entry = readTracker.files.get(relPath);
|
|
1157
|
+
if (!entry || entry.identity !== identity) {
|
|
1158
|
+
throw new Error(`EDIT_FILE requires reading ${relPath} first. Use READ_FILE or INSPECT_FILE.`);
|
|
1159
|
+
}
|
|
1160
|
+
const matchOffset = normalizedContent.indexOf(normalizedOld);
|
|
1161
|
+
const startLine = normalizedContent.slice(0, Math.max(0, matchOffset)).split('\n').length;
|
|
1162
|
+
const endLine = startLine + normalizedOld.split('\n').length - 1;
|
|
1163
|
+
if (!rangeAlreadyRead(entry, startLine, endLine)) {
|
|
1164
|
+
throw new Error(`EDIT_FILE requires reading ${relPath}:${startLine}-${endLine} first with READ_FILE.`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
727
1167
|
const matches = countOccurrences(normalizedContent, normalizedOld);
|
|
728
1168
|
if (matches === 0) {
|
|
729
1169
|
throw new Error(`EDIT_FILE search text was not found in ${relPath}. Read the exact current lines and retry.`);
|
|
@@ -741,7 +1181,7 @@ export async function executeToolCall({
|
|
|
741
1181
|
? `修改 ${relPath} · ${replaceAll ? matches : 1} 处`
|
|
742
1182
|
: `Edit ${relPath} · ${replaceAll ? matches : 1} replacement${matches === 1 ? '' : 's'}`;
|
|
743
1183
|
event('permission.requested', { kind: 'edit', target: relPath, displaySummary, preview });
|
|
744
|
-
const proceed = await
|
|
1184
|
+
const proceed = await authorize({
|
|
745
1185
|
kind: 'edit',
|
|
746
1186
|
prompt: cn ? '应用此局部修改?(y/n): ' : 'Apply this focused edit? (y/n): ',
|
|
747
1187
|
displaySummary,
|
|
@@ -766,8 +1206,10 @@ export async function executeToolCall({
|
|
|
766
1206
|
if (contentHash(currentContent) !== expectedHash) {
|
|
767
1207
|
throw new Error(`${relPath} changed while awaiting confirmation. Read the file again before editing.`);
|
|
768
1208
|
}
|
|
1209
|
+
await recordMutation(workspaceRoot, { kind: 'write', path: relPath, existed: true, content: currentContent });
|
|
769
1210
|
await fs.promises.writeFile(resolvedPath, updatedContent, 'utf8');
|
|
770
|
-
const
|
|
1211
|
+
const validation = await automaticSyntaxCheck(resolvedPath, workspaceRoot, signal);
|
|
1212
|
+
const modelResult = `Successfully edited ${relPath}: ${replaceAll ? matches : 1} replacement(s).${validation}`;
|
|
771
1213
|
return success(modelResult, cn ? `已修改 ${relPath} · ${replaceAll ? matches : 1} 处` : `Edited ${relPath} · ${replaceAll ? matches : 1} replacement(s)`, {
|
|
772
1214
|
target: relPath,
|
|
773
1215
|
replacements: replaceAll ? matches : 1
|
|
@@ -791,12 +1233,19 @@ export async function executeToolCall({
|
|
|
791
1233
|
const oldContent = existed ? await fs.promises.readFile(resolvedPath, 'utf8') : '';
|
|
792
1234
|
const expectedHash = existed ? contentHash(oldContent) : null;
|
|
793
1235
|
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
1236
|
+
if (existed && readTracker?.files) {
|
|
1237
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
1238
|
+
const entry = readTracker.files.get(relPath);
|
|
1239
|
+
if (!entry || entry.identity !== `${stat.size}:${stat.mtimeMs}`) {
|
|
1240
|
+
throw new Error(`WRITE_FILE requires reading ${relPath} first. Use READ_FILE or INSPECT_FILE.`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
794
1243
|
const preview = buildWritePreview(resolvedPath, oldContent, fileContentPart, existed);
|
|
795
1244
|
const displaySummary = cn
|
|
796
1245
|
? `${existed ? '修改' : '创建'} ${relPath}`
|
|
797
1246
|
: `${existed ? 'Update' : 'Create'} ${relPath}`;
|
|
798
1247
|
event('permission.requested', { kind: 'write', target: relPath, displaySummary, preview });
|
|
799
|
-
const proceed = await
|
|
1248
|
+
const proceed = await authorize({
|
|
800
1249
|
kind: 'write',
|
|
801
1250
|
prompt: cn ? '应用此文件更改?(y/n): ' : 'Apply this file change? (y/n): ',
|
|
802
1251
|
displaySummary,
|
|
@@ -825,9 +1274,11 @@ export async function executeToolCall({
|
|
|
825
1274
|
} else if (fs.existsSync(resolvedPath)) {
|
|
826
1275
|
throw new Error(`${relPath} was created while awaiting confirmation. Read it before writing.`);
|
|
827
1276
|
}
|
|
1277
|
+
await recordMutation(workspaceRoot, { kind: 'write', path: relPath, existed, ...(existed ? { content: oldContent } : {}) });
|
|
828
1278
|
await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
829
1279
|
await fs.promises.writeFile(resolvedPath, fileContentPart, 'utf8');
|
|
830
|
-
const
|
|
1280
|
+
const validation = await automaticSyntaxCheck(resolvedPath, workspaceRoot, signal);
|
|
1281
|
+
const modelResult = `Successfully wrote file: ${relPath}${validation}`;
|
|
831
1282
|
return success(
|
|
832
1283
|
modelResult,
|
|
833
1284
|
cn ? `已${existed ? '修改' : '创建'} ${relPath}` : `${existed ? 'Updated' : 'Created'} ${relPath}`,
|
|
@@ -842,7 +1293,7 @@ export async function executeToolCall({
|
|
|
842
1293
|
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
843
1294
|
const displaySummary = cn ? `创建目录 ${relPath}` : `Create directory ${relPath}`;
|
|
844
1295
|
event('permission.requested', { kind: 'mkdir', target: relPath, displaySummary });
|
|
845
|
-
const proceed = await
|
|
1296
|
+
const proceed = await authorize({
|
|
846
1297
|
kind: 'mkdir',
|
|
847
1298
|
prompt: cn ? `创建目录 ${relPath}?(y/n): ` : `Create directory ${relPath}? (y/n): `,
|
|
848
1299
|
displaySummary
|
|
@@ -857,6 +1308,7 @@ export async function executeToolCall({
|
|
|
857
1308
|
if (!directoryExisted && fs.existsSync(resolvedPath)) {
|
|
858
1309
|
throw new Error(`${relPath} appeared while awaiting confirmation. Inspect it before continuing.`);
|
|
859
1310
|
}
|
|
1311
|
+
await recordMutation(workspaceRoot, { kind: 'mkdir', path: relPath, existed: directoryExisted });
|
|
860
1312
|
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
|
861
1313
|
return success(
|
|
862
1314
|
`Successfully created directory: ${relPath}`,
|
|
@@ -876,7 +1328,7 @@ export async function executeToolCall({
|
|
|
876
1328
|
if (fs.existsSync(destinationPath)) throw new Error(`MOVE_PATH destination already exists: ${destinationRel}`);
|
|
877
1329
|
const displaySummary = cn ? `移动 ${sourceRel} → ${destinationRel}` : `Move ${sourceRel} -> ${destinationRel}`;
|
|
878
1330
|
event('permission.requested', { kind: 'move', target: sourceRel, displaySummary });
|
|
879
|
-
const proceed = await
|
|
1331
|
+
const proceed = await authorize({
|
|
880
1332
|
kind: 'move',
|
|
881
1333
|
prompt: cn ? `移动到 ${destinationRel}?(y/n): ` : `Move to ${destinationRel}? (y/n): `,
|
|
882
1334
|
displaySummary
|
|
@@ -894,6 +1346,7 @@ export async function executeToolCall({
|
|
|
894
1346
|
if (currentSourceIdentity.dev !== sourceIdentity.dev || currentSourceIdentity.ino !== sourceIdentity.ino || currentSourceIdentity.mtimeMs !== sourceIdentity.mtimeMs || currentSourceIdentity.size !== sourceIdentity.size) {
|
|
895
1347
|
throw new Error(`${sourceRel} changed while awaiting confirmation. Inspect it again before moving.`);
|
|
896
1348
|
}
|
|
1349
|
+
await recordMutation(workspaceRoot, { kind: 'move', source: sourceRel, destination: destinationRel });
|
|
897
1350
|
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
898
1351
|
await fs.promises.rename(sourcePath, destinationPath);
|
|
899
1352
|
return success(`Successfully moved ${sourceRel} to ${destinationRel}`, displaySummary, { source: sourceRel, destination: destinationRel });
|
|
@@ -912,7 +1365,7 @@ export async function executeToolCall({
|
|
|
912
1365
|
}
|
|
913
1366
|
const displaySummary = cn ? `删除 ${relPath}` : `Delete ${relPath}`;
|
|
914
1367
|
event('permission.requested', { kind: 'delete', target: relPath, displaySummary });
|
|
915
|
-
const proceed = await
|
|
1368
|
+
const proceed = await authorize({
|
|
916
1369
|
kind: 'delete',
|
|
917
1370
|
prompt: cn ? `确认删除 ${relPath}?(y/n): ` : `Delete ${relPath}? (y/n): `,
|
|
918
1371
|
displaySummary
|
|
@@ -928,18 +1381,40 @@ export async function executeToolCall({
|
|
|
928
1381
|
if (currentDeleteIdentity.dev !== deleteIdentity.dev || currentDeleteIdentity.ino !== deleteIdentity.ino || currentDeleteIdentity.mtimeMs !== deleteIdentity.mtimeMs || currentDeleteIdentity.size !== deleteIdentity.size) {
|
|
929
1382
|
throw new Error(`${relPath} changed while awaiting confirmation. Inspect it again before deleting.`);
|
|
930
1383
|
}
|
|
1384
|
+
await recordMutation(workspaceRoot, {
|
|
1385
|
+
kind: 'delete', path: relPath, directory: stat.isDirectory(),
|
|
1386
|
+
...(!stat.isDirectory() ? { content: await fs.promises.readFile(resolvedPath) } : {})
|
|
1387
|
+
});
|
|
931
1388
|
if (stat.isDirectory()) await fs.promises.rmdir(resolvedPath);
|
|
932
1389
|
else await fs.promises.unlink(resolvedPath);
|
|
933
1390
|
return success(`Successfully deleted: ${relPath}`, cn ? `已删除 ${relPath}` : `Deleted ${relPath}`, { target: relPath });
|
|
934
1391
|
}
|
|
935
1392
|
|
|
1393
|
+
if (toolName === 'READ_TASK_OUTPUT') {
|
|
1394
|
+
const taskId = String(toolArg?.taskId ?? toolArg ?? '').trim();
|
|
1395
|
+
const task = backgroundTasks.get(taskId);
|
|
1396
|
+
if (!task) throw new Error(`Unknown background task: ${taskId}`);
|
|
1397
|
+
const status = task.exitCode === null ? 'running' : `exited ${task.exitCode}`;
|
|
1398
|
+
return success(truncateToolResult(`Task: ${taskId}\nStatus: ${status}\n${task.output || '(no output yet)'}`, maxToolChars), cn ? `后台任务 ${taskId} · ${status}` : `Background task ${taskId} · ${status}`, { taskId, status, exitCode: task.exitCode });
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (toolName === 'KILL_TASK') {
|
|
1402
|
+
const taskId = String(toolArg?.taskId ?? toolArg ?? '').trim();
|
|
1403
|
+
const task = backgroundTasks.get(taskId);
|
|
1404
|
+
if (!task) throw new Error(`Unknown background task: ${taskId}`);
|
|
1405
|
+
const proceed = await authorize({ kind: 'kill-task', target: taskId, prompt: cn ? `停止后台任务 ${taskId}?(y/n): ` : `Stop background task ${taskId}? (y/n): `, displaySummary: `Kill ${taskId}` });
|
|
1406
|
+
if (!proceed) return { ok: true, cancelled: true, result: `Task stop cancelled: ${taskId}`, modelResult: `Task stop cancelled: ${taskId}`, displaySummary: 'Cancelled', status: 'Cancelled' };
|
|
1407
|
+
if (task.exitCode === null) task.child.kill();
|
|
1408
|
+
return success(`Stopped background task: ${taskId}`, cn ? `已停止 ${taskId}` : `Stopped ${taskId}`, { taskId });
|
|
1409
|
+
}
|
|
1410
|
+
|
|
936
1411
|
if (toolName === 'RUN_COMMAND') {
|
|
937
1412
|
const rawCommand = toolArg && typeof toolArg === 'object' ? toolArg.command : toolArg;
|
|
938
1413
|
const command = String(rawCommand || '').trim();
|
|
939
1414
|
if (!command) throw new Error('RUN_COMMAND command is empty.');
|
|
940
1415
|
const displaySummary = cn ? `运行命令: ${command}` : `Run command: ${command}`;
|
|
941
1416
|
event('permission.requested', { kind: 'command', displaySummary, preview: `$ ${command}` });
|
|
942
|
-
const proceed = await
|
|
1417
|
+
const proceed = await authorize({
|
|
943
1418
|
kind: 'command',
|
|
944
1419
|
prompt: cn
|
|
945
1420
|
? '此命令不受沙箱限制,可能影响工作区外部。仍要运行?(y/n): '
|
|
@@ -953,7 +1428,25 @@ export async function executeToolCall({
|
|
|
953
1428
|
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
954
1429
|
}
|
|
955
1430
|
event('tool.started', { label: cn ? `正在运行 ${command}` : `Running ${command}` });
|
|
956
|
-
const
|
|
1431
|
+
const shellCwd = workspaceShellCwds.get(workspaceRoot) || workspaceRoot;
|
|
1432
|
+
const cdMatch = command.match(/^cd(?:\s+(.+))?$/i);
|
|
1433
|
+
if (cdMatch) {
|
|
1434
|
+
const nextCwd = resolveExistingWorkspacePath(path.resolve(shellCwd, cdMatch[1] || '.'), workspaceRoot);
|
|
1435
|
+
if (!(await fs.promises.stat(nextCwd)).isDirectory()) throw new Error(`Not a directory: ${cdMatch[1]}`);
|
|
1436
|
+
const setLocation = process.platform === 'win32'
|
|
1437
|
+
? `Set-Location -LiteralPath '${nextCwd.replace(/'/g, "''")}'`
|
|
1438
|
+
: `cd '${nextCwd.replace(/'/g, `'"'"'`)}'`;
|
|
1439
|
+
const changed = await runWorkspaceCommand(setLocation, workspaceRoot, signal, 10000);
|
|
1440
|
+
if (changed.exitCode !== 0) throw new Error(changed.stderr || `Could not change shell directory to ${nextCwd}`);
|
|
1441
|
+
workspaceShellCwds.set(workspaceRoot, nextCwd);
|
|
1442
|
+
return success(`Shell working directory: ${path.relative(workspaceRoot, nextCwd) || '.'}`, cn ? '已更新 Shell 工作目录' : 'Shell directory updated', { cwd: nextCwd });
|
|
1443
|
+
}
|
|
1444
|
+
if (toolArg?.runInBackground === true) {
|
|
1445
|
+
const task = startBackgroundCommand(command, shellCwd);
|
|
1446
|
+
return success(`Background task started: ${task.id}\nCommand: ${command}`, cn ? `后台任务已启动 · ${task.id}` : `Background task started · ${task.id}`, { taskId: task.id, background: true });
|
|
1447
|
+
}
|
|
1448
|
+
const timeoutMs = Math.max(1000, Math.min(600000, (Number(toolArg?.timeoutSeconds) || 120) * 1000));
|
|
1449
|
+
const commandResult = await runWorkspaceCommand(command, workspaceRoot, signal, timeoutMs);
|
|
957
1450
|
const combined = [
|
|
958
1451
|
`Command: ${command}`,
|
|
959
1452
|
`Exit code: ${commandResult.exitCode}`,
|
|
@@ -969,11 +1462,73 @@ export async function executeToolCall({
|
|
|
969
1462
|
return success(modelResult, cn ? '命令执行完成' : 'Command completed', { exitCode: 0 });
|
|
970
1463
|
}
|
|
971
1464
|
|
|
1465
|
+
if (toolName === 'GLOB_FILES') {
|
|
1466
|
+
const pattern = String(toolArg?.pattern ?? toolArg ?? '').trim();
|
|
1467
|
+
if (!pattern) throw new Error('GLOB_FILES pattern is empty.');
|
|
1468
|
+
event('tool.started', { label: cn ? `正在匹配文件 ${pattern}` : `Matching files ${pattern}` });
|
|
1469
|
+
let paths;
|
|
1470
|
+
try {
|
|
1471
|
+
const rg = await runExecutable('rg', ['--files', '-g', pattern, '-g', '!.git/**', '-g', '!node_modules/**'], workspaceRoot, signal);
|
|
1472
|
+
paths = rg.stdout.split(/\r?\n/).filter(Boolean);
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1475
|
+
paths = [];
|
|
1476
|
+
const matcher = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '§§').replace(/\*/g, '[^/]*').replace(/§§/g, '.*').replace(/\?/g, '.')}$`, process.platform === 'win32' ? 'i' : '');
|
|
1477
|
+
async function walk(dir) {
|
|
1478
|
+
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
|
|
1479
|
+
if (['.git', 'node_modules'].includes(entry.name)) continue;
|
|
1480
|
+
const full = path.join(dir, entry.name);
|
|
1481
|
+
const rel = path.relative(workspaceRoot, full).replace(/\\/g, '/');
|
|
1482
|
+
if (entry.isDirectory()) await walk(full);
|
|
1483
|
+
else if (entry.isFile() && matcher.test(rel)) paths.push(rel);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
await walk(workspaceRoot);
|
|
1487
|
+
}
|
|
1488
|
+
const rows = (await Promise.all(paths.filter(file => !isSensitivePath(file)).map(async file => {
|
|
1489
|
+
const stat = await fs.promises.stat(path.join(workspaceRoot, file));
|
|
1490
|
+
return { file, mtimeMs: stat.mtimeMs };
|
|
1491
|
+
}))).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 500);
|
|
1492
|
+
const modelResult = rows.length ? rows.map(row => `${row.file}\t${new Date(row.mtimeMs).toISOString()}`).join('\n') : `No files matched ${pattern}`;
|
|
1493
|
+
return success(modelResult, cn ? `匹配 ${pattern} · ${rows.length} 个文件` : `Matched ${pattern} · ${rows.length} files`, { pattern, count: rows.length });
|
|
1494
|
+
}
|
|
1495
|
+
|
|
972
1496
|
if (toolName === 'SEARCH_GREP') {
|
|
973
|
-
const
|
|
1497
|
+
const request = toolArg && typeof toolArg === 'object' ? toolArg : { query: toolArg };
|
|
1498
|
+
const rawQuery = request.pattern ?? request.query;
|
|
974
1499
|
const query = String(rawQuery || '').trim();
|
|
975
1500
|
if (!query) throw new Error('Search query is empty.');
|
|
976
1501
|
event('tool.started', { query, label: cn ? `正在搜索 “${query}”` : `Searching "${query}"` });
|
|
1502
|
+
try {
|
|
1503
|
+
const args = ['--color', 'never', '--no-heading', '--max-count', '50', '-g', '!.git/**', '-g', '!node_modules/**', '-g', '!.env*', '-g', '!*.pem', '-g', '!*.key'];
|
|
1504
|
+
const outputMode = request.outputMode || 'content';
|
|
1505
|
+
if (outputMode === 'files_with_matches') args.push('--files-with-matches');
|
|
1506
|
+
else if (outputMode === 'count') args.push('--count');
|
|
1507
|
+
else args.push('--line-number');
|
|
1508
|
+
if (request.literal === true || (request.pattern === undefined && request.query !== undefined)) args.push('--fixed-strings');
|
|
1509
|
+
if (request.caseInsensitive) args.push('--ignore-case');
|
|
1510
|
+
if (Number(request.contextLines) > 0 && outputMode === 'content') args.push('--context', String(Math.min(20, Number(request.contextLines))));
|
|
1511
|
+
if (request.glob) args.push('--glob', String(request.glob));
|
|
1512
|
+
args.push('--', query, '.');
|
|
1513
|
+
const rg = await runExecutable('rg', args, workspaceRoot, signal);
|
|
1514
|
+
if (![0, 1].includes(rg.exitCode)) throw new Error(`ripgrep failed (${rg.exitCode}): ${rg.stderr.trim()}`);
|
|
1515
|
+
const output = rg.stdout.split(/\r?\n/).filter(line => {
|
|
1516
|
+
if (!line || line === '--') return true;
|
|
1517
|
+
let candidate = line;
|
|
1518
|
+
if (outputMode === 'content') candidate = line.match(/^(.+?)(?::|-)\d+(?::|-)/)?.[1] || line;
|
|
1519
|
+
else if (outputMode === 'count') candidate = line.replace(/:\d+\s*$/, '');
|
|
1520
|
+
candidate = candidate.replace(/^\.\//, '').replace(/^\.\\/, '');
|
|
1521
|
+
return !isSensitivePath(candidate);
|
|
1522
|
+
}).join('\n').trim();
|
|
1523
|
+
const matchCount = output ? output.split(/\r?\n/).length : 0;
|
|
1524
|
+
return success(
|
|
1525
|
+
truncateToolResult(output || `No matches found for "${query}"`, maxToolChars),
|
|
1526
|
+
cn ? `搜索 “${query}” · ${matchCount} 行` : `Searched "${query}" · ${matchCount} lines`,
|
|
1527
|
+
{ query, matchCount, engine: 'ripgrep', outputMode }
|
|
1528
|
+
);
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1531
|
+
}
|
|
977
1532
|
const results = [];
|
|
978
1533
|
let totalMatches = 0;
|
|
979
1534
|
let searchWasPartial = false;
|