dave-code 1.1.0 → 1.2.0

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