codemini-cli 0.1.18 → 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
@@ -115,6 +115,67 @@ function splitLines(text) {
115
115
  return String(text || '').split('\n');
116
116
  }
117
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
+
118
179
  function detectTextFile(filePath) {
119
180
  return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
120
181
  }
@@ -156,6 +217,63 @@ async function walkTextFiles(root, startPath = '.', fileTypes = []) {
156
217
  return out;
157
218
  }
158
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
+
159
277
  function getLineColumnForMatch(line, query, caseSensitive = false) {
160
278
  const haystack = caseSensitive ? line : line.toLowerCase();
161
279
  const needle = caseSensitive ? query : query.toLowerCase();
@@ -407,6 +525,129 @@ function buildUnifiedDiff(oldContent, newContent, filePath = 'file') {
407
525
  return body.join('\n');
408
526
  }
409
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
+
410
651
  async function getFileState(root, relativePath) {
411
652
  const target = resolveInWorkspace(root, relativePath);
412
653
  const stat = await fs.stat(target);
@@ -452,7 +693,7 @@ async function readFile(root, args) {
452
693
  suggested_start_line: startLine,
453
694
  suggested_end_line: endLine,
454
695
  read_token: readToken,
455
- 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'
456
697
  };
457
698
  }
458
699
 
@@ -492,16 +733,16 @@ async function readFile(root, args) {
492
733
  async function writeFile(root, args) {
493
734
  const rawPath = String(args?.path || '').trim();
494
735
  if (!rawPath) {
495
- 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');
496
737
  }
497
738
  if (rawPath === '.' || rawPath === './') {
498
- 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');
499
740
  }
500
741
  const target = resolveInWorkspace(root, rawPath);
501
742
  try {
502
743
  const stat = await fs.stat(target);
503
744
  if (stat.isDirectory()) {
504
- throw new Error(`write_file target is a directory: ${rawPath}`);
745
+ throw new Error(`write target is a directory: ${rawPath}`);
505
746
  }
506
747
  } catch (error) {
507
748
  if (error?.code && error.code !== 'ENOENT') throw error;
@@ -515,7 +756,7 @@ async function writeFile(root, args) {
515
756
  }
516
757
  if (existed && !args?.append && !args?.full_file_rewrite && isCodeLikePath(rawPath)) {
517
758
  throw new Error(
518
- 'write_file blocks full overwrite for existing code files by default. Use locate -> open_target -> edit_target for minimal edits, or pass full_file_rewrite=true when a full-file rewrite is truly intended.'
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.'
519
760
  );
520
761
  }
521
762
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -549,10 +790,10 @@ async function writeFile(root, args) {
549
790
  async function runCommand(root, config, args) {
550
791
  const command = args?.command || '';
551
792
  if (!command.trim()) {
552
- throw new Error('run_command requires command');
793
+ throw new Error('run requires command');
553
794
  }
554
795
  if (isLikelyLongRunningCommand(command)) {
555
- 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.');
556
797
  }
557
798
  if (
558
799
  !config.policy.allow_dangerous_commands &&
@@ -933,6 +1174,81 @@ async function searchCode(root, args) {
933
1174
  };
934
1175
  }
935
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
+
936
1252
  async function readBlock(root, args) {
937
1253
  const relativePath = String(args?.path || '').trim();
938
1254
  if (!relativePath) throw new Error('read_block requires path');
@@ -985,17 +1301,25 @@ async function validateEdit(root, args) {
985
1301
  if (!Number.isFinite(startLine) || !Number.isFinite(endLine) || startLine <= 0 || endLine < startLine) {
986
1302
  throw new Error('replace_block validation requires target.start_line and target.end_line');
987
1303
  }
988
- 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');
989
1311
  return {
990
1312
  ok: true,
991
1313
  path: relativePath,
992
1314
  kind,
993
1315
  target: {
994
- start_line: startLine,
995
- end_line: endLine,
996
- 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
997
1320
  },
998
- file_hash: sha256(content)
1321
+ file_hash: sha256(content),
1322
+ relocated: Boolean(resolved?.relocated)
999
1323
  };
1000
1324
  }
1001
1325
 
@@ -1035,18 +1359,19 @@ async function replaceBlock(root, args) {
1035
1359
  const relativePath = String(args?.path || '').trim();
1036
1360
  const newContent = String(args?.new_content || args?.content || '');
1037
1361
  const target = args?.target || {};
1038
- const startLine = Number(target.start_line);
1039
- const endLine = Number(target.end_line);
1040
- const oldHash = String(target.old_hash || '');
1041
1362
  const state = await getFileState(root, relativePath);
1042
- const oldBlock = state.lines.slice(startLine - 1, endLine).join('\n');
1043
- if (!oldHash || oldHash !== sha256(oldBlock)) {
1044
- 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');
1045
1366
  }
1046
- 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
+ ];
1047
1372
  const afterContent = nextLines.join('\n');
1048
1373
  await fs.writeFile(state.target, afterContent, 'utf8');
1049
- return editResult(relativePath, 'replace_block', state.content, afterContent, startLine);
1374
+ return editResult(relativePath, 'replace_block', state.content, afterContent, resolved.start_line);
1050
1375
  }
1051
1376
 
1052
1377
  async function replaceText(root, args) {
@@ -1056,7 +1381,11 @@ async function replaceText(root, args) {
1056
1381
  const state = await getFileState(root, relativePath);
1057
1382
  const occurrences = state.content.split(oldText).length - 1;
1058
1383
  if (occurrences !== 1) {
1059
- 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
+ );
1060
1389
  }
1061
1390
  const afterContent = state.content.replace(oldText, newText);
1062
1391
  await fs.writeFile(state.target, afterContent, 'utf8');
@@ -1093,16 +1422,55 @@ async function generateDiff(root, args) {
1093
1422
  };
1094
1423
  }
1095
1424
 
1096
- async function locate(root, args) {
1097
- const result = await searchCode(root, args);
1098
- return {
1099
- query: result.query,
1100
- matches: result.matches,
1101
- definitions: result.definitions,
1102
- references: result.references,
1103
- text_matches: result.text_matches,
1104
- truncated: result.truncated
1105
- };
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 };
1106
1474
  }
1107
1475
 
1108
1476
  async function openTarget(root, args) {
@@ -1125,10 +1493,11 @@ async function openTarget(root, args) {
1125
1493
  symbol: symbol || undefined,
1126
1494
  main_block: block,
1127
1495
  related: mainBlock.related || { imports: [], local_symbols: [] },
1128
- edit_target: {
1496
+ edit: {
1129
1497
  start_line: block.start_line,
1130
1498
  end_line: block.end_line,
1131
- old_hash: sha256(block.content)
1499
+ old_hash: sha256(block.content),
1500
+ old_content: block.content
1132
1501
  }
1133
1502
  };
1134
1503
  }
@@ -1137,9 +1506,16 @@ function normalizeEditTargetArgs(args = {}) {
1137
1506
  const file = String(args?.file || args?.path || '').trim();
1138
1507
  const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1139
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
+ }
1140
1516
  return {
1141
1517
  file,
1142
- edit: nestedEdit
1518
+ edit: normalizedEdit
1143
1519
  };
1144
1520
  }
1145
1521
  return {
@@ -1160,13 +1536,35 @@ async function editTarget(root, args) {
1160
1536
  const normalized = normalizeEditTargetArgs(args);
1161
1537
  const file = normalized.file;
1162
1538
  const edit = normalized.edit || {};
1163
- const kind = String(edit.kind || '').trim();
1164
- 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');
1165
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;
1166
1564
  try {
1167
1565
  return await replaceBlock(root, {
1168
1566
  path: file,
1169
- target: edit.target,
1567
+ target: resolvedTarget,
1170
1568
  new_content: edit.new_content
1171
1569
  });
1172
1570
  } catch (error) {
@@ -1174,7 +1572,7 @@ async function editTarget(root, args) {
1174
1572
  const validation = await validateEdit(root, {
1175
1573
  path: file,
1176
1574
  kind: 'replace_block',
1177
- target: edit.target
1575
+ target: resolvedTarget
1178
1576
  });
1179
1577
  return replaceBlock(root, {
1180
1578
  path: file,
@@ -1196,7 +1594,14 @@ async function editTarget(root, args) {
1196
1594
  if (kind === 'insert_after') {
1197
1595
  return insertRelative(root, { path: file, anchor_text: edit.anchor_text, content: edit.content }, 'insert_after');
1198
1596
  }
1199
- 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}`);
1200
1605
  }
1201
1606
 
1202
1607
  export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
@@ -1204,192 +1609,131 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1204
1609
  {
1205
1610
  type: 'function',
1206
1611
  function: {
1207
- name: 'locate',
1208
- description: 'High-level search that returns compact candidate code locations',
1209
- parameters: {
1210
- type: 'object',
1211
- properties: {
1212
- query: { type: 'string' },
1213
- path: { type: 'string' },
1214
- max_results: { type: 'number' },
1215
- language: { type: 'string' },
1216
- file_types: { type: 'array', items: { type: 'string' } }
1217
- },
1218
- required: ['query']
1219
- }
1220
- }
1221
- },
1222
- {
1223
- type: 'function',
1224
- function: {
1225
- name: 'open_target',
1226
- description: 'Open a candidate location and return the smallest useful code block plus edit metadata',
1227
- parameters: {
1228
- type: 'object',
1229
- properties: {
1230
- file: { type: 'string' },
1231
- path: { type: 'string' },
1232
- line: { type: 'number' },
1233
- symbol: { type: 'string' },
1234
- max_related_calls: { type: 'number' },
1235
- max_related_imports: { type: 'number' },
1236
- max_related_types: { type: 'number' }
1237
- },
1238
- required: ['file']
1239
- }
1240
- }
1241
- },
1242
- {
1243
- type: 'function',
1244
- function: {
1245
- name: 'edit_target',
1246
- 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',
1247
1615
  parameters: {
1248
1616
  type: 'object',
1249
1617
  properties: {
1250
- file: { type: 'string' },
1251
1618
  path: { type: 'string' },
1252
- 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' }
1253
1624
  },
1254
- required: ['file', 'edit']
1625
+ required: ['path']
1255
1626
  }
1256
1627
  }
1257
1628
  },
1258
1629
  {
1259
1630
  type: 'function',
1260
1631
  function: {
1261
- name: 'search_code',
1262
- 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',
1263
1634
  parameters: {
1264
1635
  type: 'object',
1265
1636
  properties: {
1637
+ pattern: { type: 'string' },
1266
1638
  query: { type: 'string' },
1267
1639
  path: { type: 'string' },
1268
- max_results: { type: 'number' },
1640
+ regex: { type: 'boolean' },
1269
1641
  case_sensitive: { type: 'boolean' },
1642
+ max_results: { type: 'number' },
1270
1643
  language: { type: 'string' },
1271
1644
  file_types: { type: 'array', items: { type: 'string' } }
1272
1645
  },
1273
- required: ['query']
1646
+ required: ['pattern']
1274
1647
  }
1275
1648
  }
1276
1649
  },
1277
1650
  {
1278
1651
  type: 'function',
1279
1652
  function: {
1280
- name: 'read_block',
1281
- 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',
1282
1655
  parameters: {
1283
1656
  type: 'object',
1284
1657
  properties: {
1658
+ pattern: { type: 'string' },
1285
1659
  path: { type: 'string' },
1286
- symbol: { type: 'string' },
1287
- line: { type: 'number' },
1288
- anchor_line: { type: 'number' }
1660
+ include_hidden: { type: 'boolean' },
1661
+ max_results: { type: 'number' }
1289
1662
  },
1290
- required: ['path']
1663
+ required: ['pattern']
1291
1664
  }
1292
1665
  }
1293
1666
  },
1294
1667
  {
1295
1668
  type: 'function',
1296
1669
  function: {
1297
- name: 'read_symbol_context',
1298
- description: 'Read a symbol block plus import and local symbol summaries',
1670
+ name: 'list',
1671
+ description: 'List files and directories in a workspace path',
1299
1672
  parameters: {
1300
1673
  type: 'object',
1301
1674
  properties: {
1302
1675
  path: { type: 'string' },
1303
- symbol: { type: 'string' },
1304
- max_related_calls: { type: 'number' },
1305
- max_related_imports: { type: 'number' },
1306
- max_related_types: { type: 'number' }
1307
- },
1308
- required: ['path', 'symbol']
1676
+ include_hidden: { type: 'boolean' }
1677
+ }
1309
1678
  }
1310
1679
  }
1311
1680
  },
1312
1681
  {
1313
1682
  type: 'function',
1314
1683
  function: {
1315
- name: 'validate_edit',
1316
- 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.',
1317
1687
  parameters: {
1318
1688
  type: 'object',
1319
1689
  properties: {
1690
+ file: { type: 'string' },
1320
1691
  path: { type: 'string' },
1321
- kind: { type: 'string' },
1322
- target: { type: 'object' },
1323
- start_line: { type: 'number' },
1324
- end_line: { type: 'number' },
1692
+ new_content: { type: 'string' },
1325
1693
  old_text: { type: 'string' },
1326
- anchor_text: { type: 'string' }
1327
- },
1328
- required: ['path', 'kind']
1329
- }
1330
- }
1331
- },
1332
- {
1333
- type: 'function',
1334
- function: {
1335
- name: 'replace_block',
1336
- description: 'Replace a validated line block using an old_hash guard',
1337
- parameters: {
1338
- type: 'object',
1339
- properties: {
1340
- 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' },
1341
1699
  target: { type: 'object' },
1342
- new_content: { type: 'string' }
1343
- },
1344
- required: ['path', 'target', 'new_content']
1345
- }
1346
- }
1347
- },
1348
- {
1349
- type: 'function',
1350
- function: {
1351
- name: 'replace_text',
1352
- description: 'Replace a unique text fragment in a file',
1353
- parameters: {
1354
- type: 'object',
1355
- properties: {
1356
- path: { type: 'string' },
1357
- old_text: { type: 'string' },
1358
- new_text: { type: 'string' }
1700
+ symbol: { type: 'string' },
1701
+ line: { type: 'number' },
1702
+ edit: { type: 'object' },
1359
1703
  },
1360
- required: ['path', 'old_text', 'new_text']
1704
+ required: ['file']
1361
1705
  }
1362
1706
  }
1363
1707
  },
1364
1708
  {
1365
1709
  type: 'function',
1366
1710
  function: {
1367
- name: 'insert_before',
1368
- 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.',
1369
1714
  parameters: {
1370
1715
  type: 'object',
1371
1716
  properties: {
1372
1717
  path: { type: 'string' },
1373
- anchor_text: { type: 'string' },
1374
- content: { type: 'string' }
1718
+ content: { type: 'string' },
1719
+ append: { type: 'boolean' },
1720
+ full_file_rewrite: { type: 'boolean' }
1375
1721
  },
1376
- required: ['path', 'anchor_text', 'content']
1722
+ required: ['path', 'content']
1377
1723
  }
1378
1724
  }
1379
1725
  },
1380
1726
  {
1381
1727
  type: 'function',
1382
1728
  function: {
1383
- name: 'insert_after',
1384
- 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.',
1385
1731
  parameters: {
1386
1732
  type: 'object',
1387
1733
  properties: {
1388
- path: { type: 'string' },
1389
- anchor_text: { type: 'string' },
1390
- content: { type: 'string' }
1734
+ command: { type: 'string' }
1391
1735
  },
1392
- required: ['path', 'anchor_text', 'content']
1736
+ required: ['command']
1393
1737
  }
1394
1738
  }
1395
1739
  },
@@ -1411,52 +1755,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1411
1755
  {
1412
1756
  type: 'function',
1413
1757
  function: {
1414
- name: 'read_file',
1415
- description:
1416
- 'Two-phase read: first call returns metadata+read_token; second call with include_content=true and matching read_token returns content',
1417
- parameters: {
1418
- type: 'object',
1419
- properties: {
1420
- path: { type: 'string' },
1421
- start_line: { type: 'number' },
1422
- end_line: { type: 'number' },
1423
- max_chars: { type: 'number' },
1424
- include_content: { type: 'boolean' },
1425
- read_token: { type: 'string' }
1426
- },
1427
- required: ['path']
1428
- }
1429
- }
1430
- },
1431
- {
1432
- type: 'function',
1433
- function: {
1434
- name: 'write_file',
1435
- description:
1436
- 'Write a UTF-8 text file in workspace. Always provide a full file path, not a directory. Existing code files require full_file_rewrite=true for whole-file overwrites.',
1437
- parameters: {
1438
- type: 'object',
1439
- properties: {
1440
- path: { type: 'string' },
1441
- content: { type: 'string' },
1442
- append: { type: 'boolean' },
1443
- full_file_rewrite: { type: 'boolean' }
1444
- },
1445
- required: ['path', 'content']
1446
- }
1447
- }
1448
- },
1449
- {
1450
- type: 'function',
1451
- function: {
1452
- name: 'run_command',
1453
- 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',
1454
1760
  parameters: {
1455
1761
  type: 'object',
1456
1762
  properties: {
1457
- command: { type: 'string' }
1763
+ patch: { type: 'string' },
1764
+ content: { type: 'string' }
1458
1765
  },
1459
- required: ['command']
1766
+ required: ['patch']
1460
1767
  }
1461
1768
  }
1462
1769
  },
@@ -1542,27 +1849,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1542
1849
  }
1543
1850
  }
1544
1851
  }
1545
- ];
1852
+ ].filter(Boolean);
1546
1853
 
1547
1854
  const handlers = {
1548
- locate: (args) => locate(workspaceRoot, args),
1549
- open_target: (args) => openTarget(workspaceRoot, args),
1550
- edit_target: (args) => editTarget(workspaceRoot, args),
1551
- search_code: (args) => searchCode(workspaceRoot, args),
1552
- read_block: (args) => readBlock(workspaceRoot, args),
1553
- read_symbol_context: (args) => readSymbolContext(workspaceRoot, args),
1554
- validate_edit: (args) => validateEdit(workspaceRoot, args),
1555
- replace_block: (args) => replaceBlock(workspaceRoot, args),
1556
- replace_text: (args) => replaceText(workspaceRoot, args),
1557
- insert_before: (args) => insertRelative(workspaceRoot, args, 'insert_before'),
1558
- insert_after: (args) => insertRelative(workspaceRoot, args, 'insert_after'),
1559
- generate_diff: (args) => generateDiff(workspaceRoot, args),
1560
- start_service: (args) => startService(workspaceRoot, config, args),
1561
- list_services: () => listServices(workspaceRoot),
1562
- get_service_status: (args) => getServiceStatus(workspaceRoot, args),
1563
- get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
1564
- stop_service: (args) => stopService(workspaceRoot, args),
1565
- read_file: (args) =>
1855
+ read: (args) =>
1566
1856
  readFile(workspaceRoot, {
1567
1857
  ...args,
1568
1858
  default_lines: config.context?.read_file_default_lines ?? 220,
@@ -1571,8 +1861,19 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1571
1861
  ? args.max_chars
1572
1862
  : config.context?.read_file_max_chars ?? 24000
1573
1863
  }),
1574
- write_file: (args) => writeFile(workspaceRoot, args),
1575
- 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)
1576
1877
  };
1577
1878
 
1578
1879
  return { definitions, handlers };