codemini-cli 0.1.17 → 0.1.19

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/src/core/tools.js CHANGED
@@ -37,6 +37,25 @@ const TEXT_EXTENSIONS = new Set([
37
37
  '.sh',
38
38
  '.ps1'
39
39
  ]);
40
+ const CODE_WRITE_GUARD_EXTENSIONS = new Set([
41
+ '.js',
42
+ '.jsx',
43
+ '.ts',
44
+ '.tsx',
45
+ '.mjs',
46
+ '.cjs',
47
+ '.py',
48
+ '.rb',
49
+ '.go',
50
+ '.rs',
51
+ '.java',
52
+ '.cs',
53
+ '.css',
54
+ '.scss',
55
+ '.html',
56
+ '.sh',
57
+ '.ps1'
58
+ ]);
40
59
  const LANGUAGE_FILE_TYPES = {
41
60
  js: ['js', 'jsx', 'mjs', 'cjs'],
42
61
  ts: ['ts', 'tsx'],
@@ -96,10 +115,75 @@ function splitLines(text) {
96
115
  return String(text || '').split('\n');
97
116
  }
98
117
 
118
+ function findUniqueLineBlock(lines, blockContent) {
119
+ const probeLines = splitLines(blockContent);
120
+ if (probeLines.length === 0 || (probeLines.length === 1 && probeLines[0] === '')) return null;
121
+ const matches = [];
122
+ const lastStart = lines.length - probeLines.length;
123
+ for (let start = 0; start <= lastStart; start += 1) {
124
+ let ok = true;
125
+ for (let offset = 0; offset < probeLines.length; offset += 1) {
126
+ if (lines[start + offset] !== probeLines[offset]) {
127
+ ok = false;
128
+ break;
129
+ }
130
+ }
131
+ if (ok) {
132
+ matches.push({
133
+ start_line: start + 1,
134
+ end_line: start + probeLines.length,
135
+ content: probeLines.join('\n')
136
+ });
137
+ if (matches.length > 1) break;
138
+ }
139
+ }
140
+ return matches.length === 1 ? matches[0] : null;
141
+ }
142
+
143
+ function resolveReplaceBlockTarget(state, target) {
144
+ const startLine = Number(target?.start_line);
145
+ const endLine = Number(target?.end_line);
146
+ const oldHash = String(target?.old_hash || '');
147
+ const currentBlock =
148
+ Number.isFinite(startLine) && Number.isFinite(endLine) && startLine > 0 && endLine >= startLine
149
+ ? state.lines.slice(startLine - 1, endLine).join('\n')
150
+ : '';
151
+
152
+ if (oldHash && currentBlock && oldHash === sha256(currentBlock)) {
153
+ return {
154
+ start_line: startLine,
155
+ end_line: endLine,
156
+ old_hash: oldHash,
157
+ old_content: currentBlock,
158
+ relocated: false
159
+ };
160
+ }
161
+
162
+ const oldContent = String(target?.old_content || '');
163
+ if (oldContent) {
164
+ const relocated = findUniqueLineBlock(state.lines, oldContent);
165
+ if (relocated) {
166
+ return {
167
+ start_line: relocated.start_line,
168
+ end_line: relocated.end_line,
169
+ old_hash: sha256(relocated.content),
170
+ old_content: relocated.content,
171
+ relocated: true
172
+ };
173
+ }
174
+ }
175
+
176
+ return null;
177
+ }
178
+
99
179
  function detectTextFile(filePath) {
100
180
  return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
101
181
  }
102
182
 
183
+ function isCodeLikePath(filePath) {
184
+ return CODE_WRITE_GUARD_EXTENSIONS.has(path.extname(String(filePath || '')).toLowerCase());
185
+ }
186
+
103
187
  function normalizeFileTypes(args = {}) {
104
188
  const explicit = Array.isArray(args?.file_types) ? args.file_types.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : [];
105
189
  const language = String(args?.language || '').trim().toLowerCase();
@@ -133,6 +217,63 @@ async function walkTextFiles(root, startPath = '.', fileTypes = []) {
133
217
  return out;
134
218
  }
135
219
 
220
+ async function walkWorkspaceEntries(root, startPath = '.', { includeHidden = false } = {}) {
221
+ const abs = resolveInWorkspace(root, startPath);
222
+ const out = [];
223
+
224
+ async function visit(current) {
225
+ const stat = await fs.stat(current);
226
+ const relative = toWorkspaceRelative(root, current) || '.';
227
+ const name = path.basename(current);
228
+
229
+ if (!includeHidden && name.startsWith('.') && relative !== '.') return;
230
+ if (stat.isDirectory()) {
231
+ if (SKIP_DIRS.has(name) && relative !== '.') return;
232
+ out.push({ path: relative, name, type: 'dir' });
233
+ const entries = await fs.readdir(current);
234
+ for (const entry of entries) {
235
+ await visit(path.join(current, entry));
236
+ }
237
+ return;
238
+ }
239
+
240
+ out.push({ path: relative, name, type: 'file' });
241
+ }
242
+
243
+ await visit(abs);
244
+ return out;
245
+ }
246
+
247
+ function globToRegex(pattern) {
248
+ const normalized = String(pattern || '').replace(/\\/g, '/').trim();
249
+ let regexBody = '';
250
+ for (let i = 0; i < normalized.length; i += 1) {
251
+ const ch = normalized[i];
252
+ const next = normalized[i + 1];
253
+ const afterNext = normalized[i + 2];
254
+ if (ch === '*' && next === '*' && afterNext === '/') {
255
+ regexBody += '(?:.*/)?';
256
+ i += 2;
257
+ continue;
258
+ }
259
+ if (ch === '*' && next === '*') {
260
+ regexBody += '.*';
261
+ i += 1;
262
+ continue;
263
+ }
264
+ if (ch === '*') {
265
+ regexBody += '[^/]*';
266
+ continue;
267
+ }
268
+ if (ch === '?') {
269
+ regexBody += '[^/]';
270
+ continue;
271
+ }
272
+ regexBody += /[-/\\^$+?.()|[\]{}]/.test(ch) ? `\\${ch}` : ch;
273
+ }
274
+ return new RegExp(`^${regexBody}$`);
275
+ }
276
+
136
277
  function getLineColumnForMatch(line, query, caseSensitive = false) {
137
278
  const haystack = caseSensitive ? line : line.toLowerCase();
138
279
  const needle = caseSensitive ? query : query.toLowerCase();
@@ -384,6 +525,129 @@ function buildUnifiedDiff(oldContent, newContent, filePath = 'file') {
384
525
  return body.join('\n');
385
526
  }
386
527
 
528
+ function parseUnifiedPatch(patchText) {
529
+ const lines = splitLines(String(patchText || ''));
530
+ const files = [];
531
+ let current = null;
532
+
533
+ const pushCurrent = () => {
534
+ if (current) files.push(current);
535
+ };
536
+
537
+ for (let i = 0; i < lines.length; i += 1) {
538
+ const line = lines[i];
539
+ if (line.startsWith('--- ')) {
540
+ pushCurrent();
541
+ current = {
542
+ oldPath: line.slice(4).trim(),
543
+ newPath: '',
544
+ hunks: []
545
+ };
546
+ continue;
547
+ }
548
+ if (!current) continue;
549
+ if (line.startsWith('+++ ')) {
550
+ current.newPath = line.slice(4).trim();
551
+ continue;
552
+ }
553
+ if (line.startsWith('@@ ')) {
554
+ const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
555
+ if (!match) {
556
+ throw new Error(`invalid patch hunk header: ${line}`);
557
+ }
558
+ const hunk = {
559
+ oldStart: Number(match[1]),
560
+ oldCount: Number(match[2] || '1'),
561
+ newStart: Number(match[3]),
562
+ newCount: Number(match[4] || '1'),
563
+ lines: []
564
+ };
565
+ i += 1;
566
+ while (i < lines.length) {
567
+ const hunkLine = lines[i];
568
+ if (hunkLine.startsWith('@@ ') || hunkLine.startsWith('--- ')) {
569
+ i -= 1;
570
+ break;
571
+ }
572
+ if (hunkLine.startsWith('\')) {
573
+ i += 1;
574
+ continue;
575
+ }
576
+ if (hunkLine === '') {
577
+ hunk.lines.push(' ');
578
+ i += 1;
579
+ continue;
580
+ }
581
+ if (!/^[ +\-]/.test(hunkLine)) {
582
+ hunk.lines.push(` ${hunkLine}`);
583
+ i += 1;
584
+ continue;
585
+ }
586
+ if (!/^[ +\-]/.test(hunkLine)) {
587
+ throw new Error(`invalid patch line: ${hunkLine}`);
588
+ }
589
+ hunk.lines.push(hunkLine);
590
+ i += 1;
591
+ }
592
+ current.hunks.push(hunk);
593
+ }
594
+ }
595
+
596
+ pushCurrent();
597
+ return files.filter((file) => file.oldPath || file.newPath);
598
+ }
599
+
600
+ function applyHunkToLines(lines, hunk) {
601
+ const oldChunk = [];
602
+ const newChunk = [];
603
+ for (const line of hunk.lines) {
604
+ if (line.startsWith(' ')) {
605
+ const text = line.slice(1);
606
+ oldChunk.push(text);
607
+ newChunk.push(text);
608
+ continue;
609
+ }
610
+ if (line.startsWith('-')) {
611
+ oldChunk.push(line.slice(1));
612
+ continue;
613
+ }
614
+ if (line.startsWith('+')) {
615
+ newChunk.push(line.slice(1));
616
+ }
617
+ }
618
+
619
+ if (oldChunk.length === 0) {
620
+ const insertAt = Math.max(0, Number(hunk.oldStart || 1) - 1);
621
+ return [...lines.slice(0, insertAt), ...newChunk, ...lines.slice(insertAt)];
622
+ }
623
+
624
+ const lastStart = Math.max(0, lines.length - oldChunk.length);
625
+ const matches = [];
626
+ for (let start = 0; start <= lastStart; start += 1) {
627
+ let ok = true;
628
+ for (let offset = 0; offset < oldChunk.length; offset += 1) {
629
+ if (lines[start + offset] !== oldChunk[offset]) {
630
+ ok = false;
631
+ break;
632
+ }
633
+ }
634
+ if (ok) {
635
+ matches.push(start);
636
+ if (matches.length > 1) break;
637
+ }
638
+ }
639
+
640
+ if (matches.length === 0) {
641
+ throw new Error('patch hunk context not found');
642
+ }
643
+ if (matches.length > 1) {
644
+ throw new Error('patch hunk context not unique');
645
+ }
646
+
647
+ const start = matches[0];
648
+ return [...lines.slice(0, start), ...newChunk, ...lines.slice(start + oldChunk.length)];
649
+ }
650
+
387
651
  async function getFileState(root, relativePath) {
388
652
  const target = resolveInWorkspace(root, relativePath);
389
653
  const stat = await fs.stat(target);
@@ -429,7 +693,7 @@ async function readFile(root, args) {
429
693
  suggested_start_line: startLine,
430
694
  suggested_end_line: endLine,
431
695
  read_token: readToken,
432
- next: 'Call read_file again with include_content=true and this read_token'
696
+ next: 'Call read again with include_content=true and this read_token'
433
697
  };
434
698
  }
435
699
 
@@ -469,16 +733,16 @@ async function readFile(root, args) {
469
733
  async function writeFile(root, args) {
470
734
  const rawPath = String(args?.path || '').trim();
471
735
  if (!rawPath) {
472
- throw new Error('write_file requires a file path like weather/WeatherForecast.js');
736
+ throw new Error('write requires a file path like weather/WeatherForecast.js');
473
737
  }
474
738
  if (rawPath === '.' || rawPath === './') {
475
- throw new Error('write_file requires a file path, not the workspace root');
739
+ throw new Error('write requires a file path, not the workspace root');
476
740
  }
477
741
  const target = resolveInWorkspace(root, rawPath);
478
742
  try {
479
743
  const stat = await fs.stat(target);
480
744
  if (stat.isDirectory()) {
481
- throw new Error(`write_file target is a directory: ${rawPath}`);
745
+ throw new Error(`write target is a directory: ${rawPath}`);
482
746
  }
483
747
  } catch (error) {
484
748
  if (error?.code && error.code !== 'ENOENT') throw error;
@@ -490,6 +754,11 @@ async function writeFile(root, args) {
490
754
  } catch {
491
755
  existed = false;
492
756
  }
757
+ if (existed && !args?.append && !args?.full_file_rewrite && isCodeLikePath(rawPath)) {
758
+ throw new Error(
759
+ 'write blocks full overwrite for existing code files by default. Use grep/read -> edit for minimal edits, or pass full_file_rewrite=true when a whole-file rewrite is truly intended.'
760
+ );
761
+ }
493
762
  await fs.mkdir(path.dirname(target), { recursive: true });
494
763
  if (args?.append) {
495
764
  await fs.appendFile(target, args?.content || '', 'utf8');
@@ -521,10 +790,10 @@ async function writeFile(root, args) {
521
790
  async function runCommand(root, config, args) {
522
791
  const command = args?.command || '';
523
792
  if (!command.trim()) {
524
- throw new Error('run_command requires command');
793
+ throw new Error('run requires command');
525
794
  }
526
795
  if (isLikelyLongRunningCommand(command)) {
527
- throw new Error('Command looks like a long-running service. Use start_service instead of run_command.');
796
+ throw new Error('Command looks like a long-running service. Use start_service instead of run.');
528
797
  }
529
798
  if (
530
799
  !config.policy.allow_dangerous_commands &&
@@ -905,6 +1174,81 @@ async function searchCode(root, args) {
905
1174
  };
906
1175
  }
907
1176
 
1177
+ async function grep(root, args) {
1178
+ const pattern = String(args?.pattern || args?.query || '').trim();
1179
+ if (!pattern) throw new Error('grep requires pattern');
1180
+ const maxResults = Math.max(1, Math.min(200, Number(args?.max_results || 50)));
1181
+ const caseSensitive = Boolean(args?.case_sensitive);
1182
+ const files = await walkTextFiles(root, args?.path || '.', normalizeFileTypes(args));
1183
+ const regex = args?.regex
1184
+ ? new RegExp(pattern, caseSensitive ? 'g' : 'gi')
1185
+ : new RegExp(escapeRegex(pattern), caseSensitive ? 'g' : 'gi');
1186
+ const matches = [];
1187
+
1188
+ for (const filePath of files) {
1189
+ const content = await fs.readFile(filePath, 'utf8');
1190
+ const lines = splitLines(content);
1191
+ for (let idx = 0; idx < lines.length; idx += 1) {
1192
+ const line = String(lines[idx] || '');
1193
+ regex.lastIndex = 0;
1194
+ const found = regex.exec(line);
1195
+ if (!found) continue;
1196
+ matches.push({
1197
+ path: toWorkspaceRelative(root, filePath),
1198
+ line: idx + 1,
1199
+ column: Math.max(1, Number(found.index || 0) + 1),
1200
+ preview: trimLinePreview(line)
1201
+ });
1202
+ if (matches.length >= maxResults) {
1203
+ return { pattern, matches, truncated: true };
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ return { pattern, matches, truncated: false };
1209
+ }
1210
+
1211
+ async function glob(root, args) {
1212
+ const pattern = String(args?.pattern || '').trim();
1213
+ if (!pattern) throw new Error('glob requires pattern');
1214
+ const maxResults = Math.max(1, Math.min(500, Number(args?.max_results || 200)));
1215
+ const regex = globToRegex(pattern);
1216
+ const entries = await walkWorkspaceEntries(root, args?.path || '.', {
1217
+ includeHidden: Boolean(args?.include_hidden)
1218
+ });
1219
+ const matches = entries
1220
+ .filter((entry) => entry.type === 'file' && regex.test(entry.path))
1221
+ .slice(0, maxResults)
1222
+ .map((entry) => entry.path);
1223
+ return {
1224
+ pattern,
1225
+ matches,
1226
+ truncated: entries.filter((entry) => entry.type === 'file' && regex.test(entry.path)).length > matches.length
1227
+ };
1228
+ }
1229
+
1230
+ async function list(root, args) {
1231
+ const relativePath = String(args?.path || '.').trim() || '.';
1232
+ const target = resolveInWorkspace(root, relativePath);
1233
+ const entries = await fs.readdir(target, { withFileTypes: true });
1234
+ const includeHidden = Boolean(args?.include_hidden);
1235
+ const items = entries
1236
+ .filter((entry) => includeHidden || !entry.name.startsWith('.'))
1237
+ .map((entry) => ({
1238
+ name: entry.name,
1239
+ path: path.posix.join(relativePath === '.' ? '' : relativePath.replace(/\\/g, '/'), entry.name) || entry.name,
1240
+ type: entry.isDirectory() ? 'dir' : 'file'
1241
+ }))
1242
+ .sort((left, right) => {
1243
+ if (left.type !== right.type) return left.type === 'dir' ? -1 : 1;
1244
+ return left.path.localeCompare(right.path);
1245
+ });
1246
+ return {
1247
+ path: relativePath,
1248
+ items
1249
+ };
1250
+ }
1251
+
908
1252
  async function readBlock(root, args) {
909
1253
  const relativePath = String(args?.path || '').trim();
910
1254
  if (!relativePath) throw new Error('read_block requires path');
@@ -957,17 +1301,25 @@ async function validateEdit(root, args) {
957
1301
  if (!Number.isFinite(startLine) || !Number.isFinite(endLine) || startLine <= 0 || endLine < startLine) {
958
1302
  throw new Error('replace_block validation requires target.start_line and target.end_line');
959
1303
  }
960
- const oldBlock = lines.slice(startLine - 1, endLine).join('\n');
1304
+ const resolved = resolveReplaceBlockTarget({ content, lines }, {
1305
+ start_line: startLine,
1306
+ end_line: endLine,
1307
+ old_hash: args?.target?.old_hash,
1308
+ old_content: args?.target?.old_content
1309
+ });
1310
+ const oldBlock = resolved?.old_content || lines.slice(startLine - 1, endLine).join('\n');
961
1311
  return {
962
1312
  ok: true,
963
1313
  path: relativePath,
964
1314
  kind,
965
1315
  target: {
966
- start_line: startLine,
967
- end_line: endLine,
968
- old_hash: sha256(oldBlock)
1316
+ start_line: resolved?.start_line || startLine,
1317
+ end_line: resolved?.end_line || endLine,
1318
+ old_hash: sha256(oldBlock),
1319
+ old_content: oldBlock
969
1320
  },
970
- file_hash: sha256(content)
1321
+ file_hash: sha256(content),
1322
+ relocated: Boolean(resolved?.relocated)
971
1323
  };
972
1324
  }
973
1325
 
@@ -1007,18 +1359,19 @@ async function replaceBlock(root, args) {
1007
1359
  const relativePath = String(args?.path || '').trim();
1008
1360
  const newContent = String(args?.new_content || args?.content || '');
1009
1361
  const target = args?.target || {};
1010
- const startLine = Number(target.start_line);
1011
- const endLine = Number(target.end_line);
1012
- const oldHash = String(target.old_hash || '');
1013
1362
  const state = await getFileState(root, relativePath);
1014
- const oldBlock = state.lines.slice(startLine - 1, endLine).join('\n');
1015
- if (!oldHash || oldHash !== sha256(oldBlock)) {
1016
- throw new Error('replace_block old_hash mismatch');
1363
+ const resolved = resolveReplaceBlockTarget(state, target);
1364
+ if (!resolved) {
1365
+ throw new Error('replace_block old_hash mismatch; retry through edit with a symbol or line hint');
1017
1366
  }
1018
- const nextLines = [...state.lines.slice(0, startLine - 1), ...splitLines(newContent), ...state.lines.slice(endLine)];
1367
+ const nextLines = [
1368
+ ...state.lines.slice(0, resolved.start_line - 1),
1369
+ ...splitLines(newContent),
1370
+ ...state.lines.slice(resolved.end_line)
1371
+ ];
1019
1372
  const afterContent = nextLines.join('\n');
1020
1373
  await fs.writeFile(state.target, afterContent, 'utf8');
1021
- return editResult(relativePath, 'replace_block', state.content, afterContent, startLine);
1374
+ return editResult(relativePath, 'replace_block', state.content, afterContent, resolved.start_line);
1022
1375
  }
1023
1376
 
1024
1377
  async function replaceText(root, args) {
@@ -1028,7 +1381,11 @@ async function replaceText(root, args) {
1028
1381
  const state = await getFileState(root, relativePath);
1029
1382
  const occurrences = state.content.split(oldText).length - 1;
1030
1383
  if (occurrences !== 1) {
1031
- throw new Error(occurrences === 0 ? 'replace_text old_text not found' : 'replace_text old_text not unique');
1384
+ throw new Error(
1385
+ occurrences === 0
1386
+ ? 'replace_text old_text not found; use edit with a symbol or line hint for block edits'
1387
+ : 'replace_text old_text not unique; use a larger unique fragment or retry through edit'
1388
+ );
1032
1389
  }
1033
1390
  const afterContent = state.content.replace(oldText, newText);
1034
1391
  await fs.writeFile(state.target, afterContent, 'utf8');
@@ -1065,16 +1422,55 @@ async function generateDiff(root, args) {
1065
1422
  };
1066
1423
  }
1067
1424
 
1068
- async function locate(root, args) {
1069
- const result = await searchCode(root, args);
1070
- return {
1071
- query: result.query,
1072
- matches: result.matches,
1073
- definitions: result.definitions,
1074
- references: result.references,
1075
- text_matches: result.text_matches,
1076
- truncated: result.truncated
1077
- };
1425
+ async function applyPatch(root, args) {
1426
+ const patchText = String(args?.patch || args?.content || '').trim();
1427
+ if (!patchText) throw new Error('patch requires patch content');
1428
+ const files = parseUnifiedPatch(patchText);
1429
+ if (files.length === 0) throw new Error('patch contains no file changes');
1430
+
1431
+ const results = [];
1432
+ for (const fileChange of files) {
1433
+ const newPath = String(fileChange.newPath || '').trim();
1434
+ const oldPath = String(fileChange.oldPath || '').trim();
1435
+ const targetPath = newPath && newPath !== '/dev/null' ? newPath : oldPath;
1436
+ if (!targetPath || targetPath === '/dev/null') {
1437
+ throw new Error('patch requires a target file path');
1438
+ }
1439
+ const absTarget = resolveInWorkspace(root, targetPath);
1440
+ let beforeContent = '';
1441
+ let beforeLines = [];
1442
+ try {
1443
+ beforeContent = await fs.readFile(absTarget, 'utf8');
1444
+ beforeLines = splitLines(beforeContent);
1445
+ } catch (error) {
1446
+ if (!(error && error.code === 'ENOENT')) throw error;
1447
+ }
1448
+
1449
+ let nextLines = beforeLines;
1450
+ for (const hunk of fileChange.hunks) {
1451
+ nextLines = applyHunkToLines(nextLines, hunk);
1452
+ }
1453
+ const afterContent = nextLines.join('\n');
1454
+
1455
+ if (newPath === '/dev/null') {
1456
+ await fs.rm(absTarget, { force: true });
1457
+ results.push({
1458
+ path: targetPath,
1459
+ action: 'delete',
1460
+ changed_line: 1,
1461
+ diff_preview: `deleted ${targetPath}`,
1462
+ diff: buildUnifiedDiff(beforeContent, '', targetPath),
1463
+ new_hash: sha256('')
1464
+ });
1465
+ continue;
1466
+ }
1467
+
1468
+ await fs.mkdir(path.dirname(absTarget), { recursive: true });
1469
+ await fs.writeFile(absTarget, afterContent, 'utf8');
1470
+ results.push(editResult(targetPath, beforeContent ? 'patch' : 'create', beforeContent, afterContent, 1));
1471
+ }
1472
+
1473
+ return results.length === 1 ? results[0] : { ok: true, files: results };
1078
1474
  }
1079
1475
 
1080
1476
  async function openTarget(root, args) {
@@ -1097,10 +1493,11 @@ async function openTarget(root, args) {
1097
1493
  symbol: symbol || undefined,
1098
1494
  main_block: block,
1099
1495
  related: mainBlock.related || { imports: [], local_symbols: [] },
1100
- edit_target: {
1496
+ edit: {
1101
1497
  start_line: block.start_line,
1102
1498
  end_line: block.end_line,
1103
- old_hash: sha256(block.content)
1499
+ old_hash: sha256(block.content),
1500
+ old_content: block.content
1104
1501
  }
1105
1502
  };
1106
1503
  }
@@ -1109,9 +1506,16 @@ function normalizeEditTargetArgs(args = {}) {
1109
1506
  const file = String(args?.file || args?.path || '').trim();
1110
1507
  const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1111
1508
  if (nestedEdit) {
1509
+ const normalizedEdit = { ...nestedEdit };
1510
+ if (normalizedEdit.new_content == null && normalizedEdit.content != null) {
1511
+ normalizedEdit.new_content = normalizedEdit.content;
1512
+ }
1513
+ if (normalizedEdit.new_text == null && normalizedEdit.content != null && normalizedEdit.old_text != null) {
1514
+ normalizedEdit.new_text = normalizedEdit.content;
1515
+ }
1112
1516
  return {
1113
1517
  file,
1114
- edit: nestedEdit
1518
+ edit: normalizedEdit
1115
1519
  };
1116
1520
  }
1117
1521
  return {
@@ -1132,13 +1536,35 @@ async function editTarget(root, args) {
1132
1536
  const normalized = normalizeEditTargetArgs(args);
1133
1537
  const file = normalized.file;
1134
1538
  const edit = normalized.edit || {};
1135
- const kind = String(edit.kind || '').trim();
1136
- if (!file || !kind) throw new Error('edit_target requires file and edit.kind');
1539
+ let kind = String(edit.kind || '').trim();
1540
+ const hasContent = edit.new_content != null || edit.content != null;
1541
+ const hasTargetHint = Boolean(edit.symbol || args?.symbol || edit.line || args?.line || edit.target);
1542
+ if (!kind) {
1543
+ if (hasContent && hasTargetHint) {
1544
+ kind = 'replace_block';
1545
+ } else if (edit.old_text != null && (edit.new_text != null || edit.content != null)) {
1546
+ kind = 'replace_text';
1547
+ } else if ((edit.anchor_text != null || edit.target_text != null) && (edit.content != null || edit.new_content != null)) {
1548
+ kind = String(edit.position || edit.mode || args?.position || '').trim() === 'after' ? 'insert_after' : 'insert_before';
1549
+ } else if (hasContent) {
1550
+ kind = 'rewrite_file';
1551
+ }
1552
+ }
1553
+ if (!file || !kind) throw new Error('edit requires file and edit.kind');
1137
1554
  if (kind === 'replace_block') {
1555
+ const resolvedTarget =
1556
+ edit.target ||
1557
+ (
1558
+ await openTarget(root, {
1559
+ file,
1560
+ symbol: edit.symbol || args?.symbol,
1561
+ line: edit.line || args?.line
1562
+ })
1563
+ ).edit;
1138
1564
  try {
1139
1565
  return await replaceBlock(root, {
1140
1566
  path: file,
1141
- target: edit.target,
1567
+ target: resolvedTarget,
1142
1568
  new_content: edit.new_content
1143
1569
  });
1144
1570
  } catch (error) {
@@ -1146,7 +1572,7 @@ async function editTarget(root, args) {
1146
1572
  const validation = await validateEdit(root, {
1147
1573
  path: file,
1148
1574
  kind: 'replace_block',
1149
- target: edit.target
1575
+ target: resolvedTarget
1150
1576
  });
1151
1577
  return replaceBlock(root, {
1152
1578
  path: file,
@@ -1168,7 +1594,14 @@ async function editTarget(root, args) {
1168
1594
  if (kind === 'insert_after') {
1169
1595
  return insertRelative(root, { path: file, anchor_text: edit.anchor_text, content: edit.content }, 'insert_after');
1170
1596
  }
1171
- throw new Error(`edit_target does not support kind: ${kind}`);
1597
+ if (kind === 'rewrite_file') {
1598
+ return writeFile(root, {
1599
+ path: file,
1600
+ content: edit.new_content ?? edit.content ?? '',
1601
+ full_file_rewrite: true
1602
+ });
1603
+ }
1604
+ throw new Error(`edit does not support kind: ${kind}`);
1172
1605
  }
1173
1606
 
1174
1607
  export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
@@ -1176,192 +1609,131 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1176
1609
  {
1177
1610
  type: 'function',
1178
1611
  function: {
1179
- name: 'locate',
1180
- description: 'High-level search that returns compact candidate code locations',
1181
- parameters: {
1182
- type: 'object',
1183
- properties: {
1184
- query: { type: 'string' },
1185
- path: { type: 'string' },
1186
- max_results: { type: 'number' },
1187
- language: { type: 'string' },
1188
- file_types: { type: 'array', items: { type: 'string' } }
1189
- },
1190
- required: ['query']
1191
- }
1192
- }
1193
- },
1194
- {
1195
- type: 'function',
1196
- function: {
1197
- name: 'open_target',
1198
- description: 'Open a candidate location and return the smallest useful code block plus edit metadata',
1199
- parameters: {
1200
- type: 'object',
1201
- properties: {
1202
- file: { type: 'string' },
1203
- path: { type: 'string' },
1204
- line: { type: 'number' },
1205
- symbol: { type: 'string' },
1206
- max_related_calls: { type: 'number' },
1207
- max_related_imports: { type: 'number' },
1208
- max_related_types: { type: 'number' }
1209
- },
1210
- required: ['file']
1211
- }
1212
- }
1213
- },
1214
- {
1215
- type: 'function',
1216
- function: {
1217
- name: 'edit_target',
1218
- description: 'Apply a validated high-level edit against an opened target',
1612
+ name: 'read',
1613
+ description:
1614
+ 'Primary read tool. First call returns metadata+read_token, second call with include_content=true and matching read_token returns content',
1219
1615
  parameters: {
1220
1616
  type: 'object',
1221
1617
  properties: {
1222
- file: { type: 'string' },
1223
1618
  path: { type: 'string' },
1224
- edit: { type: 'object' }
1619
+ start_line: { type: 'number' },
1620
+ end_line: { type: 'number' },
1621
+ max_chars: { type: 'number' },
1622
+ include_content: { type: 'boolean' },
1623
+ read_token: { type: 'string' }
1225
1624
  },
1226
- required: ['file', 'edit']
1625
+ required: ['path']
1227
1626
  }
1228
1627
  }
1229
1628
  },
1230
1629
  {
1231
1630
  type: 'function',
1232
1631
  function: {
1233
- name: 'search_code',
1234
- description: 'Search code and return structured top matches with file, line, preview, and basic match kind',
1632
+ name: 'grep',
1633
+ description: 'Search file contents using a plain string or regex pattern and return compact matches',
1235
1634
  parameters: {
1236
1635
  type: 'object',
1237
1636
  properties: {
1637
+ pattern: { type: 'string' },
1238
1638
  query: { type: 'string' },
1239
1639
  path: { type: 'string' },
1240
- max_results: { type: 'number' },
1640
+ regex: { type: 'boolean' },
1241
1641
  case_sensitive: { type: 'boolean' },
1642
+ max_results: { type: 'number' },
1242
1643
  language: { type: 'string' },
1243
1644
  file_types: { type: 'array', items: { type: 'string' } }
1244
1645
  },
1245
- required: ['query']
1646
+ required: ['pattern']
1246
1647
  }
1247
1648
  }
1248
1649
  },
1249
1650
  {
1250
1651
  type: 'function',
1251
1652
  function: {
1252
- name: 'read_block',
1253
- description: 'Read the smallest likely code block around a symbol or line from a file',
1653
+ name: 'glob',
1654
+ description: 'Find files by glob pattern such as **/*.ts or src/**/*.tsx',
1254
1655
  parameters: {
1255
1656
  type: 'object',
1256
1657
  properties: {
1658
+ pattern: { type: 'string' },
1257
1659
  path: { type: 'string' },
1258
- symbol: { type: 'string' },
1259
- line: { type: 'number' },
1260
- anchor_line: { type: 'number' }
1660
+ include_hidden: { type: 'boolean' },
1661
+ max_results: { type: 'number' }
1261
1662
  },
1262
- required: ['path']
1663
+ required: ['pattern']
1263
1664
  }
1264
1665
  }
1265
1666
  },
1266
1667
  {
1267
1668
  type: 'function',
1268
1669
  function: {
1269
- name: 'read_symbol_context',
1270
- description: 'Read a symbol block plus import and local symbol summaries',
1670
+ name: 'list',
1671
+ description: 'List files and directories in a workspace path',
1271
1672
  parameters: {
1272
1673
  type: 'object',
1273
1674
  properties: {
1274
1675
  path: { type: 'string' },
1275
- symbol: { type: 'string' },
1276
- max_related_calls: { type: 'number' },
1277
- max_related_imports: { type: 'number' },
1278
- max_related_types: { type: 'number' }
1279
- },
1280
- required: ['path', 'symbol']
1676
+ include_hidden: { type: 'boolean' }
1677
+ }
1281
1678
  }
1282
1679
  }
1283
1680
  },
1284
1681
  {
1285
1682
  type: 'function',
1286
1683
  function: {
1287
- name: 'validate_edit',
1288
- description: 'Validate whether an edit target is stable before applying it',
1684
+ name: 'edit',
1685
+ description:
1686
+ 'Preferred edit tool for existing files. Accepts natural forms such as file + new_content for whole-file rewrites, file + symbol/line + new_content for block edits, file + old_text + new_text for exact replacements, and file + anchor_text + content for anchored inserts. A nested edit object is also supported.',
1289
1687
  parameters: {
1290
1688
  type: 'object',
1291
1689
  properties: {
1690
+ file: { type: 'string' },
1292
1691
  path: { type: 'string' },
1293
- kind: { type: 'string' },
1294
- target: { type: 'object' },
1295
- start_line: { type: 'number' },
1296
- end_line: { type: 'number' },
1692
+ new_content: { type: 'string' },
1297
1693
  old_text: { type: 'string' },
1298
- anchor_text: { type: 'string' }
1299
- },
1300
- required: ['path', 'kind']
1301
- }
1302
- }
1303
- },
1304
- {
1305
- type: 'function',
1306
- function: {
1307
- name: 'replace_block',
1308
- description: 'Replace a validated line block using an old_hash guard',
1309
- parameters: {
1310
- type: 'object',
1311
- properties: {
1312
- path: { type: 'string' },
1694
+ new_text: { type: 'string' },
1695
+ anchor_text: { type: 'string' },
1696
+ content: { type: 'string' },
1697
+ position: { type: 'string' },
1698
+ kind: { type: 'string' },
1313
1699
  target: { type: 'object' },
1314
- new_content: { type: 'string' }
1315
- },
1316
- required: ['path', 'target', 'new_content']
1317
- }
1318
- }
1319
- },
1320
- {
1321
- type: 'function',
1322
- function: {
1323
- name: 'replace_text',
1324
- description: 'Replace a unique text fragment in a file',
1325
- parameters: {
1326
- type: 'object',
1327
- properties: {
1328
- path: { type: 'string' },
1329
- old_text: { type: 'string' },
1330
- new_text: { type: 'string' }
1700
+ symbol: { type: 'string' },
1701
+ line: { type: 'number' },
1702
+ edit: { type: 'object' },
1331
1703
  },
1332
- required: ['path', 'old_text', 'new_text']
1704
+ required: ['file']
1333
1705
  }
1334
1706
  }
1335
1707
  },
1336
1708
  {
1337
1709
  type: 'function',
1338
1710
  function: {
1339
- name: 'insert_before',
1340
- description: 'Insert text before a unique anchor string',
1711
+ name: 'write',
1712
+ description:
1713
+ 'Primary write tool. Create a UTF-8 text file or overwrite an existing file. Existing code files require full_file_rewrite=true for whole-file overwrites.',
1341
1714
  parameters: {
1342
1715
  type: 'object',
1343
1716
  properties: {
1344
1717
  path: { type: 'string' },
1345
- anchor_text: { type: 'string' },
1346
- content: { type: 'string' }
1718
+ content: { type: 'string' },
1719
+ append: { type: 'boolean' },
1720
+ full_file_rewrite: { type: 'boolean' }
1347
1721
  },
1348
- required: ['path', 'anchor_text', 'content']
1722
+ required: ['path', 'content']
1349
1723
  }
1350
1724
  }
1351
1725
  },
1352
1726
  {
1353
1727
  type: 'function',
1354
1728
  function: {
1355
- name: 'insert_after',
1356
- description: 'Insert text after a unique anchor string',
1729
+ name: 'run',
1730
+ description: 'Primary run tool. Execute a one-shot shell command in workspace. Do not use for long-running services.',
1357
1731
  parameters: {
1358
1732
  type: 'object',
1359
1733
  properties: {
1360
- path: { type: 'string' },
1361
- anchor_text: { type: 'string' },
1362
- content: { type: 'string' }
1734
+ command: { type: 'string' }
1363
1735
  },
1364
- required: ['path', 'anchor_text', 'content']
1736
+ required: ['command']
1365
1737
  }
1366
1738
  }
1367
1739
  },
@@ -1383,50 +1755,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1383
1755
  {
1384
1756
  type: 'function',
1385
1757
  function: {
1386
- name: 'read_file',
1387
- description:
1388
- 'Two-phase read: first call returns metadata+read_token; second call with include_content=true and matching read_token returns content',
1389
- parameters: {
1390
- type: 'object',
1391
- properties: {
1392
- path: { type: 'string' },
1393
- start_line: { type: 'number' },
1394
- end_line: { type: 'number' },
1395
- max_chars: { type: 'number' },
1396
- include_content: { type: 'boolean' },
1397
- read_token: { type: 'string' }
1398
- },
1399
- required: ['path']
1400
- }
1401
- }
1402
- },
1403
- {
1404
- type: 'function',
1405
- function: {
1406
- name: 'write_file',
1407
- description: 'Write a UTF-8 text file in workspace. Always provide a full file path, not a directory.',
1408
- parameters: {
1409
- type: 'object',
1410
- properties: {
1411
- path: { type: 'string' },
1412
- content: { type: 'string' },
1413
- append: { type: 'boolean' }
1414
- },
1415
- required: ['path', 'content']
1416
- }
1417
- }
1418
- },
1419
- {
1420
- type: 'function',
1421
- function: {
1422
- name: 'run_command',
1423
- description: 'Execute a one-shot shell command in workspace. Do not use for long-running services.',
1758
+ name: 'patch',
1759
+ description: 'Apply one or more unified diff hunks to files in the workspace',
1424
1760
  parameters: {
1425
1761
  type: 'object',
1426
1762
  properties: {
1427
- command: { type: 'string' }
1763
+ patch: { type: 'string' },
1764
+ content: { type: 'string' }
1428
1765
  },
1429
- required: ['command']
1766
+ required: ['patch']
1430
1767
  }
1431
1768
  }
1432
1769
  },
@@ -1512,27 +1849,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1512
1849
  }
1513
1850
  }
1514
1851
  }
1515
- ];
1852
+ ].filter(Boolean);
1516
1853
 
1517
1854
  const handlers = {
1518
- locate: (args) => locate(workspaceRoot, args),
1519
- open_target: (args) => openTarget(workspaceRoot, args),
1520
- edit_target: (args) => editTarget(workspaceRoot, args),
1521
- search_code: (args) => searchCode(workspaceRoot, args),
1522
- read_block: (args) => readBlock(workspaceRoot, args),
1523
- read_symbol_context: (args) => readSymbolContext(workspaceRoot, args),
1524
- validate_edit: (args) => validateEdit(workspaceRoot, args),
1525
- replace_block: (args) => replaceBlock(workspaceRoot, args),
1526
- replace_text: (args) => replaceText(workspaceRoot, args),
1527
- insert_before: (args) => insertRelative(workspaceRoot, args, 'insert_before'),
1528
- insert_after: (args) => insertRelative(workspaceRoot, args, 'insert_after'),
1529
- generate_diff: (args) => generateDiff(workspaceRoot, args),
1530
- start_service: (args) => startService(workspaceRoot, config, args),
1531
- list_services: () => listServices(workspaceRoot),
1532
- get_service_status: (args) => getServiceStatus(workspaceRoot, args),
1533
- get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
1534
- stop_service: (args) => stopService(workspaceRoot, args),
1535
- read_file: (args) =>
1855
+ read: (args) =>
1536
1856
  readFile(workspaceRoot, {
1537
1857
  ...args,
1538
1858
  default_lines: config.context?.read_file_default_lines ?? 220,
@@ -1541,8 +1861,19 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1541
1861
  ? args.max_chars
1542
1862
  : config.context?.read_file_max_chars ?? 24000
1543
1863
  }),
1544
- write_file: (args) => writeFile(workspaceRoot, args),
1545
- run_command: (args) => runCommand(workspaceRoot, config, args)
1864
+ grep: (args) => grep(workspaceRoot, args),
1865
+ glob: (args) => glob(workspaceRoot, args),
1866
+ list: (args) => list(workspaceRoot, args),
1867
+ edit: (args) => editTarget(workspaceRoot, args),
1868
+ generate_diff: (args) => generateDiff(workspaceRoot, args),
1869
+ patch: (args) => applyPatch(workspaceRoot, args),
1870
+ write: (args) => writeFile(workspaceRoot, args),
1871
+ run: (args) => runCommand(workspaceRoot, config, args),
1872
+ start_service: (args) => startService(workspaceRoot, config, args),
1873
+ list_services: () => listServices(workspaceRoot),
1874
+ get_service_status: (args) => getServiceStatus(workspaceRoot, args),
1875
+ get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
1876
+ stop_service: (args) => stopService(workspaceRoot, args)
1546
1877
  };
1547
1878
 
1548
1879
  return { definitions, handlers };