codemini-cli 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/tools.js CHANGED
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import { spawn } from 'node:child_process';
5
5
  import net from 'node:net';
6
6
  import {
7
+ classifyCommandIntent,
7
8
  hasReadyOutput,
8
9
  isDangerousCommand,
9
10
  isLikelyLongRunningCommand,
@@ -115,6 +116,67 @@ function splitLines(text) {
115
116
  return String(text || '').split('\n');
116
117
  }
117
118
 
119
+ function findUniqueLineBlock(lines, blockContent) {
120
+ const probeLines = splitLines(blockContent);
121
+ if (probeLines.length === 0 || (probeLines.length === 1 && probeLines[0] === '')) return null;
122
+ const matches = [];
123
+ const lastStart = lines.length - probeLines.length;
124
+ for (let start = 0; start <= lastStart; start += 1) {
125
+ let ok = true;
126
+ for (let offset = 0; offset < probeLines.length; offset += 1) {
127
+ if (lines[start + offset] !== probeLines[offset]) {
128
+ ok = false;
129
+ break;
130
+ }
131
+ }
132
+ if (ok) {
133
+ matches.push({
134
+ start_line: start + 1,
135
+ end_line: start + probeLines.length,
136
+ content: probeLines.join('\n')
137
+ });
138
+ if (matches.length > 1) break;
139
+ }
140
+ }
141
+ return matches.length === 1 ? matches[0] : null;
142
+ }
143
+
144
+ function resolveReplaceBlockTarget(state, target) {
145
+ const startLine = Number(target?.start_line);
146
+ const endLine = Number(target?.end_line);
147
+ const oldHash = String(target?.old_hash || '');
148
+ const currentBlock =
149
+ Number.isFinite(startLine) && Number.isFinite(endLine) && startLine > 0 && endLine >= startLine
150
+ ? state.lines.slice(startLine - 1, endLine).join('\n')
151
+ : '';
152
+
153
+ if (oldHash && currentBlock && oldHash === sha256(currentBlock)) {
154
+ return {
155
+ start_line: startLine,
156
+ end_line: endLine,
157
+ old_hash: oldHash,
158
+ old_content: currentBlock,
159
+ relocated: false
160
+ };
161
+ }
162
+
163
+ const oldContent = String(target?.old_content || '');
164
+ if (oldContent) {
165
+ const relocated = findUniqueLineBlock(state.lines, oldContent);
166
+ if (relocated) {
167
+ return {
168
+ start_line: relocated.start_line,
169
+ end_line: relocated.end_line,
170
+ old_hash: sha256(relocated.content),
171
+ old_content: relocated.content,
172
+ relocated: true
173
+ };
174
+ }
175
+ }
176
+
177
+ return null;
178
+ }
179
+
118
180
  function detectTextFile(filePath) {
119
181
  return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
120
182
  }
@@ -156,6 +218,63 @@ async function walkTextFiles(root, startPath = '.', fileTypes = []) {
156
218
  return out;
157
219
  }
158
220
 
221
+ async function walkWorkspaceEntries(root, startPath = '.', { includeHidden = false } = {}) {
222
+ const abs = resolveInWorkspace(root, startPath);
223
+ const out = [];
224
+
225
+ async function visit(current) {
226
+ const stat = await fs.stat(current);
227
+ const relative = toWorkspaceRelative(root, current) || '.';
228
+ const name = path.basename(current);
229
+
230
+ if (!includeHidden && name.startsWith('.') && relative !== '.') return;
231
+ if (stat.isDirectory()) {
232
+ if (SKIP_DIRS.has(name) && relative !== '.') return;
233
+ out.push({ path: relative, name, type: 'dir' });
234
+ const entries = await fs.readdir(current);
235
+ for (const entry of entries) {
236
+ await visit(path.join(current, entry));
237
+ }
238
+ return;
239
+ }
240
+
241
+ out.push({ path: relative, name, type: 'file' });
242
+ }
243
+
244
+ await visit(abs);
245
+ return out;
246
+ }
247
+
248
+ function globToRegex(pattern) {
249
+ const normalized = String(pattern || '').replace(/\\/g, '/').trim();
250
+ let regexBody = '';
251
+ for (let i = 0; i < normalized.length; i += 1) {
252
+ const ch = normalized[i];
253
+ const next = normalized[i + 1];
254
+ const afterNext = normalized[i + 2];
255
+ if (ch === '*' && next === '*' && afterNext === '/') {
256
+ regexBody += '(?:.*/)?';
257
+ i += 2;
258
+ continue;
259
+ }
260
+ if (ch === '*' && next === '*') {
261
+ regexBody += '.*';
262
+ i += 1;
263
+ continue;
264
+ }
265
+ if (ch === '*') {
266
+ regexBody += '[^/]*';
267
+ continue;
268
+ }
269
+ if (ch === '?') {
270
+ regexBody += '[^/]';
271
+ continue;
272
+ }
273
+ regexBody += /[-/\\^$+?.()|[\]{}]/.test(ch) ? `\\${ch}` : ch;
274
+ }
275
+ return new RegExp(`^${regexBody}$`);
276
+ }
277
+
159
278
  function getLineColumnForMatch(line, query, caseSensitive = false) {
160
279
  const haystack = caseSensitive ? line : line.toLowerCase();
161
280
  const needle = caseSensitive ? query : query.toLowerCase();
@@ -407,6 +526,129 @@ function buildUnifiedDiff(oldContent, newContent, filePath = 'file') {
407
526
  return body.join('\n');
408
527
  }
409
528
 
529
+ function parseUnifiedPatch(patchText) {
530
+ const lines = splitLines(String(patchText || ''));
531
+ const files = [];
532
+ let current = null;
533
+
534
+ const pushCurrent = () => {
535
+ if (current) files.push(current);
536
+ };
537
+
538
+ for (let i = 0; i < lines.length; i += 1) {
539
+ const line = lines[i];
540
+ if (line.startsWith('--- ')) {
541
+ pushCurrent();
542
+ current = {
543
+ oldPath: line.slice(4).trim(),
544
+ newPath: '',
545
+ hunks: []
546
+ };
547
+ continue;
548
+ }
549
+ if (!current) continue;
550
+ if (line.startsWith('+++ ')) {
551
+ current.newPath = line.slice(4).trim();
552
+ continue;
553
+ }
554
+ if (line.startsWith('@@ ')) {
555
+ const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
556
+ if (!match) {
557
+ throw new Error(`invalid patch hunk header: ${line}`);
558
+ }
559
+ const hunk = {
560
+ oldStart: Number(match[1]),
561
+ oldCount: Number(match[2] || '1'),
562
+ newStart: Number(match[3]),
563
+ newCount: Number(match[4] || '1'),
564
+ lines: []
565
+ };
566
+ i += 1;
567
+ while (i < lines.length) {
568
+ const hunkLine = lines[i];
569
+ if (hunkLine.startsWith('@@ ') || hunkLine.startsWith('--- ')) {
570
+ i -= 1;
571
+ break;
572
+ }
573
+ if (hunkLine.startsWith('\')) {
574
+ i += 1;
575
+ continue;
576
+ }
577
+ if (hunkLine === '') {
578
+ hunk.lines.push(' ');
579
+ i += 1;
580
+ continue;
581
+ }
582
+ if (!/^[ +\-]/.test(hunkLine)) {
583
+ hunk.lines.push(` ${hunkLine}`);
584
+ i += 1;
585
+ continue;
586
+ }
587
+ if (!/^[ +\-]/.test(hunkLine)) {
588
+ throw new Error(`invalid patch line: ${hunkLine}`);
589
+ }
590
+ hunk.lines.push(hunkLine);
591
+ i += 1;
592
+ }
593
+ current.hunks.push(hunk);
594
+ }
595
+ }
596
+
597
+ pushCurrent();
598
+ return files.filter((file) => file.oldPath || file.newPath);
599
+ }
600
+
601
+ function applyHunkToLines(lines, hunk) {
602
+ const oldChunk = [];
603
+ const newChunk = [];
604
+ for (const line of hunk.lines) {
605
+ if (line.startsWith(' ')) {
606
+ const text = line.slice(1);
607
+ oldChunk.push(text);
608
+ newChunk.push(text);
609
+ continue;
610
+ }
611
+ if (line.startsWith('-')) {
612
+ oldChunk.push(line.slice(1));
613
+ continue;
614
+ }
615
+ if (line.startsWith('+')) {
616
+ newChunk.push(line.slice(1));
617
+ }
618
+ }
619
+
620
+ if (oldChunk.length === 0) {
621
+ const insertAt = Math.max(0, Number(hunk.oldStart || 1) - 1);
622
+ return [...lines.slice(0, insertAt), ...newChunk, ...lines.slice(insertAt)];
623
+ }
624
+
625
+ const lastStart = Math.max(0, lines.length - oldChunk.length);
626
+ const matches = [];
627
+ for (let start = 0; start <= lastStart; start += 1) {
628
+ let ok = true;
629
+ for (let offset = 0; offset < oldChunk.length; offset += 1) {
630
+ if (lines[start + offset] !== oldChunk[offset]) {
631
+ ok = false;
632
+ break;
633
+ }
634
+ }
635
+ if (ok) {
636
+ matches.push(start);
637
+ if (matches.length > 1) break;
638
+ }
639
+ }
640
+
641
+ if (matches.length === 0) {
642
+ throw new Error('patch hunk context not found');
643
+ }
644
+ if (matches.length > 1) {
645
+ throw new Error('patch hunk context not unique');
646
+ }
647
+
648
+ const start = matches[0];
649
+ return [...lines.slice(0, start), ...newChunk, ...lines.slice(start + oldChunk.length)];
650
+ }
651
+
410
652
  async function getFileState(root, relativePath) {
411
653
  const target = resolveInWorkspace(root, relativePath);
412
654
  const stat = await fs.stat(target);
@@ -452,7 +694,7 @@ async function readFile(root, args) {
452
694
  suggested_start_line: startLine,
453
695
  suggested_end_line: endLine,
454
696
  read_token: readToken,
455
- next: 'Call read_file again with include_content=true and this read_token'
697
+ next: 'Call read again with include_content=true and this read_token'
456
698
  };
457
699
  }
458
700
 
@@ -492,16 +734,16 @@ async function readFile(root, args) {
492
734
  async function writeFile(root, args) {
493
735
  const rawPath = String(args?.path || '').trim();
494
736
  if (!rawPath) {
495
- throw new Error('write_file requires a file path like weather/WeatherForecast.js');
737
+ throw new Error('write requires a file path like weather/WeatherForecast.js');
496
738
  }
497
739
  if (rawPath === '.' || rawPath === './') {
498
- throw new Error('write_file requires a file path, not the workspace root');
740
+ throw new Error('write requires a file path, not the workspace root');
499
741
  }
500
742
  const target = resolveInWorkspace(root, rawPath);
501
743
  try {
502
744
  const stat = await fs.stat(target);
503
745
  if (stat.isDirectory()) {
504
- throw new Error(`write_file target is a directory: ${rawPath}`);
746
+ throw new Error(`write target is a directory: ${rawPath}`);
505
747
  }
506
748
  } catch (error) {
507
749
  if (error?.code && error.code !== 'ENOENT') throw error;
@@ -515,7 +757,7 @@ async function writeFile(root, args) {
515
757
  }
516
758
  if (existed && !args?.append && !args?.full_file_rewrite && isCodeLikePath(rawPath)) {
517
759
  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.'
760
+ '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
761
  );
520
762
  }
521
763
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -549,10 +791,19 @@ async function writeFile(root, args) {
549
791
  async function runCommand(root, config, args) {
550
792
  const command = args?.command || '';
551
793
  if (!command.trim()) {
552
- throw new Error('run_command requires command');
794
+ throw new Error('run requires command');
553
795
  }
554
796
  if (isLikelyLongRunningCommand(command)) {
555
- throw new Error('Command looks like a long-running service. Use start_service instead of run_command.');
797
+ const intent = classifyCommandIntent(command);
798
+ const labelMap = {
799
+ 'frontend-service': 'frontend service',
800
+ 'backend-service': 'backend service',
801
+ 'database-service': 'database service',
802
+ 'docker-service': 'Docker service',
803
+ service: 'long-running service'
804
+ };
805
+ const label = labelMap[intent.kind] || 'long-running service';
806
+ throw new Error(`Command looks like a ${label}. Use start_service instead of run.`);
556
807
  }
557
808
  if (
558
809
  !config.policy.allow_dangerous_commands &&
@@ -933,6 +1184,81 @@ async function searchCode(root, args) {
933
1184
  };
934
1185
  }
935
1186
 
1187
+ async function grep(root, args) {
1188
+ const pattern = String(args?.pattern || args?.query || '').trim();
1189
+ if (!pattern) throw new Error('grep requires pattern');
1190
+ const maxResults = Math.max(1, Math.min(200, Number(args?.max_results || 50)));
1191
+ const caseSensitive = Boolean(args?.case_sensitive);
1192
+ const files = await walkTextFiles(root, args?.path || '.', normalizeFileTypes(args));
1193
+ const regex = args?.regex
1194
+ ? new RegExp(pattern, caseSensitive ? 'g' : 'gi')
1195
+ : new RegExp(escapeRegex(pattern), caseSensitive ? 'g' : 'gi');
1196
+ const matches = [];
1197
+
1198
+ for (const filePath of files) {
1199
+ const content = await fs.readFile(filePath, 'utf8');
1200
+ const lines = splitLines(content);
1201
+ for (let idx = 0; idx < lines.length; idx += 1) {
1202
+ const line = String(lines[idx] || '');
1203
+ regex.lastIndex = 0;
1204
+ const found = regex.exec(line);
1205
+ if (!found) continue;
1206
+ matches.push({
1207
+ path: toWorkspaceRelative(root, filePath),
1208
+ line: idx + 1,
1209
+ column: Math.max(1, Number(found.index || 0) + 1),
1210
+ preview: trimLinePreview(line)
1211
+ });
1212
+ if (matches.length >= maxResults) {
1213
+ return { pattern, matches, truncated: true };
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ return { pattern, matches, truncated: false };
1219
+ }
1220
+
1221
+ async function glob(root, args) {
1222
+ const pattern = String(args?.pattern || '').trim();
1223
+ if (!pattern) throw new Error('glob requires pattern');
1224
+ const maxResults = Math.max(1, Math.min(500, Number(args?.max_results || 200)));
1225
+ const regex = globToRegex(pattern);
1226
+ const entries = await walkWorkspaceEntries(root, args?.path || '.', {
1227
+ includeHidden: Boolean(args?.include_hidden)
1228
+ });
1229
+ const matches = entries
1230
+ .filter((entry) => entry.type === 'file' && regex.test(entry.path))
1231
+ .slice(0, maxResults)
1232
+ .map((entry) => entry.path);
1233
+ return {
1234
+ pattern,
1235
+ matches,
1236
+ truncated: entries.filter((entry) => entry.type === 'file' && regex.test(entry.path)).length > matches.length
1237
+ };
1238
+ }
1239
+
1240
+ async function list(root, args) {
1241
+ const relativePath = String(args?.path || '.').trim() || '.';
1242
+ const target = resolveInWorkspace(root, relativePath);
1243
+ const entries = await fs.readdir(target, { withFileTypes: true });
1244
+ const includeHidden = Boolean(args?.include_hidden);
1245
+ const items = entries
1246
+ .filter((entry) => includeHidden || !entry.name.startsWith('.'))
1247
+ .map((entry) => ({
1248
+ name: entry.name,
1249
+ path: path.posix.join(relativePath === '.' ? '' : relativePath.replace(/\\/g, '/'), entry.name) || entry.name,
1250
+ type: entry.isDirectory() ? 'dir' : 'file'
1251
+ }))
1252
+ .sort((left, right) => {
1253
+ if (left.type !== right.type) return left.type === 'dir' ? -1 : 1;
1254
+ return left.path.localeCompare(right.path);
1255
+ });
1256
+ return {
1257
+ path: relativePath,
1258
+ items
1259
+ };
1260
+ }
1261
+
936
1262
  async function readBlock(root, args) {
937
1263
  const relativePath = String(args?.path || '').trim();
938
1264
  if (!relativePath) throw new Error('read_block requires path');
@@ -985,17 +1311,25 @@ async function validateEdit(root, args) {
985
1311
  if (!Number.isFinite(startLine) || !Number.isFinite(endLine) || startLine <= 0 || endLine < startLine) {
986
1312
  throw new Error('replace_block validation requires target.start_line and target.end_line');
987
1313
  }
988
- const oldBlock = lines.slice(startLine - 1, endLine).join('\n');
1314
+ const resolved = resolveReplaceBlockTarget({ content, lines }, {
1315
+ start_line: startLine,
1316
+ end_line: endLine,
1317
+ old_hash: args?.target?.old_hash,
1318
+ old_content: args?.target?.old_content
1319
+ });
1320
+ const oldBlock = resolved?.old_content || lines.slice(startLine - 1, endLine).join('\n');
989
1321
  return {
990
1322
  ok: true,
991
1323
  path: relativePath,
992
1324
  kind,
993
1325
  target: {
994
- start_line: startLine,
995
- end_line: endLine,
996
- old_hash: sha256(oldBlock)
1326
+ start_line: resolved?.start_line || startLine,
1327
+ end_line: resolved?.end_line || endLine,
1328
+ old_hash: sha256(oldBlock),
1329
+ old_content: oldBlock
997
1330
  },
998
- file_hash: sha256(content)
1331
+ file_hash: sha256(content),
1332
+ relocated: Boolean(resolved?.relocated)
999
1333
  };
1000
1334
  }
1001
1335
 
@@ -1035,18 +1369,19 @@ async function replaceBlock(root, args) {
1035
1369
  const relativePath = String(args?.path || '').trim();
1036
1370
  const newContent = String(args?.new_content || args?.content || '');
1037
1371
  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
1372
  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');
1373
+ const resolved = resolveReplaceBlockTarget(state, target);
1374
+ if (!resolved) {
1375
+ throw new Error('replace_block old_hash mismatch; retry through edit with a symbol or line hint');
1045
1376
  }
1046
- const nextLines = [...state.lines.slice(0, startLine - 1), ...splitLines(newContent), ...state.lines.slice(endLine)];
1377
+ const nextLines = [
1378
+ ...state.lines.slice(0, resolved.start_line - 1),
1379
+ ...splitLines(newContent),
1380
+ ...state.lines.slice(resolved.end_line)
1381
+ ];
1047
1382
  const afterContent = nextLines.join('\n');
1048
1383
  await fs.writeFile(state.target, afterContent, 'utf8');
1049
- return editResult(relativePath, 'replace_block', state.content, afterContent, startLine);
1384
+ return editResult(relativePath, 'replace_block', state.content, afterContent, resolved.start_line);
1050
1385
  }
1051
1386
 
1052
1387
  async function replaceText(root, args) {
@@ -1056,7 +1391,11 @@ async function replaceText(root, args) {
1056
1391
  const state = await getFileState(root, relativePath);
1057
1392
  const occurrences = state.content.split(oldText).length - 1;
1058
1393
  if (occurrences !== 1) {
1059
- throw new Error(occurrences === 0 ? 'replace_text old_text not found' : 'replace_text old_text not unique');
1394
+ throw new Error(
1395
+ occurrences === 0
1396
+ ? 'replace_text old_text not found; use edit with a symbol or line hint for block edits'
1397
+ : 'replace_text old_text not unique; use a larger unique fragment or retry through edit'
1398
+ );
1060
1399
  }
1061
1400
  const afterContent = state.content.replace(oldText, newText);
1062
1401
  await fs.writeFile(state.target, afterContent, 'utf8');
@@ -1093,16 +1432,55 @@ async function generateDiff(root, args) {
1093
1432
  };
1094
1433
  }
1095
1434
 
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
- };
1435
+ async function applyPatch(root, args) {
1436
+ const patchText = String(args?.patch || args?.content || '').trim();
1437
+ if (!patchText) throw new Error('patch requires patch content');
1438
+ const files = parseUnifiedPatch(patchText);
1439
+ if (files.length === 0) throw new Error('patch contains no file changes');
1440
+
1441
+ const results = [];
1442
+ for (const fileChange of files) {
1443
+ const newPath = String(fileChange.newPath || '').trim();
1444
+ const oldPath = String(fileChange.oldPath || '').trim();
1445
+ const targetPath = newPath && newPath !== '/dev/null' ? newPath : oldPath;
1446
+ if (!targetPath || targetPath === '/dev/null') {
1447
+ throw new Error('patch requires a target file path');
1448
+ }
1449
+ const absTarget = resolveInWorkspace(root, targetPath);
1450
+ let beforeContent = '';
1451
+ let beforeLines = [];
1452
+ try {
1453
+ beforeContent = await fs.readFile(absTarget, 'utf8');
1454
+ beforeLines = splitLines(beforeContent);
1455
+ } catch (error) {
1456
+ if (!(error && error.code === 'ENOENT')) throw error;
1457
+ }
1458
+
1459
+ let nextLines = beforeLines;
1460
+ for (const hunk of fileChange.hunks) {
1461
+ nextLines = applyHunkToLines(nextLines, hunk);
1462
+ }
1463
+ const afterContent = nextLines.join('\n');
1464
+
1465
+ if (newPath === '/dev/null') {
1466
+ await fs.rm(absTarget, { force: true });
1467
+ results.push({
1468
+ path: targetPath,
1469
+ action: 'delete',
1470
+ changed_line: 1,
1471
+ diff_preview: `deleted ${targetPath}`,
1472
+ diff: buildUnifiedDiff(beforeContent, '', targetPath),
1473
+ new_hash: sha256('')
1474
+ });
1475
+ continue;
1476
+ }
1477
+
1478
+ await fs.mkdir(path.dirname(absTarget), { recursive: true });
1479
+ await fs.writeFile(absTarget, afterContent, 'utf8');
1480
+ results.push(editResult(targetPath, beforeContent ? 'patch' : 'create', beforeContent, afterContent, 1));
1481
+ }
1482
+
1483
+ return results.length === 1 ? results[0] : { ok: true, files: results };
1106
1484
  }
1107
1485
 
1108
1486
  async function openTarget(root, args) {
@@ -1125,10 +1503,11 @@ async function openTarget(root, args) {
1125
1503
  symbol: symbol || undefined,
1126
1504
  main_block: block,
1127
1505
  related: mainBlock.related || { imports: [], local_symbols: [] },
1128
- edit_target: {
1506
+ edit: {
1129
1507
  start_line: block.start_line,
1130
1508
  end_line: block.end_line,
1131
- old_hash: sha256(block.content)
1509
+ old_hash: sha256(block.content),
1510
+ old_content: block.content
1132
1511
  }
1133
1512
  };
1134
1513
  }
@@ -1137,9 +1516,16 @@ function normalizeEditTargetArgs(args = {}) {
1137
1516
  const file = String(args?.file || args?.path || '').trim();
1138
1517
  const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1139
1518
  if (nestedEdit) {
1519
+ const normalizedEdit = { ...nestedEdit };
1520
+ if (normalizedEdit.new_content == null && normalizedEdit.content != null) {
1521
+ normalizedEdit.new_content = normalizedEdit.content;
1522
+ }
1523
+ if (normalizedEdit.new_text == null && normalizedEdit.content != null && normalizedEdit.old_text != null) {
1524
+ normalizedEdit.new_text = normalizedEdit.content;
1525
+ }
1140
1526
  return {
1141
1527
  file,
1142
- edit: nestedEdit
1528
+ edit: normalizedEdit
1143
1529
  };
1144
1530
  }
1145
1531
  return {
@@ -1160,13 +1546,35 @@ async function editTarget(root, args) {
1160
1546
  const normalized = normalizeEditTargetArgs(args);
1161
1547
  const file = normalized.file;
1162
1548
  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');
1549
+ let kind = String(edit.kind || '').trim();
1550
+ const hasContent = edit.new_content != null || edit.content != null;
1551
+ const hasTargetHint = Boolean(edit.symbol || args?.symbol || edit.line || args?.line || edit.target);
1552
+ if (!kind) {
1553
+ if (hasContent && hasTargetHint) {
1554
+ kind = 'replace_block';
1555
+ } else if (edit.old_text != null && (edit.new_text != null || edit.content != null)) {
1556
+ kind = 'replace_text';
1557
+ } else if ((edit.anchor_text != null || edit.target_text != null) && (edit.content != null || edit.new_content != null)) {
1558
+ kind = String(edit.position || edit.mode || args?.position || '').trim() === 'after' ? 'insert_after' : 'insert_before';
1559
+ } else if (hasContent) {
1560
+ kind = 'rewrite_file';
1561
+ }
1562
+ }
1563
+ if (!file || !kind) throw new Error('edit requires file and edit.kind');
1165
1564
  if (kind === 'replace_block') {
1565
+ const resolvedTarget =
1566
+ edit.target ||
1567
+ (
1568
+ await openTarget(root, {
1569
+ file,
1570
+ symbol: edit.symbol || args?.symbol,
1571
+ line: edit.line || args?.line
1572
+ })
1573
+ ).edit;
1166
1574
  try {
1167
1575
  return await replaceBlock(root, {
1168
1576
  path: file,
1169
- target: edit.target,
1577
+ target: resolvedTarget,
1170
1578
  new_content: edit.new_content
1171
1579
  });
1172
1580
  } catch (error) {
@@ -1174,7 +1582,7 @@ async function editTarget(root, args) {
1174
1582
  const validation = await validateEdit(root, {
1175
1583
  path: file,
1176
1584
  kind: 'replace_block',
1177
- target: edit.target
1585
+ target: resolvedTarget
1178
1586
  });
1179
1587
  return replaceBlock(root, {
1180
1588
  path: file,
@@ -1196,7 +1604,14 @@ async function editTarget(root, args) {
1196
1604
  if (kind === 'insert_after') {
1197
1605
  return insertRelative(root, { path: file, anchor_text: edit.anchor_text, content: edit.content }, 'insert_after');
1198
1606
  }
1199
- throw new Error(`edit_target does not support kind: ${kind}`);
1607
+ if (kind === 'rewrite_file') {
1608
+ return writeFile(root, {
1609
+ path: file,
1610
+ content: edit.new_content ?? edit.content ?? '',
1611
+ full_file_rewrite: true
1612
+ });
1613
+ }
1614
+ throw new Error(`edit does not support kind: ${kind}`);
1200
1615
  }
1201
1616
 
1202
1617
  export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
@@ -1204,192 +1619,132 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1204
1619
  {
1205
1620
  type: 'function',
1206
1621
  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',
1622
+ name: 'read',
1623
+ description:
1624
+ 'Primary read tool. First call returns metadata+read_token, second call with include_content=true and matching read_token returns content',
1247
1625
  parameters: {
1248
1626
  type: 'object',
1249
1627
  properties: {
1250
- file: { type: 'string' },
1251
1628
  path: { type: 'string' },
1252
- edit: { type: 'object' }
1629
+ start_line: { type: 'number' },
1630
+ end_line: { type: 'number' },
1631
+ max_chars: { type: 'number' },
1632
+ include_content: { type: 'boolean' },
1633
+ read_token: { type: 'string' }
1253
1634
  },
1254
- required: ['file', 'edit']
1635
+ required: ['path']
1255
1636
  }
1256
1637
  }
1257
1638
  },
1258
1639
  {
1259
1640
  type: 'function',
1260
1641
  function: {
1261
- name: 'search_code',
1262
- description: 'Search code and return structured top matches with file, line, preview, and basic match kind',
1642
+ name: 'grep',
1643
+ description: 'Search file contents using a plain string or regex pattern and return compact matches',
1263
1644
  parameters: {
1264
1645
  type: 'object',
1265
1646
  properties: {
1647
+ pattern: { type: 'string' },
1266
1648
  query: { type: 'string' },
1267
1649
  path: { type: 'string' },
1268
- max_results: { type: 'number' },
1650
+ regex: { type: 'boolean' },
1269
1651
  case_sensitive: { type: 'boolean' },
1652
+ max_results: { type: 'number' },
1270
1653
  language: { type: 'string' },
1271
1654
  file_types: { type: 'array', items: { type: 'string' } }
1272
1655
  },
1273
- required: ['query']
1656
+ required: ['pattern']
1274
1657
  }
1275
1658
  }
1276
1659
  },
1277
1660
  {
1278
1661
  type: 'function',
1279
1662
  function: {
1280
- name: 'read_block',
1281
- description: 'Read the smallest likely code block around a symbol or line from a file',
1663
+ name: 'glob',
1664
+ description: 'Find files by glob pattern such as **/*.ts or src/**/*.tsx',
1282
1665
  parameters: {
1283
1666
  type: 'object',
1284
1667
  properties: {
1668
+ pattern: { type: 'string' },
1285
1669
  path: { type: 'string' },
1286
- symbol: { type: 'string' },
1287
- line: { type: 'number' },
1288
- anchor_line: { type: 'number' }
1670
+ include_hidden: { type: 'boolean' },
1671
+ max_results: { type: 'number' }
1289
1672
  },
1290
- required: ['path']
1673
+ required: ['pattern']
1291
1674
  }
1292
1675
  }
1293
1676
  },
1294
1677
  {
1295
1678
  type: 'function',
1296
1679
  function: {
1297
- name: 'read_symbol_context',
1298
- description: 'Read a symbol block plus import and local symbol summaries',
1680
+ name: 'list',
1681
+ description: 'List files and directories in a workspace path',
1299
1682
  parameters: {
1300
1683
  type: 'object',
1301
1684
  properties: {
1302
1685
  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']
1686
+ include_hidden: { type: 'boolean' }
1687
+ }
1309
1688
  }
1310
1689
  }
1311
1690
  },
1312
1691
  {
1313
1692
  type: 'function',
1314
1693
  function: {
1315
- name: 'validate_edit',
1316
- description: 'Validate whether an edit target is stable before applying it',
1694
+ name: 'edit',
1695
+ description:
1696
+ '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
1697
  parameters: {
1318
1698
  type: 'object',
1319
1699
  properties: {
1700
+ file: { type: 'string' },
1320
1701
  path: { type: 'string' },
1321
- kind: { type: 'string' },
1322
- target: { type: 'object' },
1323
- start_line: { type: 'number' },
1324
- end_line: { type: 'number' },
1702
+ new_content: { type: 'string' },
1325
1703
  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' },
1704
+ new_text: { type: 'string' },
1705
+ anchor_text: { type: 'string' },
1706
+ content: { type: 'string' },
1707
+ position: { type: 'string' },
1708
+ kind: { type: 'string' },
1341
1709
  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' }
1710
+ symbol: { type: 'string' },
1711
+ line: { type: 'number' },
1712
+ edit: { type: 'object' },
1359
1713
  },
1360
- required: ['path', 'old_text', 'new_text']
1714
+ required: ['file']
1361
1715
  }
1362
1716
  }
1363
1717
  },
1364
1718
  {
1365
1719
  type: 'function',
1366
1720
  function: {
1367
- name: 'insert_before',
1368
- description: 'Insert text before a unique anchor string',
1721
+ name: 'write',
1722
+ description:
1723
+ '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
1724
  parameters: {
1370
1725
  type: 'object',
1371
1726
  properties: {
1372
1727
  path: { type: 'string' },
1373
- anchor_text: { type: 'string' },
1374
- content: { type: 'string' }
1728
+ content: { type: 'string' },
1729
+ append: { type: 'boolean' },
1730
+ full_file_rewrite: { type: 'boolean' }
1375
1731
  },
1376
- required: ['path', 'anchor_text', 'content']
1732
+ required: ['path', 'content']
1377
1733
  }
1378
1734
  }
1379
1735
  },
1380
1736
  {
1381
1737
  type: 'function',
1382
1738
  function: {
1383
- name: 'insert_after',
1384
- description: 'Insert text after a unique anchor string',
1739
+ name: 'run',
1740
+ description:
1741
+ 'Primary run tool. Execute a one-shot shell command in workspace such as install, build, test, or other finite tasks. Do not use for long-running services or watchers.',
1385
1742
  parameters: {
1386
1743
  type: 'object',
1387
1744
  properties: {
1388
- path: { type: 'string' },
1389
- anchor_text: { type: 'string' },
1390
- content: { type: 'string' }
1745
+ command: { type: 'string' }
1391
1746
  },
1392
- required: ['path', 'anchor_text', 'content']
1747
+ required: ['command']
1393
1748
  }
1394
1749
  }
1395
1750
  },
@@ -1411,52 +1766,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1411
1766
  {
1412
1767
  type: 'function',
1413
1768
  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',
1769
+ name: 'patch',
1770
+ description: 'Apply one or more unified diff hunks to files in the workspace',
1417
1771
  parameters: {
1418
1772
  type: 'object',
1419
1773
  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.',
1454
- parameters: {
1455
- type: 'object',
1456
- properties: {
1457
- command: { type: 'string' }
1774
+ patch: { type: 'string' },
1775
+ content: { type: 'string' }
1458
1776
  },
1459
- required: ['command']
1777
+ required: ['patch']
1460
1778
  }
1461
1779
  }
1462
1780
  },
@@ -1464,7 +1782,8 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1464
1782
  type: 'function',
1465
1783
  function: {
1466
1784
  name: 'start_service',
1467
- description: 'Start a long-running local service and return a compact service handle instead of blocking on process exit.',
1785
+ description:
1786
+ 'Start a long-running local service, such as a frontend, backend, database, or dev watcher, and return a compact service handle instead of blocking on process exit.',
1468
1787
  parameters: {
1469
1788
  type: 'object',
1470
1789
  properties: {
@@ -1542,27 +1861,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1542
1861
  }
1543
1862
  }
1544
1863
  }
1545
- ];
1864
+ ].filter(Boolean);
1546
1865
 
1547
1866
  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) =>
1867
+ read: (args) =>
1566
1868
  readFile(workspaceRoot, {
1567
1869
  ...args,
1568
1870
  default_lines: config.context?.read_file_default_lines ?? 220,
@@ -1571,8 +1873,19 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config }) {
1571
1873
  ? args.max_chars
1572
1874
  : config.context?.read_file_max_chars ?? 24000
1573
1875
  }),
1574
- write_file: (args) => writeFile(workspaceRoot, args),
1575
- run_command: (args) => runCommand(workspaceRoot, config, args)
1876
+ grep: (args) => grep(workspaceRoot, args),
1877
+ glob: (args) => glob(workspaceRoot, args),
1878
+ list: (args) => list(workspaceRoot, args),
1879
+ edit: (args) => editTarget(workspaceRoot, args),
1880
+ generate_diff: (args) => generateDiff(workspaceRoot, args),
1881
+ patch: (args) => applyPatch(workspaceRoot, args),
1882
+ write: (args) => writeFile(workspaceRoot, args),
1883
+ run: (args) => runCommand(workspaceRoot, config, args),
1884
+ start_service: (args) => startService(workspaceRoot, config, args),
1885
+ list_services: () => listServices(workspaceRoot),
1886
+ get_service_status: (args) => getServiceStatus(workspaceRoot, args),
1887
+ get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
1888
+ stop_service: (args) => stopService(workspaceRoot, args)
1576
1889
  };
1577
1890
 
1578
1891
  return { definitions, handlers };