dave-code 1.0.4 → 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 +39 -5
- package/bin/aiClient.js +878 -159
- package/bin/check.js +11 -0
- package/bin/cliMenu.js +251 -172
- package/bin/commandRouter.js +70 -0
- package/bin/configManager.js +153 -64
- package/bin/contextManager.js +167 -0
- package/bin/index.js +2103 -573
- package/bin/markdownRenderer.js +264 -0
- package/bin/memoryManager.js +182 -0
- package/bin/planManager.js +291 -0
- package/bin/projectNotebookManager.js +839 -0
- package/bin/runtimeEvents.js +104 -0
- package/bin/scanManager.js +561 -0
- package/bin/sessionManager.js +182 -0
- package/bin/terminalRenderer.js +701 -0
- 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 +1607 -0
- package/package.json +6 -5
|
@@ -0,0 +1,1607 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import readline from 'readline';
|
|
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
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SUPPORTED_TOOLS = new Set([
|
|
55
|
+
'LIST_DIR',
|
|
56
|
+
'INSPECT_FILE',
|
|
57
|
+
'READ_FILE',
|
|
58
|
+
'READ_NOTEBOOK',
|
|
59
|
+
'SEARCH_GREP',
|
|
60
|
+
'GLOB_FILES',
|
|
61
|
+
'EDIT_FILE',
|
|
62
|
+
'WRITE_FILE',
|
|
63
|
+
'MAKE_DIR',
|
|
64
|
+
'MOVE_PATH',
|
|
65
|
+
'DELETE_PATH',
|
|
66
|
+
'RUN_COMMAND'
|
|
67
|
+
,'READ_TASK_OUTPUT'
|
|
68
|
+
,'KILL_TASK'
|
|
69
|
+
,'UPDATE_TODOS'
|
|
70
|
+
]);
|
|
71
|
+
export const MUTATING_TOOLS = new Set([
|
|
72
|
+
'EDIT_FILE', 'WRITE_FILE', 'MAKE_DIR', 'MOVE_PATH', 'DELETE_PATH', 'RUN_COMMAND', 'KILL_TASK'
|
|
73
|
+
]);
|
|
74
|
+
export const TOOL_DEFINITIONS = [
|
|
75
|
+
{ name: 'LIST_DIR', description: 'List one directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
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 } },
|
|
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 } },
|
|
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 } },
|
|
83
|
+
{ name: 'MAKE_DIR', description: 'Create a directory inside the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } },
|
|
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 } },
|
|
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 } },
|
|
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 } }
|
|
90
|
+
];
|
|
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
|
+
|
|
157
|
+
export function getToolDefinitions(mode = 'chat') {
|
|
158
|
+
if (mode === 'note') return [];
|
|
159
|
+
if (mode === 'scan') return SCAN_TOOL_DEFINITIONS;
|
|
160
|
+
return TOOL_DEFINITIONS.filter(tool => mode === 'code' || !MUTATING_TOOLS.has(tool.name));
|
|
161
|
+
}
|
|
162
|
+
const DEFAULT_TEXT_EXTS = new Set([
|
|
163
|
+
'.js', '.json', '.txt', '.md', '.html', '.css', '.yml', '.yaml',
|
|
164
|
+
'.sh', '.bat', '.cmd', '.py', '.cpp', '.h', '.c', '.go', '.rs',
|
|
165
|
+
'.java', '.ts', '.tsx', '.jsx'
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
function decodeEntities(value) {
|
|
169
|
+
return String(value || '')
|
|
170
|
+
.replace(/</g, '<')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/"/g, '"')
|
|
173
|
+
.replace(/&/g, '&');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildEditArgument(filePath, oldText, newText, replaceAll = false) {
|
|
177
|
+
return `${filePath}\n<<<SEARCH\n${oldText}\n===REPLACE\n${newText}\n>>>END${replaceAll ? '\nALL' : ''}`;
|
|
178
|
+
}
|
|
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
|
+
|
|
236
|
+
function parseDSMLToolCall(text) {
|
|
237
|
+
const trimmed = String(text || '').trim();
|
|
238
|
+
if (!/^<||DSML||tool_calls>[\s\S]*<\/||DSML||tool_calls>$/.test(trimmed)) return null;
|
|
239
|
+
const invokePattern = /<||DSML||invoke\s+name="([^"]+)">([\s\S]*?)<\/||DSML||invoke>/g;
|
|
240
|
+
const invokes = [...trimmed.matchAll(invokePattern)];
|
|
241
|
+
if (invokes.length === 0) return null;
|
|
242
|
+
if (invokes.length !== 1) {
|
|
243
|
+
return { error: 'Tool parse error: exactly one DSML invocation is allowed.' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const invoke = invokes[0];
|
|
247
|
+
const sourceName = invoke[1];
|
|
248
|
+
const body = invoke[2];
|
|
249
|
+
const params = {};
|
|
250
|
+
const parameterPattern = /<||DSML||parameter\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/||DSML||parameter>/g;
|
|
251
|
+
for (const match of body.matchAll(parameterPattern)) {
|
|
252
|
+
params[match[1]] = decodeEntities(match[2]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const key = sourceName.toLowerCase();
|
|
256
|
+
const filePath = String(params.filePath || params.path || params.file_path || '').trim();
|
|
257
|
+
let toolName;
|
|
258
|
+
let toolArg;
|
|
259
|
+
|
|
260
|
+
if (key === 'read') {
|
|
261
|
+
toolName = 'READ_FILE';
|
|
262
|
+
const offset = Math.max(1, Number.parseInt(params.offset || '1', 10) || 1);
|
|
263
|
+
const limit = Math.max(1, Number.parseInt(params.limit || '0', 10) || 0);
|
|
264
|
+
toolArg = limit ? `${filePath}:${offset}-${offset + limit - 1}` : filePath;
|
|
265
|
+
} else if (key === 'write') {
|
|
266
|
+
toolName = 'WRITE_FILE';
|
|
267
|
+
toolArg = `${filePath}\n${params.content || params.text || ''}`;
|
|
268
|
+
} else if (key === 'edit' || key === 'strreplace') {
|
|
269
|
+
toolName = 'EDIT_FILE';
|
|
270
|
+
toolArg = buildEditArgument(
|
|
271
|
+
filePath,
|
|
272
|
+
params.oldString || params.old_string || params.oldText || '',
|
|
273
|
+
params.newString || params.new_string || params.newText || '',
|
|
274
|
+
String(params.replaceAll || params.replace_all || '').toLowerCase() === 'true'
|
|
275
|
+
);
|
|
276
|
+
} else if (key === 'bash' || key === 'shell' || key === 'runcommand') {
|
|
277
|
+
toolName = 'RUN_COMMAND';
|
|
278
|
+
toolArg = params.command || params.cmd || '';
|
|
279
|
+
} else if (key === 'grep' || key === 'search') {
|
|
280
|
+
toolName = 'SEARCH_GREP';
|
|
281
|
+
toolArg = params.pattern || params.query || params.text || '';
|
|
282
|
+
} else if (key === 'glob' || key === 'list' || key === 'listdir') {
|
|
283
|
+
toolName = 'LIST_DIR';
|
|
284
|
+
toolArg = params.path || params.directory || '.';
|
|
285
|
+
} else if (key === 'delete' || key === 'remove') {
|
|
286
|
+
toolName = 'DELETE_PATH';
|
|
287
|
+
toolArg = filePath;
|
|
288
|
+
} else if (key === 'move' || key === 'rename') {
|
|
289
|
+
toolName = 'MOVE_PATH';
|
|
290
|
+
toolArg = `${params.source || params.from || filePath}\n${params.destination || params.to || ''}`;
|
|
291
|
+
} else if (key === 'mkdir' || key === 'makedir') {
|
|
292
|
+
toolName = 'MAKE_DIR';
|
|
293
|
+
toolArg = filePath || params.directory || '';
|
|
294
|
+
} else {
|
|
295
|
+
return { error: `Tool parse error: unsupported DSML tool "${sourceName}".` };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
toolName,
|
|
300
|
+
toolArg,
|
|
301
|
+
match: invoke,
|
|
302
|
+
recovered: true,
|
|
303
|
+
sourceFormat: 'dsml',
|
|
304
|
+
remainingCalls: 0,
|
|
305
|
+
thoughtText: ''
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function createToolTagStreamFilter() {
|
|
310
|
+
let mode = 'pending';
|
|
311
|
+
let buffer = '';
|
|
312
|
+
|
|
313
|
+
function push(chunk) {
|
|
314
|
+
const text = String(chunk || '');
|
|
315
|
+
if (!text) return '';
|
|
316
|
+
if (mode === 'text') return text;
|
|
317
|
+
if (mode === 'reasoning') {
|
|
318
|
+
buffer += text;
|
|
319
|
+
const closeIndex = buffer.toLowerCase().indexOf('</think>');
|
|
320
|
+
if (closeIndex === -1) return '';
|
|
321
|
+
const remainder = buffer.slice(closeIndex + 8);
|
|
322
|
+
buffer = '';
|
|
323
|
+
mode = 'pending';
|
|
324
|
+
return push(remainder);
|
|
325
|
+
}
|
|
326
|
+
buffer += text;
|
|
327
|
+
const trimmed = buffer.trimStart();
|
|
328
|
+
const lower = trimmed.toLowerCase();
|
|
329
|
+
const upper = trimmed.toUpperCase();
|
|
330
|
+
|
|
331
|
+
if (!trimmed) return '';
|
|
332
|
+
if (lower.startsWith('<think>')) {
|
|
333
|
+
mode = 'reasoning';
|
|
334
|
+
const closeIndex = lower.indexOf('</think>');
|
|
335
|
+
if (closeIndex !== -1) {
|
|
336
|
+
const remainder = trimmed.slice(closeIndex + 8);
|
|
337
|
+
buffer = '';
|
|
338
|
+
mode = 'pending';
|
|
339
|
+
return push(remainder);
|
|
340
|
+
}
|
|
341
|
+
return '';
|
|
342
|
+
}
|
|
343
|
+
if ('<think>'.startsWith(lower) || 'reasoning_content:'.startsWith(lower)) return '';
|
|
344
|
+
if (lower.startsWith('reasoning_content:')) {
|
|
345
|
+
mode = 'reasoning';
|
|
346
|
+
return '';
|
|
347
|
+
}
|
|
348
|
+
const toolPrefixes = Array.from(SUPPORTED_TOOLS).map(tool => `<<${tool}:`);
|
|
349
|
+
if (toolPrefixes.some(prefix => prefix.startsWith(upper))) return '';
|
|
350
|
+
if (toolPrefixes.some(prefix => upper.startsWith(prefix))) {
|
|
351
|
+
mode = 'tool';
|
|
352
|
+
return '';
|
|
353
|
+
}
|
|
354
|
+
if (trimmed.startsWith('<<')) {
|
|
355
|
+
if (/^<<[A-Z_]+\s*:/.test(trimmed)) mode = 'tool';
|
|
356
|
+
return '';
|
|
357
|
+
}
|
|
358
|
+
if ('<<'.startsWith(trimmed)) return '';
|
|
359
|
+
|
|
360
|
+
mode = 'text';
|
|
361
|
+
const output = buffer;
|
|
362
|
+
buffer = '';
|
|
363
|
+
return output;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function finish() {
|
|
367
|
+
const trimmed = buffer.trimStart().toLowerCase();
|
|
368
|
+
if (mode === 'pending' && !trimmed.startsWith('<<') && !trimmed.startsWith('<think') && !trimmed.startsWith('reasoning_content:')) {
|
|
369
|
+
mode = 'text';
|
|
370
|
+
const output = buffer;
|
|
371
|
+
buffer = '';
|
|
372
|
+
return output;
|
|
373
|
+
}
|
|
374
|
+
return '';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
push,
|
|
379
|
+
finish,
|
|
380
|
+
isTool: () => mode === 'tool' || /^<<[A-Z_]+\s*:/.test(buffer.trimStart())
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function parseToolCall(reply = '') {
|
|
385
|
+
const text = String(reply).trim();
|
|
386
|
+
if (text.startsWith('<||DSML||tool_calls>')) {
|
|
387
|
+
const dsml = parseDSMLToolCall(text);
|
|
388
|
+
if (dsml) return dsml;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!text.startsWith('<<')) return null;
|
|
392
|
+
const match = text.match(/^<<([A-Z_]+):\s*([\s\S]*)>>$/);
|
|
393
|
+
if (!match) return { error: 'Tool parse error: legacy calls must occupy the complete message as <<TOOL_NAME: arguments>>.' };
|
|
394
|
+
if (/<<[A-Z_]+\s*:/.test(match[2])) {
|
|
395
|
+
return { error: 'Tool parse error: exactly one legacy tool call is allowed.' };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const toolName = match[1];
|
|
399
|
+
if (!SUPPORTED_TOOLS.has(toolName)) {
|
|
400
|
+
return {
|
|
401
|
+
error: `Tool parse error: unsupported tool "${toolName}". Supported tools: ${Array.from(SUPPORTED_TOOLS).join(', ')}.`
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
toolName,
|
|
407
|
+
toolArg: match[2],
|
|
408
|
+
match,
|
|
409
|
+
recovered: false,
|
|
410
|
+
thoughtText: ''
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function isInsidePath(childPath, rootPath) {
|
|
415
|
+
const relative = path.relative(rootPath, childPath);
|
|
416
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function canonicalExistingPath(targetPath) {
|
|
420
|
+
return fs.realpathSync.native(targetPath);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function canonicalRoot(workspaceRoot) {
|
|
424
|
+
const root = canonicalExistingPath(path.resolve(workspaceRoot));
|
|
425
|
+
return process.platform === 'win32' ? root.toLowerCase() : root;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function comparablePath(value) {
|
|
429
|
+
const normalized = path.resolve(value);
|
|
430
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function nearestExistingAncestor(targetPath) {
|
|
434
|
+
let current = path.resolve(targetPath);
|
|
435
|
+
while (!fs.existsSync(current)) {
|
|
436
|
+
const parent = path.dirname(current);
|
|
437
|
+
if (parent === current) break;
|
|
438
|
+
current = parent;
|
|
439
|
+
}
|
|
440
|
+
return current;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function resolveWorkspacePath(rawPath, workspaceRoot) {
|
|
444
|
+
const trimmed = String(rawPath || '').trim();
|
|
445
|
+
if (!trimmed) {
|
|
446
|
+
throw new Error('Path is empty.');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const resolved = path.isAbsolute(trimmed)
|
|
450
|
+
? path.resolve(trimmed)
|
|
451
|
+
: path.resolve(workspaceRoot, trimmed);
|
|
452
|
+
const root = path.resolve(workspaceRoot);
|
|
453
|
+
|
|
454
|
+
if (!isInsidePath(resolved, root)) {
|
|
455
|
+
throw new Error(`Access outside workspace is blocked: ${trimmed}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const realRoot = canonicalRoot(workspaceRoot);
|
|
459
|
+
const ancestor = nearestExistingAncestor(resolved);
|
|
460
|
+
const realAncestor = comparablePath(canonicalExistingPath(ancestor));
|
|
461
|
+
if (!isInsidePath(realAncestor, realRoot)) {
|
|
462
|
+
throw new Error(`Workspace path resolves through a link outside the workspace: ${trimmed}`);
|
|
463
|
+
}
|
|
464
|
+
if (fs.existsSync(resolved)) {
|
|
465
|
+
const realTarget = comparablePath(canonicalExistingPath(resolved));
|
|
466
|
+
if (!isInsidePath(realTarget, realRoot)) {
|
|
467
|
+
throw new Error(`Workspace path resolves outside the workspace: ${trimmed}`);
|
|
468
|
+
}
|
|
469
|
+
return resolved;
|
|
470
|
+
}
|
|
471
|
+
return resolved;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function resolveExistingWorkspacePath(rawPath, workspaceRoot) {
|
|
475
|
+
const value = String(rawPath || '').trim();
|
|
476
|
+
let resolved;
|
|
477
|
+
let originalError;
|
|
478
|
+
try {
|
|
479
|
+
resolved = resolveWorkspacePath(value, workspaceRoot);
|
|
480
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
481
|
+
} catch (error) {
|
|
482
|
+
originalError = error;
|
|
483
|
+
}
|
|
484
|
+
const fileName = value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1);
|
|
485
|
+
const suggestions = [];
|
|
486
|
+
if (fileName && !['.', '..'].includes(fileName)) {
|
|
487
|
+
const visit = (directory, depth) => {
|
|
488
|
+
if (depth > 6 || suggestions.length >= 5) return;
|
|
489
|
+
let entries = [];
|
|
490
|
+
try {
|
|
491
|
+
entries = fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
492
|
+
} catch {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
for (const entry of entries) {
|
|
496
|
+
if (suggestions.length >= 5) break;
|
|
497
|
+
if (['node_modules', '.git', '.gemini'].includes(entry.name)) continue;
|
|
498
|
+
const fullPath = path.join(directory, entry.name);
|
|
499
|
+
if (entry.isDirectory()) visit(fullPath, depth + 1);
|
|
500
|
+
else if (entry.isFile() && entry.name === fileName) suggestions.push(path.relative(workspaceRoot, fullPath));
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
visit(workspaceRoot, 0);
|
|
504
|
+
}
|
|
505
|
+
const hint = suggestions.length
|
|
506
|
+
? ` Candidate path${suggestions.length === 1 ? '' : 's'}: ${suggestions.join(', ')}. Retry with one exact workspace-relative path.`
|
|
507
|
+
: '';
|
|
508
|
+
if (originalError) throw new Error(`${originalError.message}${hint}`);
|
|
509
|
+
throw new Error(`Path does not exist inside workspace: ${value}.${hint}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function isSensitivePath(filePath) {
|
|
513
|
+
const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase();
|
|
514
|
+
const base = path.posix.basename(normalized);
|
|
515
|
+
return base === '.env' || base.startsWith('.env.') ||
|
|
516
|
+
['.npmrc', '.netrc'].includes(base) || base.startsWith('credentials') || base.startsWith('secrets') ||
|
|
517
|
+
/^(id_rsa|id_ed25519)(\.|$)/.test(base) ||
|
|
518
|
+
/\.(pem|key|p12|pfx|jks|keystore|crt|cer)$/.test(base) ||
|
|
519
|
+
normalized.split('/').includes('.git');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function contentHash(content) {
|
|
523
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function looksBinary(filePath) {
|
|
527
|
+
const handle = await fs.promises.open(filePath, 'r');
|
|
528
|
+
try {
|
|
529
|
+
const buffer = Buffer.alloc(8192);
|
|
530
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
531
|
+
return buffer.subarray(0, bytesRead).includes(0);
|
|
532
|
+
} finally {
|
|
533
|
+
await handle.close();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
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)}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
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));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export function truncateToolResult(text, maxChars = 12000) {
|
|
575
|
+
const clean = String(text || '');
|
|
576
|
+
if (clean.length <= maxChars) return clean;
|
|
577
|
+
return `${clean.slice(0, maxChars)}\n\n[Tool result truncated: ${clean.length - maxChars} characters omitted. Use narrower search terms or READ_FILE line ranges.]`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseReadArgument(toolArg) {
|
|
581
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
582
|
+
const rawStart = toolArg.cursor ?? toolArg.startLine ?? toolArg.start_line;
|
|
583
|
+
const startLine = Math.max(1, Number.parseInt(rawStart ?? '1', 10) || 1);
|
|
584
|
+
const rawEnd = toolArg.endLine ?? toolArg.end_line;
|
|
585
|
+
const endLine = rawEnd === undefined || rawEnd === null ? null : Math.max(1, Number.parseInt(rawEnd, 10) || 1);
|
|
586
|
+
if (endLine !== null && endLine < startLine) {
|
|
587
|
+
throw new Error('Invalid line range: end line must be greater than or equal to start line.');
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
filePath: String(toolArg.path ?? toolArg.filePath ?? ''),
|
|
591
|
+
startLine,
|
|
592
|
+
endLine,
|
|
593
|
+
hasRange: rawStart !== undefined || endLine !== null
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
let filePath = String(toolArg || '').trim();
|
|
597
|
+
let startLine = 1;
|
|
598
|
+
let endLine = null;
|
|
599
|
+
const rangeMatch = filePath.match(/:(\d+)-(\d+)$/);
|
|
600
|
+
if (rangeMatch) {
|
|
601
|
+
filePath = filePath.slice(0, -rangeMatch[0].length);
|
|
602
|
+
startLine = parseInt(rangeMatch[1], 10);
|
|
603
|
+
endLine = parseInt(rangeMatch[2], 10);
|
|
604
|
+
}
|
|
605
|
+
if (endLine !== null && endLine < startLine) {
|
|
606
|
+
throw new Error('Invalid line range: end line must be greater than or equal to start line.');
|
|
607
|
+
}
|
|
608
|
+
return { filePath, startLine, endLine, hasRange: !!rangeMatch };
|
|
609
|
+
}
|
|
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
|
+
|
|
641
|
+
function buildWritePreview(filePath, oldContent, newContent, existed) {
|
|
642
|
+
const relName = path.basename(filePath);
|
|
643
|
+
if (!existed) {
|
|
644
|
+
return [
|
|
645
|
+
`Create ${relName}`,
|
|
646
|
+
previewLines(newContent, '+')
|
|
647
|
+
].join('\n');
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (oldContent === newContent) {
|
|
651
|
+
return `${relName}: no content changes.`;
|
|
652
|
+
}
|
|
653
|
+
|
|
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');
|
|
660
|
+
}
|
|
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
|
+
}
|
|
666
|
+
}
|
|
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');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function previewLines(text, marker, maxLines = 60) {
|
|
700
|
+
const lines = String(text || '').split('\n');
|
|
701
|
+
const shown = lines.slice(0, maxLines).map(line => `${marker} ${line}`).join('\n');
|
|
702
|
+
if (lines.length <= maxLines) return shown;
|
|
703
|
+
return `${shown}\n... ${lines.length - maxLines} more lines omitted`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function parseEditArgument(toolArg) {
|
|
707
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
708
|
+
return {
|
|
709
|
+
filePath: toolArg.path || toolArg.filePath || '',
|
|
710
|
+
oldText: toolArg.oldText ?? toolArg.oldString ?? '',
|
|
711
|
+
newText: toolArg.newText ?? toolArg.newString ?? '',
|
|
712
|
+
replaceAll: toolArg.replaceAll === true
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
const raw = String(toolArg || '');
|
|
716
|
+
if (raw.trimStart().startsWith('{')) {
|
|
717
|
+
const parsed = JSON.parse(raw);
|
|
718
|
+
return {
|
|
719
|
+
filePath: parsed.path || parsed.filePath || '',
|
|
720
|
+
oldText: parsed.oldText ?? parsed.oldString ?? '',
|
|
721
|
+
newText: parsed.newText ?? parsed.newString ?? '',
|
|
722
|
+
replaceAll: parsed.replaceAll === true
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
727
|
+
const firstNewline = normalized.indexOf('\n');
|
|
728
|
+
if (firstNewline === -1) throw new Error('EDIT_FILE requires a path and SEARCH/REPLACE blocks.');
|
|
729
|
+
const filePath = normalized.slice(0, firstNewline).trim();
|
|
730
|
+
const body = normalized.slice(firstNewline + 1);
|
|
731
|
+
const searchMarker = '<<<SEARCH\n';
|
|
732
|
+
const replaceMarker = '\n===REPLACE\n';
|
|
733
|
+
const endMarker = '\n>>>END';
|
|
734
|
+
if (!body.startsWith(searchMarker)) throw new Error('EDIT_FILE is missing <<<SEARCH.');
|
|
735
|
+
const replaceIndex = body.indexOf(replaceMarker, searchMarker.length);
|
|
736
|
+
const endIndex = body.lastIndexOf(endMarker);
|
|
737
|
+
if (replaceIndex === -1 || endIndex === -1 || endIndex < replaceIndex) {
|
|
738
|
+
throw new Error('EDIT_FILE requires ===REPLACE and >>>END markers.');
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
filePath,
|
|
742
|
+
oldText: body.slice(searchMarker.length, replaceIndex),
|
|
743
|
+
newText: body.slice(replaceIndex + replaceMarker.length, endIndex),
|
|
744
|
+
replaceAll: /\nALL\s*$/.test(body.slice(endIndex + endMarker.length))
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function countOccurrences(content, search) {
|
|
749
|
+
if (!search) return 0;
|
|
750
|
+
let count = 0;
|
|
751
|
+
let offset = 0;
|
|
752
|
+
while ((offset = content.indexOf(search, offset)) !== -1) {
|
|
753
|
+
count++;
|
|
754
|
+
offset += search.length;
|
|
755
|
+
}
|
|
756
|
+
return count;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function parseMoveArgument(toolArg) {
|
|
760
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
761
|
+
return {
|
|
762
|
+
source: String(toolArg.source || toolArg.from || '').trim(),
|
|
763
|
+
destination: String(toolArg.destination || toolArg.to || '').trim()
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const raw = String(toolArg || '').trim();
|
|
767
|
+
if (raw.startsWith('{')) {
|
|
768
|
+
const parsed = JSON.parse(raw);
|
|
769
|
+
return {
|
|
770
|
+
source: parsed.source || parsed.from || '',
|
|
771
|
+
destination: parsed.destination || parsed.to || ''
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const [source = '', destination = ''] = raw.split(/\r?\n/, 2);
|
|
775
|
+
return { source: source.trim(), destination: destination.trim() };
|
|
776
|
+
}
|
|
777
|
+
|
|
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) => {
|
|
781
|
+
signal?.removeEventListener('abort', abort);
|
|
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 });
|
|
784
|
+
});
|
|
785
|
+
const abort = () => child.kill();
|
|
786
|
+
if (signal?.aborted) abort();
|
|
787
|
+
else signal?.addEventListener('abort', abort, { once: true });
|
|
788
|
+
});
|
|
789
|
+
}
|
|
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
|
+
|
|
903
|
+
export async function executeToolCall({
|
|
904
|
+
toolName,
|
|
905
|
+
toolArg,
|
|
906
|
+
workspaceRoot,
|
|
907
|
+
toolCallId = '',
|
|
908
|
+
emit = () => {},
|
|
909
|
+
requestPermission = async () => false,
|
|
910
|
+
lang = 'cn',
|
|
911
|
+
mode = 'chat',
|
|
912
|
+
signal,
|
|
913
|
+
readTracker,
|
|
914
|
+
permissionPolicy = { mode: 'ask', rules: [] },
|
|
915
|
+
contextPlan,
|
|
916
|
+
remainingBudgetTokens,
|
|
917
|
+
readNotebook = async () => null
|
|
918
|
+
}) {
|
|
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)));
|
|
922
|
+
const cn = lang === 'cn';
|
|
923
|
+
const startedAt = Date.now();
|
|
924
|
+
|
|
925
|
+
function event(type, data = {}) {
|
|
926
|
+
emit(type, { tool: toolName, toolCallId, ...data });
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function success(modelResult, displaySummary, data = {}) {
|
|
930
|
+
const durationMs = Date.now() - startedAt;
|
|
931
|
+
event('tool.completed', { displaySummary, durationMs, ...data });
|
|
932
|
+
return { ok: true, result: modelResult, modelResult, displaySummary, status: 'Done', data };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function failure(error) {
|
|
936
|
+
const modelResult = `Tool ${toolName} failed: ${error.message}`;
|
|
937
|
+
const displaySummary = cn ? `${toolName} 失败 · ${error.message}` : `${toolName} failed · ${error.message}`;
|
|
938
|
+
event('tool.failed', { displaySummary, error: error.message, durationMs: Date.now() - startedAt });
|
|
939
|
+
return { ok: false, result: modelResult, modelResult, displaySummary, status: 'Failed' };
|
|
940
|
+
}
|
|
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
|
+
|
|
971
|
+
try {
|
|
972
|
+
if (MUTATING_TOOLS.has(toolName) && mode !== 'code') {
|
|
973
|
+
throw new Error(`Workspace mutation is blocked in ${mode} mode. Use /code <request> to authorize one coding turn.`);
|
|
974
|
+
}
|
|
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
|
+
|
|
986
|
+
if (toolName === 'LIST_DIR') {
|
|
987
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
988
|
+
const resolvedPath = resolveExistingWorkspacePath(String(rawPath || '.').trim() || '.', workspaceRoot);
|
|
989
|
+
const relPath = path.relative(workspaceRoot, resolvedPath) || '.';
|
|
990
|
+
event('tool.started', { target: relPath, label: cn ? `正在查看 ${relPath}` : `Listing ${relPath}` });
|
|
991
|
+
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
|
992
|
+
const rows = await Promise.all(entries.map(async entry => {
|
|
993
|
+
if (entry.isDirectory()) return `${entry.name} (Dir)`;
|
|
994
|
+
if (entry.isSymbolicLink()) return `${entry.name} (Symlink)`;
|
|
995
|
+
const stat = await fs.promises.stat(path.join(resolvedPath, entry.name));
|
|
996
|
+
return `${entry.name} (File, ${stat.size}B)`;
|
|
997
|
+
}));
|
|
998
|
+
const result = rows.join('\n') || '(Empty directory)';
|
|
999
|
+
return success(
|
|
1000
|
+
truncateToolResult(result, maxToolChars),
|
|
1001
|
+
cn ? `已查看 ${relPath} · ${entries.length} 项` : `Listed ${relPath} · ${entries.length} items`,
|
|
1002
|
+
{ target: relPath, itemCount: entries.length }
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
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
|
+
|
|
1037
|
+
if (toolName === 'READ_FILE') {
|
|
1038
|
+
const { filePath, startLine, endLine, hasRange } = parseReadArgument(toolArg);
|
|
1039
|
+
const resolvedPath = resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
1040
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
1041
|
+
const rangeText = hasRange ? `:${startLine}-${endLine ?? 'EOF'}` : '';
|
|
1042
|
+
const target = `${relPath}${rangeText}`;
|
|
1043
|
+
const realReadTarget = fs.realpathSync.native(resolvedPath);
|
|
1044
|
+
if (isSensitivePath(relPath) || isSensitivePath(realReadTarget)) {
|
|
1045
|
+
const displaySummary = cn ? `敏感文件读取确认 · ${relPath}` : `Sensitive file read · ${relPath}`;
|
|
1046
|
+
event('permission.requested', { kind: 'sensitive-read', target: relPath, displaySummary });
|
|
1047
|
+
const allowed = await authorize({
|
|
1048
|
+
kind: 'sensitive-read',
|
|
1049
|
+
prompt: cn
|
|
1050
|
+
? `读取 ${relPath} 会将内容发送给模型,是否允许?(y/n): `
|
|
1051
|
+
: `Reading ${relPath} sends its content to the model. Allow? (y/n): `,
|
|
1052
|
+
displaySummary
|
|
1053
|
+
});
|
|
1054
|
+
event('permission.resolved', { kind: 'sensitive-read', target: relPath, allowed, displaySummary });
|
|
1055
|
+
if (!allowed) {
|
|
1056
|
+
const modelResult = `Sensitive file read denied by user: ${relPath}`;
|
|
1057
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
event('tool.started', { target, label: cn ? `正在读取 ${target}` : `Reading ${target}` });
|
|
1061
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
1062
|
+
if (!stat.isFile()) throw new Error(`READ_FILE target is not a file: ${relPath}`);
|
|
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);
|
|
1066
|
+
|
|
1067
|
+
if (!hasRange && tracked?.full) {
|
|
1068
|
+
return success(
|
|
1069
|
+
tracked.fullContent,
|
|
1070
|
+
cn ? `已复用 ${relPath} · 文件未变化` : `Reused ${relPath} · unchanged`,
|
|
1071
|
+
{ target: relPath, cached: true }
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (hasRange) {
|
|
1076
|
+
const rangeKey = `${startLine}:${endLine ?? '*'}`;
|
|
1077
|
+
const cachedRange = tracked?.rangeContents?.get(rangeKey);
|
|
1078
|
+
if (cachedRange) {
|
|
1079
|
+
return success(
|
|
1080
|
+
cachedRange,
|
|
1081
|
+
cn ? `已复用 ${target} · 文件未变化` : `Reused ${target} · unchanged`,
|
|
1082
|
+
{ target, cached: true }
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
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
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
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
|
+
}
|
|
1106
|
+
return success(
|
|
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 }
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
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; }
|
|
1116
|
+
return success(
|
|
1117
|
+
fullResult,
|
|
1118
|
+
cn ? `已读取 ${relPath} · ${lines.length} 行` : `Read ${relPath} · ${lines.length} lines`,
|
|
1119
|
+
{ target: relPath, lineCount: lines.length, totalLines: lines.length }
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
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
|
+
|
|
1141
|
+
if (toolName === 'EDIT_FILE') {
|
|
1142
|
+
const { filePath, oldText, newText, replaceAll } = parseEditArgument(toolArg);
|
|
1143
|
+
if (!filePath) throw new Error('EDIT_FILE path is empty.');
|
|
1144
|
+
if (!oldText) throw new Error('EDIT_FILE search text is empty.');
|
|
1145
|
+
const resolvedPath = resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
1146
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
1147
|
+
const oldContent = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
1148
|
+
const expectedHash = contentHash(oldContent);
|
|
1149
|
+
const eol = oldContent.includes('\r\n') ? '\r\n' : '\n';
|
|
1150
|
+
const normalizedContent = oldContent.replace(/\r\n/g, '\n');
|
|
1151
|
+
const normalizedOld = String(oldText).replace(/\r\n/g, '\n');
|
|
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
|
+
}
|
|
1167
|
+
const matches = countOccurrences(normalizedContent, normalizedOld);
|
|
1168
|
+
if (matches === 0) {
|
|
1169
|
+
throw new Error(`EDIT_FILE search text was not found in ${relPath}. Read the exact current lines and retry.`);
|
|
1170
|
+
}
|
|
1171
|
+
if (matches > 1 && !replaceAll) {
|
|
1172
|
+
throw new Error(`EDIT_FILE search text matched ${matches} locations in ${relPath}. Include more surrounding context or set replaceAll.`);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const updatedNormalized = replaceAll
|
|
1176
|
+
? normalizedContent.split(normalizedOld).join(normalizedNew)
|
|
1177
|
+
: normalizedContent.replace(normalizedOld, normalizedNew);
|
|
1178
|
+
const updatedContent = eol === '\r\n' ? updatedNormalized.replace(/\n/g, '\r\n') : updatedNormalized;
|
|
1179
|
+
const preview = buildWritePreview(resolvedPath, oldContent, updatedContent, true);
|
|
1180
|
+
const displaySummary = cn
|
|
1181
|
+
? `修改 ${relPath} · ${replaceAll ? matches : 1} 处`
|
|
1182
|
+
: `Edit ${relPath} · ${replaceAll ? matches : 1} replacement${matches === 1 ? '' : 's'}`;
|
|
1183
|
+
event('permission.requested', { kind: 'edit', target: relPath, displaySummary, preview });
|
|
1184
|
+
const proceed = await authorize({
|
|
1185
|
+
kind: 'edit',
|
|
1186
|
+
prompt: cn ? '应用此局部修改?(y/n): ' : 'Apply this focused edit? (y/n): ',
|
|
1187
|
+
displaySummary,
|
|
1188
|
+
preview
|
|
1189
|
+
});
|
|
1190
|
+
event('permission.resolved', {
|
|
1191
|
+
kind: 'edit',
|
|
1192
|
+
target: relPath,
|
|
1193
|
+
allowed: proceed,
|
|
1194
|
+
displaySummary: proceed
|
|
1195
|
+
? (cn ? '已确认局部修改' : 'Focused edit approved')
|
|
1196
|
+
: (cn ? `已取消修改 ${relPath}` : `Cancelled edit to ${relPath}`)
|
|
1197
|
+
});
|
|
1198
|
+
if (!proceed) {
|
|
1199
|
+
const modelResult = `Edit cancelled by user: ${relPath}`;
|
|
1200
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
event('tool.started', { target: relPath, label: cn ? `正在修改 ${relPath}` : `Editing ${relPath}` });
|
|
1204
|
+
resolveExistingWorkspacePath(filePath, workspaceRoot);
|
|
1205
|
+
const currentContent = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
1206
|
+
if (contentHash(currentContent) !== expectedHash) {
|
|
1207
|
+
throw new Error(`${relPath} changed while awaiting confirmation. Read the file again before editing.`);
|
|
1208
|
+
}
|
|
1209
|
+
await recordMutation(workspaceRoot, { kind: 'write', path: relPath, existed: true, content: currentContent });
|
|
1210
|
+
await fs.promises.writeFile(resolvedPath, updatedContent, 'utf8');
|
|
1211
|
+
const validation = await automaticSyntaxCheck(resolvedPath, workspaceRoot, signal);
|
|
1212
|
+
const modelResult = `Successfully edited ${relPath}: ${replaceAll ? matches : 1} replacement(s).${validation}`;
|
|
1213
|
+
return success(modelResult, cn ? `已修改 ${relPath} · ${replaceAll ? matches : 1} 处` : `Edited ${relPath} · ${replaceAll ? matches : 1} replacement(s)`, {
|
|
1214
|
+
target: relPath,
|
|
1215
|
+
replacements: replaceAll ? matches : 1
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (toolName === 'WRITE_FILE') {
|
|
1220
|
+
let filePathPart;
|
|
1221
|
+
let fileContentPart;
|
|
1222
|
+
if (toolArg && typeof toolArg === 'object') {
|
|
1223
|
+
filePathPart = String(toolArg.path || toolArg.filePath || '');
|
|
1224
|
+
fileContentPart = String(toolArg.content ?? '');
|
|
1225
|
+
} else {
|
|
1226
|
+
const rawArg = String(toolArg || '');
|
|
1227
|
+
const firstNewline = rawArg.indexOf('\n');
|
|
1228
|
+
filePathPart = firstNewline === -1 ? rawArg : rawArg.slice(0, firstNewline);
|
|
1229
|
+
fileContentPart = firstNewline === -1 ? '' : rawArg.slice(firstNewline + 1);
|
|
1230
|
+
}
|
|
1231
|
+
const resolvedPath = resolveWorkspacePath(filePathPart.trim(), workspaceRoot);
|
|
1232
|
+
const existed = fs.existsSync(resolvedPath);
|
|
1233
|
+
const oldContent = existed ? await fs.promises.readFile(resolvedPath, 'utf8') : '';
|
|
1234
|
+
const expectedHash = existed ? contentHash(oldContent) : null;
|
|
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
|
+
}
|
|
1243
|
+
const preview = buildWritePreview(resolvedPath, oldContent, fileContentPart, existed);
|
|
1244
|
+
const displaySummary = cn
|
|
1245
|
+
? `${existed ? '修改' : '创建'} ${relPath}`
|
|
1246
|
+
: `${existed ? 'Update' : 'Create'} ${relPath}`;
|
|
1247
|
+
event('permission.requested', { kind: 'write', target: relPath, displaySummary, preview });
|
|
1248
|
+
const proceed = await authorize({
|
|
1249
|
+
kind: 'write',
|
|
1250
|
+
prompt: cn ? '应用此文件更改?(y/n): ' : 'Apply this file change? (y/n): ',
|
|
1251
|
+
displaySummary,
|
|
1252
|
+
preview
|
|
1253
|
+
});
|
|
1254
|
+
event('permission.resolved', {
|
|
1255
|
+
kind: 'write',
|
|
1256
|
+
target: relPath,
|
|
1257
|
+
allowed: proceed,
|
|
1258
|
+
displaySummary: proceed
|
|
1259
|
+
? (cn ? '已确认文件更改' : 'File change approved')
|
|
1260
|
+
: (cn ? `已取消修改 ${relPath}` : `Cancelled change to ${relPath}`)
|
|
1261
|
+
});
|
|
1262
|
+
if (!proceed) {
|
|
1263
|
+
const modelResult = `Write cancelled by user: ${relPath}`;
|
|
1264
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
event('tool.started', { target: relPath, label: cn ? `正在写入 ${relPath}` : `Writing ${relPath}` });
|
|
1268
|
+
resolveWorkspacePath(filePathPart.trim(), workspaceRoot);
|
|
1269
|
+
if (existed) {
|
|
1270
|
+
const currentContent = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
1271
|
+
if (contentHash(currentContent) !== expectedHash) {
|
|
1272
|
+
throw new Error(`${relPath} changed while awaiting confirmation. Read the file again before writing.`);
|
|
1273
|
+
}
|
|
1274
|
+
} else if (fs.existsSync(resolvedPath)) {
|
|
1275
|
+
throw new Error(`${relPath} was created while awaiting confirmation. Read it before writing.`);
|
|
1276
|
+
}
|
|
1277
|
+
await recordMutation(workspaceRoot, { kind: 'write', path: relPath, existed, ...(existed ? { content: oldContent } : {}) });
|
|
1278
|
+
await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
1279
|
+
await fs.promises.writeFile(resolvedPath, fileContentPart, 'utf8');
|
|
1280
|
+
const validation = await automaticSyntaxCheck(resolvedPath, workspaceRoot, signal);
|
|
1281
|
+
const modelResult = `Successfully wrote file: ${relPath}${validation}`;
|
|
1282
|
+
return success(
|
|
1283
|
+
modelResult,
|
|
1284
|
+
cn ? `已${existed ? '修改' : '创建'} ${relPath}` : `${existed ? 'Updated' : 'Created'} ${relPath}`,
|
|
1285
|
+
{ target: relPath, created: !existed, bytes: Buffer.byteLength(fileContentPart, 'utf8') }
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (toolName === 'MAKE_DIR') {
|
|
1290
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
1291
|
+
const resolvedPath = resolveWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
1292
|
+
const directoryExisted = fs.existsSync(resolvedPath);
|
|
1293
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
1294
|
+
const displaySummary = cn ? `创建目录 ${relPath}` : `Create directory ${relPath}`;
|
|
1295
|
+
event('permission.requested', { kind: 'mkdir', target: relPath, displaySummary });
|
|
1296
|
+
const proceed = await authorize({
|
|
1297
|
+
kind: 'mkdir',
|
|
1298
|
+
prompt: cn ? `创建目录 ${relPath}?(y/n): ` : `Create directory ${relPath}? (y/n): `,
|
|
1299
|
+
displaySummary
|
|
1300
|
+
});
|
|
1301
|
+
event('permission.resolved', { kind: 'mkdir', target: relPath, allowed: proceed, displaySummary });
|
|
1302
|
+
if (!proceed) {
|
|
1303
|
+
const modelResult = `Directory creation cancelled by user: ${relPath}`;
|
|
1304
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1305
|
+
}
|
|
1306
|
+
event('tool.started', { target: relPath, label: cn ? `正在创建目录 ${relPath}` : `Creating directory ${relPath}` });
|
|
1307
|
+
resolveWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
1308
|
+
if (!directoryExisted && fs.existsSync(resolvedPath)) {
|
|
1309
|
+
throw new Error(`${relPath} appeared while awaiting confirmation. Inspect it before continuing.`);
|
|
1310
|
+
}
|
|
1311
|
+
await recordMutation(workspaceRoot, { kind: 'mkdir', path: relPath, existed: directoryExisted });
|
|
1312
|
+
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
|
1313
|
+
return success(
|
|
1314
|
+
`Successfully created directory: ${relPath}`,
|
|
1315
|
+
cn ? `已创建目录 ${relPath}` : `Created directory ${relPath}`,
|
|
1316
|
+
{ target: relPath }
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (toolName === 'MOVE_PATH') {
|
|
1321
|
+
const { source, destination } = parseMoveArgument(toolArg);
|
|
1322
|
+
if (!source || !destination) throw new Error('MOVE_PATH requires source and destination paths.');
|
|
1323
|
+
const sourcePath = resolveExistingWorkspacePath(source, workspaceRoot);
|
|
1324
|
+
const destinationPath = resolveWorkspacePath(destination, workspaceRoot);
|
|
1325
|
+
const sourceIdentity = await fs.promises.lstat(sourcePath);
|
|
1326
|
+
const sourceRel = path.relative(workspaceRoot, sourcePath);
|
|
1327
|
+
const destinationRel = path.relative(workspaceRoot, destinationPath);
|
|
1328
|
+
if (fs.existsSync(destinationPath)) throw new Error(`MOVE_PATH destination already exists: ${destinationRel}`);
|
|
1329
|
+
const displaySummary = cn ? `移动 ${sourceRel} → ${destinationRel}` : `Move ${sourceRel} -> ${destinationRel}`;
|
|
1330
|
+
event('permission.requested', { kind: 'move', target: sourceRel, displaySummary });
|
|
1331
|
+
const proceed = await authorize({
|
|
1332
|
+
kind: 'move',
|
|
1333
|
+
prompt: cn ? `移动到 ${destinationRel}?(y/n): ` : `Move to ${destinationRel}? (y/n): `,
|
|
1334
|
+
displaySummary
|
|
1335
|
+
});
|
|
1336
|
+
event('permission.resolved', { kind: 'move', target: sourceRel, allowed: proceed, displaySummary: proceed ? displaySummary : (cn ? '已取消移动' : 'Move cancelled') });
|
|
1337
|
+
if (!proceed) {
|
|
1338
|
+
const modelResult = `Move cancelled by user: ${sourceRel}`;
|
|
1339
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1340
|
+
}
|
|
1341
|
+
event('tool.started', { target: sourceRel, label: cn ? `正在移动 ${sourceRel}` : `Moving ${sourceRel}` });
|
|
1342
|
+
resolveExistingWorkspacePath(source, workspaceRoot);
|
|
1343
|
+
resolveWorkspacePath(destination, workspaceRoot);
|
|
1344
|
+
if (fs.existsSync(destinationPath)) throw new Error(`${destinationRel} appeared while awaiting confirmation.`);
|
|
1345
|
+
const currentSourceIdentity = await fs.promises.lstat(sourcePath);
|
|
1346
|
+
if (currentSourceIdentity.dev !== sourceIdentity.dev || currentSourceIdentity.ino !== sourceIdentity.ino || currentSourceIdentity.mtimeMs !== sourceIdentity.mtimeMs || currentSourceIdentity.size !== sourceIdentity.size) {
|
|
1347
|
+
throw new Error(`${sourceRel} changed while awaiting confirmation. Inspect it again before moving.`);
|
|
1348
|
+
}
|
|
1349
|
+
await recordMutation(workspaceRoot, { kind: 'move', source: sourceRel, destination: destinationRel });
|
|
1350
|
+
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
1351
|
+
await fs.promises.rename(sourcePath, destinationPath);
|
|
1352
|
+
return success(`Successfully moved ${sourceRel} to ${destinationRel}`, displaySummary, { source: sourceRel, destination: destinationRel });
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (toolName === 'DELETE_PATH') {
|
|
1356
|
+
const rawPath = toolArg && typeof toolArg === 'object' ? toolArg.path : toolArg;
|
|
1357
|
+
const resolvedPath = resolveExistingWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
1358
|
+
const relPath = path.relative(workspaceRoot, resolvedPath);
|
|
1359
|
+
if (!relPath) throw new Error('Deleting the workspace root is not allowed.');
|
|
1360
|
+
const stat = await fs.promises.lstat(resolvedPath);
|
|
1361
|
+
const deleteIdentity = { dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
1362
|
+
if (stat.isDirectory()) {
|
|
1363
|
+
const entries = await fs.promises.readdir(resolvedPath);
|
|
1364
|
+
if (entries.length > 0) throw new Error('DELETE_PATH only removes files or empty directories.');
|
|
1365
|
+
}
|
|
1366
|
+
const displaySummary = cn ? `删除 ${relPath}` : `Delete ${relPath}`;
|
|
1367
|
+
event('permission.requested', { kind: 'delete', target: relPath, displaySummary });
|
|
1368
|
+
const proceed = await authorize({
|
|
1369
|
+
kind: 'delete',
|
|
1370
|
+
prompt: cn ? `确认删除 ${relPath}?(y/n): ` : `Delete ${relPath}? (y/n): `,
|
|
1371
|
+
displaySummary
|
|
1372
|
+
});
|
|
1373
|
+
event('permission.resolved', { kind: 'delete', target: relPath, allowed: proceed, displaySummary: proceed ? displaySummary : (cn ? '已取消删除' : 'Delete cancelled') });
|
|
1374
|
+
if (!proceed) {
|
|
1375
|
+
const modelResult = `Delete cancelled by user: ${relPath}`;
|
|
1376
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1377
|
+
}
|
|
1378
|
+
event('tool.started', { target: relPath, label: cn ? `正在删除 ${relPath}` : `Deleting ${relPath}` });
|
|
1379
|
+
resolveExistingWorkspacePath(String(rawPath || '').trim(), workspaceRoot);
|
|
1380
|
+
const currentDeleteIdentity = await fs.promises.lstat(resolvedPath);
|
|
1381
|
+
if (currentDeleteIdentity.dev !== deleteIdentity.dev || currentDeleteIdentity.ino !== deleteIdentity.ino || currentDeleteIdentity.mtimeMs !== deleteIdentity.mtimeMs || currentDeleteIdentity.size !== deleteIdentity.size) {
|
|
1382
|
+
throw new Error(`${relPath} changed while awaiting confirmation. Inspect it again before deleting.`);
|
|
1383
|
+
}
|
|
1384
|
+
await recordMutation(workspaceRoot, {
|
|
1385
|
+
kind: 'delete', path: relPath, directory: stat.isDirectory(),
|
|
1386
|
+
...(!stat.isDirectory() ? { content: await fs.promises.readFile(resolvedPath) } : {})
|
|
1387
|
+
});
|
|
1388
|
+
if (stat.isDirectory()) await fs.promises.rmdir(resolvedPath);
|
|
1389
|
+
else await fs.promises.unlink(resolvedPath);
|
|
1390
|
+
return success(`Successfully deleted: ${relPath}`, cn ? `已删除 ${relPath}` : `Deleted ${relPath}`, { target: relPath });
|
|
1391
|
+
}
|
|
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
|
+
|
|
1411
|
+
if (toolName === 'RUN_COMMAND') {
|
|
1412
|
+
const rawCommand = toolArg && typeof toolArg === 'object' ? toolArg.command : toolArg;
|
|
1413
|
+
const command = String(rawCommand || '').trim();
|
|
1414
|
+
if (!command) throw new Error('RUN_COMMAND command is empty.');
|
|
1415
|
+
const displaySummary = cn ? `运行命令: ${command}` : `Run command: ${command}`;
|
|
1416
|
+
event('permission.requested', { kind: 'command', displaySummary, preview: `$ ${command}` });
|
|
1417
|
+
const proceed = await authorize({
|
|
1418
|
+
kind: 'command',
|
|
1419
|
+
prompt: cn
|
|
1420
|
+
? '此命令不受沙箱限制,可能影响工作区外部。仍要运行?(y/n): '
|
|
1421
|
+
: 'This command is not sandboxed and may affect paths outside the workspace. Run it? (y/n): ',
|
|
1422
|
+
displaySummary,
|
|
1423
|
+
preview: `$ ${command}`
|
|
1424
|
+
});
|
|
1425
|
+
event('permission.resolved', { kind: 'command', allowed: proceed, displaySummary: proceed ? (cn ? '已允许命令执行' : 'Command approved') : (cn ? '已取消命令' : 'Command cancelled') });
|
|
1426
|
+
if (!proceed) {
|
|
1427
|
+
const modelResult = `Command cancelled by user: ${command}`;
|
|
1428
|
+
return { ok: true, cancelled: true, result: modelResult, modelResult, displaySummary, status: 'Cancelled' };
|
|
1429
|
+
}
|
|
1430
|
+
event('tool.started', { label: cn ? `正在运行 ${command}` : `Running ${command}` });
|
|
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);
|
|
1450
|
+
const combined = [
|
|
1451
|
+
`Command: ${command}`,
|
|
1452
|
+
`Exit code: ${commandResult.exitCode}`,
|
|
1453
|
+
commandResult.stdout ? `stdout:\n${commandResult.stdout}` : '',
|
|
1454
|
+
commandResult.stderr ? `stderr:\n${commandResult.stderr}` : ''
|
|
1455
|
+
].filter(Boolean).join('\n');
|
|
1456
|
+
const modelResult = truncateToolResult(combined, maxToolChars);
|
|
1457
|
+
if (commandResult.exitCode !== 0) {
|
|
1458
|
+
const failedSummary = cn ? `命令失败 · 退出码 ${commandResult.exitCode}` : `Command failed · exit ${commandResult.exitCode}`;
|
|
1459
|
+
event('tool.failed', { displaySummary: failedSummary, error: commandResult.stderr || commandResult.error?.message || failedSummary, durationMs: Date.now() - startedAt });
|
|
1460
|
+
return { ok: false, result: modelResult, modelResult, displaySummary: failedSummary, status: 'Failed', data: { exitCode: commandResult.exitCode } };
|
|
1461
|
+
}
|
|
1462
|
+
return success(modelResult, cn ? '命令执行完成' : 'Command completed', { exitCode: 0 });
|
|
1463
|
+
}
|
|
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
|
+
|
|
1496
|
+
if (toolName === 'SEARCH_GREP') {
|
|
1497
|
+
const request = toolArg && typeof toolArg === 'object' ? toolArg : { query: toolArg };
|
|
1498
|
+
const rawQuery = request.pattern ?? request.query;
|
|
1499
|
+
const query = String(rawQuery || '').trim();
|
|
1500
|
+
if (!query) throw new Error('Search query is empty.');
|
|
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
|
+
}
|
|
1532
|
+
const results = [];
|
|
1533
|
+
let totalMatches = 0;
|
|
1534
|
+
let searchWasPartial = false;
|
|
1535
|
+
const maxTotalMatches = 50;
|
|
1536
|
+
|
|
1537
|
+
async function searchGrep(dir, depth = 0) {
|
|
1538
|
+
if (depth > 6) {
|
|
1539
|
+
searchWasPartial = true;
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (totalMatches >= maxTotalMatches) return;
|
|
1543
|
+
const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
|
|
1544
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1545
|
+
for (const entry of entries) {
|
|
1546
|
+
if (totalMatches >= maxTotalMatches) break;
|
|
1547
|
+
const file = entry.name;
|
|
1548
|
+
if (file === 'node_modules' || file === '.git' || file === '.gemini' || file === 'package-lock.json') continue;
|
|
1549
|
+
const fullPath = path.join(dir, file);
|
|
1550
|
+
if (isSensitivePath(path.relative(workspaceRoot, fullPath))) continue;
|
|
1551
|
+
try {
|
|
1552
|
+
if (entry.isDirectory()) {
|
|
1553
|
+
await searchGrep(fullPath, depth + 1);
|
|
1554
|
+
} else if (entry.isFile()) {
|
|
1555
|
+
const stat = await fs.promises.stat(fullPath);
|
|
1556
|
+
const ext = path.extname(file).toLowerCase();
|
|
1557
|
+
if (!DEFAULT_TEXT_EXTS.has(ext) && stat.size >= 100000) continue;
|
|
1558
|
+
const content = await fs.promises.readFile(fullPath, 'utf8');
|
|
1559
|
+
if (!content.includes(query)) continue;
|
|
1560
|
+
const lines = content.split('\n');
|
|
1561
|
+
const fileMatches = [];
|
|
1562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1563
|
+
if (lines[i].includes(query)) {
|
|
1564
|
+
const text = lines[i].trim();
|
|
1565
|
+
fileMatches.push({ lineNum: i + 1, text: text.length > 140 ? `${text.slice(0, 137)}...` : text });
|
|
1566
|
+
totalMatches++;
|
|
1567
|
+
if (totalMatches >= maxTotalMatches) break;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (fileMatches.length > 0) {
|
|
1571
|
+
results.push({ file: path.relative(workspaceRoot, fullPath), matches: fileMatches });
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
} catch (e) {
|
|
1575
|
+
searchWasPartial = true;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
await searchGrep(workspaceRoot);
|
|
1581
|
+
if (results.length === 0) {
|
|
1582
|
+
const modelResult = `No matches found for "${query}"${searchWasPartial ? '\n[Search was partial because some paths were too deep or unreadable.]' : ''}`;
|
|
1583
|
+
return success(modelResult, cn ? `搜索 “${query}” · 无结果` : `Searched "${query}" · no matches`, { query, matchCount: 0, partial: searchWasPartial });
|
|
1584
|
+
}
|
|
1585
|
+
let output = 'Found matches:\n';
|
|
1586
|
+
for (const result of results) {
|
|
1587
|
+
output += `- ${result.file}:\n`;
|
|
1588
|
+
for (const match of result.matches) {
|
|
1589
|
+
output += ` Line ${match.lineNum}: ${match.text}\n`;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (totalMatches >= maxTotalMatches) {
|
|
1593
|
+
output += `[Search truncated at ${maxTotalMatches} matches. Use a narrower query if needed.]\n`;
|
|
1594
|
+
}
|
|
1595
|
+
if (searchWasPartial) output += '[Search was partial because some paths were too deep or unreadable.]\n';
|
|
1596
|
+
return success(
|
|
1597
|
+
truncateToolResult(output, maxToolChars),
|
|
1598
|
+
cn ? `搜索 “${query}” · ${totalMatches} 处` : `Searched "${query}" · ${totalMatches} matches`,
|
|
1599
|
+
{ query, matchCount: totalMatches, truncated: totalMatches >= maxTotalMatches, partial: searchWasPartial }
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
return failure(new Error(`Unknown tool: ${toolName}`));
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
return failure(error);
|
|
1606
|
+
}
|
|
1607
|
+
}
|