codemini-cli 0.3.5 → 0.3.6

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
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
- import fsSync from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { spawn } from 'node:child_process';
5
4
  import net from 'node:net';
5
+ import { escapeRegex, normalizePath } from './string-utils.js';
6
6
  import {
7
7
  classifyCommandIntent,
8
8
  hasReadyOutput,
@@ -13,22 +13,24 @@ import {
13
13
  terminateChild
14
14
  } from './shell.js';
15
15
  import { evaluateCommandPolicy } from './command-policy.js';
16
- import { queryAst, readAstNode, resolveAstTarget } from './ast.js';
16
+ import { findEnclosingSymbol, queryAst, readAstNode, resolveAstTarget } from './ast.js';
17
17
  import { initializeProjectIndex, queryProjectIndex, refreshIndexedFile } from './project-index.js';
18
18
  import { checkReadDedup } from './agent-loop.js';
19
19
  import { TOOL_SKIP_DIRS as SKIP_DIRS, TEXT_EXTENSIONS, CODE_WRITE_GUARD_EXTENSIONS, LANGUAGE_FILE_TYPES } from './constants.js';
20
- import { sha256Prefixed as sha256, sha1 } from './crypto-utils.js';
20
+ import { sha256Prefixed as sha256, sha256 as sha256Hash } from './crypto-utils.js';
21
21
  import { forgetMemory, listMemories, rememberMemory, searchMemories } from './memory-store.js';
22
22
  import { normalizeTodos } from './todo-state.js';
23
23
  const BACKGROUND_TASK_RECENT_OUTPUT_LIMIT = 80;
24
24
  const BACKGROUND_TASK_POLL_MS = 150;
25
+ const MAX_AST_ENCLOSING_BYTES = 300_000;
26
+ const MAX_AST_ENCLOSING_LINES = 5_000;
25
27
  const backgroundTaskRegistry = new Map();
26
28
  let backgroundTaskCounter = 0;
27
29
  let backgroundTaskLogCursorCounter = 0;
28
30
 
29
- function realpathIfExists(targetPath) {
31
+ async function realpathIfExists(targetPath) {
30
32
  try {
31
- return fsSync.realpathSync.native(targetPath);
33
+ return await fs.realpath(targetPath);
32
34
  } catch (error) {
33
35
  if (error?.code === 'ENOENT') return null;
34
36
  throw error;
@@ -40,11 +42,11 @@ function isWithinResolvedRoot(resolvedRoot, candidatePath) {
40
42
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
41
43
  }
42
44
 
43
- function resolveInWorkspace(root, targetPath = '.') {
45
+ async function resolveInWorkspace(root, targetPath = '.') {
44
46
  const absRoot = path.resolve(root);
45
- const realRoot = fsSync.realpathSync.native(absRoot);
47
+ const realRoot = await fs.realpath(absRoot);
46
48
  const absTarget = path.resolve(absRoot, targetPath);
47
- const realTarget = realpathIfExists(absTarget);
49
+ const realTarget = await realpathIfExists(absTarget);
48
50
  if (realTarget) {
49
51
  if (!isWithinResolvedRoot(realRoot, realTarget)) {
50
52
  throw new Error(`Path escapes workspace: ${targetPath}`);
@@ -53,13 +55,13 @@ function resolveInWorkspace(root, targetPath = '.') {
53
55
  }
54
56
 
55
57
  let probe = path.dirname(absTarget);
56
- while (!realpathIfExists(probe)) {
58
+ while (!(await realpathIfExists(probe))) {
57
59
  const parent = path.dirname(probe);
58
60
  if (parent === probe) break;
59
61
  probe = parent;
60
62
  }
61
63
 
62
- const resolvedProbe = realpathIfExists(probe);
64
+ const resolvedProbe = await realpathIfExists(probe);
63
65
  if (!resolvedProbe) {
64
66
  throw new Error(`Path escapes workspace: ${targetPath}`);
65
67
  }
@@ -71,12 +73,12 @@ function resolveInWorkspace(root, targetPath = '.') {
71
73
  return resolvedTarget;
72
74
  }
73
75
 
74
- function getBackgroundTasksDir(root) {
75
- return path.join(resolveInWorkspace(root, '.codemini'), 'tasks');
76
+ async function getBackgroundTasksDir(root) {
77
+ return path.join(await resolveInWorkspace(root, '.codemini'), 'tasks');
76
78
  }
77
79
 
78
80
  function toWorkspaceRelative(root, absPath) {
79
- return path.relative(path.resolve(root), absPath).replace(/\\/g, '/');
81
+ return normalizePath(path.relative(path.resolve(root), absPath));
80
82
  }
81
83
 
82
84
  function trimLinePreview(line, maxLen = 180) {
@@ -85,10 +87,6 @@ function trimLinePreview(line, maxLen = 180) {
85
87
  return `${text.slice(0, maxLen - 3)}...`;
86
88
  }
87
89
 
88
- function escapeRegex(value) {
89
- return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
90
- }
91
-
92
90
  function splitLines(text) {
93
91
  return String(text || '').split('\n');
94
92
  }
@@ -297,7 +295,7 @@ async function mapLimit(items, limit, worker) {
297
295
  const WALKER_CONCURRENCY = 8;
298
296
 
299
297
  async function walkTextFiles(root, startPath = '.', fileTypes = []) {
300
- const abs = resolveInWorkspace(root, startPath);
298
+ const abs = await resolveInWorkspace(root, startPath);
301
299
  const allowedExts = new Set((Array.isArray(fileTypes) ? fileTypes : []).map((item) => `.${String(item || '').replace(/^\./, '')}`));
302
300
 
303
301
  async function visit(current) {
@@ -318,7 +316,7 @@ async function walkTextFiles(root, startPath = '.', fileTypes = []) {
318
316
  }
319
317
 
320
318
  async function walkWorkspaceEntries(root, startPath = '.', { includeHidden = false } = {}) {
321
- const abs = resolveInWorkspace(root, startPath);
319
+ const abs = await resolveInWorkspace(root, startPath);
322
320
 
323
321
  async function visit(current) {
324
322
  const stat = await fs.stat(current);
@@ -369,41 +367,6 @@ function globToRegex(pattern) {
369
367
  return new RegExp(`^${regexBody}$`);
370
368
  }
371
369
 
372
- function getLineColumnForMatch(line, query, caseSensitive = false) {
373
- const haystack = caseSensitive ? line : line.toLowerCase();
374
- const needle = caseSensitive ? query : query.toLowerCase();
375
- const index = haystack.indexOf(needle);
376
- return index === -1 ? 1 : index + 1;
377
- }
378
-
379
- function classifyMatch(preview, query) {
380
- const line = String(preview || '');
381
- const escaped = escapeRegex(query);
382
- const normalized = line.toLowerCase();
383
- const queryLower = String(query || '').toLowerCase();
384
- const definitionLeadPatterns = [
385
- /^\s*(?:export\s+)?(?:async\s+)?function\b/i,
386
- /^\s*(?:export\s+)?class\b/i,
387
- /^\s*(?:export\s+)?(?:const|let|var)\b/i,
388
- /^\s*(?:export\s+)?(?:interface|type|enum)\b/i,
389
- /^\s*def\b/i,
390
- /^\s*(?:public|private|protected)\s+[A-Za-z0-9_<>,[\]\s?]+\s+[A-Za-z0-9_$]+\s*\(/i
391
- ];
392
- if (definitionLeadPatterns.some((pattern) => pattern.test(line)) && normalized.includes(queryLower)) {
393
- return 'definition';
394
- }
395
- if (new RegExp(String.raw`\b${escaped}\s*\(`, 'i').test(line)) return 'reference';
396
- return 'text';
397
- }
398
-
399
- function matchSpecificity(preview, query) {
400
- const line = String(preview || '');
401
- const escaped = escapeRegex(query);
402
- if (new RegExp(String.raw`\b${escaped}\b`, 'i').test(line)) return 0;
403
- if (line.toLowerCase().includes(String(query || '').toLowerCase())) return 1;
404
- return 2;
405
- }
406
-
407
370
  function findSymbolDefinition(lines, symbol) {
408
371
  const escaped = String(symbol || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
409
372
  const patterns = [
@@ -555,7 +518,7 @@ function extractDirectCalls(lines, symbol, maxItems = 3, excludeRange = null) {
555
518
  if (excludeRange && i + 1 >= excludeRange.startLine && i + 1 <= excludeRange.endLine) continue;
556
519
  const line = String(lines[i] || '');
557
520
  if (!new RegExp(String.raw`\b${escaped}\s*\(`).test(line)) continue;
558
- const blockLine = findEnclosingSymbol(lines, i + 1);
521
+ const blockLine = findEnclosingSymbolLine(lines, i + 1);
559
522
  const owner = blockLine ? trimLinePreview(lines[blockLine - 1], 220) : trimLinePreview(line, 220);
560
523
  const ownerName = blockLine ? extractSymbolName(lines[blockLine - 1]) : '';
561
524
  if (ownerName === symbol) continue;
@@ -578,7 +541,7 @@ function extractSymbolName(line) {
578
541
  return match?.[1] || '';
579
542
  }
580
543
 
581
- function findEnclosingSymbol(lines, anchorLine) {
544
+ function findEnclosingSymbolLine(lines, anchorLine) {
582
545
  for (let i = Math.max(0, anchorLine - 1); i >= 0; i -= 1) {
583
546
  const name = extractSymbolName(lines[i]);
584
547
  if (name) return i + 1;
@@ -620,131 +583,8 @@ function buildUnifiedDiff(oldContent, newContent, filePath = 'file') {
620
583
  return body.join('\n');
621
584
  }
622
585
 
623
- function parseUnifiedPatch(patchText) {
624
- const lines = splitLines(String(patchText || ''));
625
- const files = [];
626
- let current = null;
627
-
628
- const pushCurrent = () => {
629
- if (current) files.push(current);
630
- };
631
-
632
- for (let i = 0; i < lines.length; i += 1) {
633
- const line = lines[i];
634
- if (line.startsWith('--- ')) {
635
- pushCurrent();
636
- current = {
637
- oldPath: line.slice(4).trim(),
638
- newPath: '',
639
- hunks: []
640
- };
641
- continue;
642
- }
643
- if (!current) continue;
644
- if (line.startsWith('+++ ')) {
645
- current.newPath = line.slice(4).trim();
646
- continue;
647
- }
648
- if (line.startsWith('@@ ')) {
649
- const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
650
- if (!match) {
651
- throw new Error(`invalid patch hunk header: ${line}`);
652
- }
653
- const hunk = {
654
- oldStart: Number(match[1]),
655
- oldCount: Number(match[2] || '1'),
656
- newStart: Number(match[3]),
657
- newCount: Number(match[4] || '1'),
658
- lines: []
659
- };
660
- i += 1;
661
- while (i < lines.length) {
662
- const hunkLine = lines[i];
663
- if (hunkLine.startsWith('@@ ') || hunkLine.startsWith('--- ')) {
664
- i -= 1;
665
- break;
666
- }
667
- if (hunkLine.startsWith('\')) {
668
- i += 1;
669
- continue;
670
- }
671
- if (hunkLine === '') {
672
- hunk.lines.push(' ');
673
- i += 1;
674
- continue;
675
- }
676
- if (!/^[ +\-]/.test(hunkLine)) {
677
- hunk.lines.push(` ${hunkLine}`);
678
- i += 1;
679
- continue;
680
- }
681
- if (!/^[ +\-]/.test(hunkLine)) {
682
- throw new Error(`invalid patch line: ${hunkLine}`);
683
- }
684
- hunk.lines.push(hunkLine);
685
- i += 1;
686
- }
687
- current.hunks.push(hunk);
688
- }
689
- }
690
-
691
- pushCurrent();
692
- return files.filter((file) => file.oldPath || file.newPath);
693
- }
694
-
695
- function applyHunkToLines(lines, hunk) {
696
- const oldChunk = [];
697
- const newChunk = [];
698
- for (const line of hunk.lines) {
699
- if (line.startsWith(' ')) {
700
- const text = line.slice(1);
701
- oldChunk.push(text);
702
- newChunk.push(text);
703
- continue;
704
- }
705
- if (line.startsWith('-')) {
706
- oldChunk.push(line.slice(1));
707
- continue;
708
- }
709
- if (line.startsWith('+')) {
710
- newChunk.push(line.slice(1));
711
- }
712
- }
713
-
714
- if (oldChunk.length === 0) {
715
- const insertAt = Math.max(0, Number(hunk.oldStart || 1) - 1);
716
- return [...lines.slice(0, insertAt), ...newChunk, ...lines.slice(insertAt)];
717
- }
718
-
719
- const lastStart = Math.max(0, lines.length - oldChunk.length);
720
- const matches = [];
721
- for (let start = 0; start <= lastStart; start += 1) {
722
- let ok = true;
723
- for (let offset = 0; offset < oldChunk.length; offset += 1) {
724
- if (lines[start + offset] !== oldChunk[offset]) {
725
- ok = false;
726
- break;
727
- }
728
- }
729
- if (ok) {
730
- matches.push(start);
731
- if (matches.length > 1) break;
732
- }
733
- }
734
-
735
- if (matches.length === 0) {
736
- throw new Error('patch hunk context not found');
737
- }
738
- if (matches.length > 1) {
739
- throw new Error('patch hunk context not unique');
740
- }
741
-
742
- const start = matches[0];
743
- return [...lines.slice(0, start), ...newChunk, ...lines.slice(start + oldChunk.length)];
744
- }
745
-
746
586
  async function getFileState(root, relativePath) {
747
- const target = resolveInWorkspace(root, relativePath);
587
+ const target = await resolveInWorkspace(root, relativePath);
748
588
  const stat = await fs.stat(target);
749
589
  const content = await fs.readFile(target, 'utf8');
750
590
  return {
@@ -757,7 +597,7 @@ async function getFileState(root, relativePath) {
757
597
 
758
598
  async function readFile(root, args) {
759
599
  const normalizedArgs = normalizeReadArgs(args);
760
- const target = resolveInWorkspace(root, normalizedArgs?.path);
600
+ const target = await resolveInWorkspace(root, normalizedArgs?.path);
761
601
  const stat = await fs.stat(target);
762
602
  const text = await fs.readFile(target, 'utf8');
763
603
  const lines = splitLines(text);
@@ -777,7 +617,7 @@ async function readFile(root, args) {
777
617
  endLine = Math.max(startLine, Math.min(endLine, totalLines));
778
618
 
779
619
  const tokenSeed = `${normalizedArgs?.path}|${stat.size}|${stat.mtimeMs}|${startLine}|${endLine}`;
780
- const readToken = sha1(tokenSeed).slice(0, 16);
620
+ const readToken = sha256Hash(tokenSeed).slice(0, 16);
781
621
 
782
622
  if (wantsMetadataOnly) {
783
623
  return {
@@ -820,6 +660,11 @@ async function readFile(root, args) {
820
660
  };
821
661
  }
822
662
 
663
+ // Resolve enclosing structural symbol via Tree-sitter (best-effort, skipped for large files)
664
+ const shouldResolveEnclosing = text.length <= MAX_AST_ENCLOSING_BYTES && totalLines <= MAX_AST_ENCLOSING_LINES;
665
+ const anchorLine = Math.floor((startLine + endLine) / 2);
666
+ const enclosing = shouldResolveEnclosing ? await findEnclosingSymbol(text, normalizedArgs?.path, anchorLine) : null;
667
+
823
668
  return {
824
669
  path: normalizedArgs?.path,
825
670
  phase: 'content',
@@ -827,7 +672,8 @@ async function readFile(root, args) {
827
672
  end_line: endLine,
828
673
  total_lines: totalLines,
829
674
  truncated,
830
- content
675
+ content,
676
+ ...(enclosing ? { enclosing_symbol: enclosing.name, enclosing_kind: enclosing.kind, enclosing_line: enclosing.start_line } : {})
831
677
  };
832
678
  }
833
679
 
@@ -840,7 +686,7 @@ async function writeFile(root, args) {
840
686
  if (rawPath === '.' || rawPath === './') {
841
687
  throw new Error('write requires a file path, not the workspace root');
842
688
  }
843
- const target = resolveInWorkspace(root, rawPath);
689
+ const target = await resolveInWorkspace(root, rawPath);
844
690
  try {
845
691
  const stat = await fs.stat(target);
846
692
  if (stat.isDirectory()) {
@@ -889,6 +735,63 @@ async function writeFile(root, args) {
889
735
  };
890
736
  }
891
737
 
738
+ async function prepareDeleteTarget(root, args) {
739
+ const normalizedArgs = normalizePathArgs(args, ['file', 'file_path', 'target', 'directory', 'dir']);
740
+ const rawPath = String(normalizedArgs?.path || '').trim();
741
+ if (!rawPath) {
742
+ throw new Error('delete requires a file or directory path');
743
+ }
744
+ const absRoot = path.resolve(root);
745
+ const realRoot = await fs.realpath(absRoot);
746
+ const originalTarget = path.resolve(absRoot, rawPath);
747
+ if (originalTarget === absRoot) {
748
+ throw new Error('delete requires a path inside the workspace, not the workspace root');
749
+ }
750
+ const resolvedTarget = await resolveInWorkspace(root, rawPath);
751
+ if (resolvedTarget === realRoot) {
752
+ throw new Error('delete requires a path inside the workspace, not the workspace root');
753
+ }
754
+
755
+ let rawStat;
756
+ let stat;
757
+ try {
758
+ rawStat = await fs.lstat(originalTarget);
759
+ } catch (error) {
760
+ if (error?.code === 'ENOENT') {
761
+ throw new Error(`delete target not found: ${rawPath}`);
762
+ }
763
+ throw error;
764
+ }
765
+ try {
766
+ stat = await fs.stat(resolvedTarget);
767
+ } catch (error) {
768
+ if (error?.code !== 'ENOENT') throw error;
769
+ }
770
+
771
+ const type = stat?.isDirectory?.() ? 'directory' : rawStat.isDirectory() ? 'directory' : 'file';
772
+ const pathInWorkspace = toWorkspaceRelative(root, originalTarget);
773
+ return {
774
+ originalTarget,
775
+ resolvedTarget,
776
+ path: pathInWorkspace,
777
+ name: path.basename(pathInWorkspace),
778
+ type
779
+ };
780
+ }
781
+
782
+ async function deletePath(root, args) {
783
+ const target = await prepareDeleteTarget(root, args);
784
+ await fs.rm(target.originalTarget, { recursive: true, force: false });
785
+
786
+ return {
787
+ ok: true,
788
+ path: target.path,
789
+ name: target.name,
790
+ type: target.type,
791
+ deleted: true
792
+ };
793
+ }
794
+
892
795
  async function runCommand(root, config, args) {
893
796
  const command = args?.command || '';
894
797
  if (!command.trim()) {
@@ -1086,7 +989,7 @@ async function startBackgroundTask(root, config, args) {
1086
989
  const successMatchers = normalizeSuccessMatchers(args?.success_matchers || args?.successMatchers);
1087
990
  const portProbe = Number(args?.port_probe || args?.portProbe || 0) || 0;
1088
991
  const httpProbe = normalizeHttpProbe(args?.http_probe || args?.httpProbe);
1089
- const outputDir = getBackgroundTasksDir(root);
992
+ const outputDir = await getBackgroundTasksDir(root);
1090
993
  await fs.mkdir(outputDir, { recursive: true });
1091
994
  const outputFileAbs = path.join(outputDir, `${taskId}.log`);
1092
995
  await fs.writeFile(outputFileAbs, '', 'utf8');
@@ -1229,68 +1132,6 @@ async function stopBackgroundTask(_root, args) {
1229
1132
  return { ...snapshotBackgroundTask(task), stopped: true };
1230
1133
  }
1231
1134
 
1232
- async function searchCode(root, args) {
1233
- const query = String(args?.query || args?.symbol || '').trim();
1234
- if (!query) throw new Error('search_code requires query');
1235
- const maxResults = Math.max(1, Math.min(50, Number(args?.max_results || 12)));
1236
- const caseSensitive = Boolean(args?.case_sensitive);
1237
- const files = await walkTextFiles(root, args?.path || '.', normalizeFileTypes(args));
1238
- const matches = [];
1239
-
1240
- for (const filePath of files) {
1241
- const content = await fs.readFile(filePath, 'utf8');
1242
- const lines = splitLines(content);
1243
- for (let idx = 0; idx < lines.length; idx += 1) {
1244
- const line = lines[idx];
1245
- const haystack = caseSensitive ? line : line.toLowerCase();
1246
- const needle = caseSensitive ? query : query.toLowerCase();
1247
- if (!haystack.includes(needle)) continue;
1248
- matches.push({
1249
- file: toWorkspaceRelative(root, filePath),
1250
- line: idx + 1,
1251
- column: getLineColumnForMatch(line, query, caseSensitive),
1252
- preview: trimLinePreview(line),
1253
- kind: classifyMatch(line, query),
1254
- symbolHint: query
1255
- });
1256
- if (matches.length >= maxResults) {
1257
- matches.sort((left, right) => {
1258
- const kindRank = { definition: 0, reference: 1, text: 2 };
1259
- const specificity = matchSpecificity(left.preview, query) - matchSpecificity(right.preview, query);
1260
- if (specificity !== 0) return specificity;
1261
- if (kindRank[left.kind] !== kindRank[right.kind]) return kindRank[left.kind] - kindRank[right.kind];
1262
- return left.file.localeCompare(right.file) || left.line - right.line;
1263
- });
1264
- return {
1265
- query,
1266
- matches,
1267
- definitions: matches.filter((item) => item.kind === 'definition'),
1268
- references: matches.filter((item) => item.kind === 'reference'),
1269
- text_matches: matches.filter((item) => item.kind === 'text'),
1270
- truncated: true
1271
- };
1272
- }
1273
- }
1274
- }
1275
-
1276
- matches.sort((left, right) => {
1277
- const kindRank = { definition: 0, reference: 1, text: 2 };
1278
- const specificity = matchSpecificity(left.preview, query) - matchSpecificity(right.preview, query);
1279
- if (specificity !== 0) return specificity;
1280
- if (kindRank[left.kind] !== kindRank[right.kind]) return kindRank[left.kind] - kindRank[right.kind];
1281
- return left.file.localeCompare(right.file) || left.line - right.line;
1282
- });
1283
-
1284
- return {
1285
- query,
1286
- matches,
1287
- definitions: matches.filter((item) => item.kind === 'definition'),
1288
- references: matches.filter((item) => item.kind === 'reference'),
1289
- text_matches: matches.filter((item) => item.kind === 'text'),
1290
- truncated: false
1291
- };
1292
- }
1293
-
1294
1135
  async function grep(root, args) {
1295
1136
  const normalizedArgs = normalizePatternArgs(args, ['query', 'symbol', 'q'], ['directory', 'dir', 'cwd']);
1296
1137
  const pattern = String(normalizedArgs?.pattern || '').trim();
@@ -1349,7 +1190,7 @@ async function glob(root, args) {
1349
1190
  async function list(root, args) {
1350
1191
  const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'target']);
1351
1192
  const relativePath = String(normalizedArgs?.path || '.').trim() || '.';
1352
- const target = resolveInWorkspace(root, relativePath);
1193
+ const target = await resolveInWorkspace(root, relativePath);
1353
1194
  const entries = await fs.readdir(target, { withFileTypes: true });
1354
1195
  const includeHidden = Boolean(normalizedArgs?.include_hidden);
1355
1196
  const items = entries
@@ -1529,70 +1370,6 @@ async function insertRelative(root, args, mode) {
1529
1370
  return editResult(relativePath, mode, state.content, afterContent, changedLine);
1530
1371
  }
1531
1372
 
1532
- async function generateDiff(root, args) {
1533
- const relativePath = String(args?.path || '').trim();
1534
- if (!relativePath) throw new Error('generate_diff requires path');
1535
- const state = await getFileState(root, relativePath);
1536
- const newContent = String(args?.new_content || '');
1537
- return {
1538
- path: relativePath,
1539
- old_hash: sha256(state.content),
1540
- new_hash: sha256(newContent),
1541
- diff: buildUnifiedDiff(state.content, newContent, relativePath)
1542
- };
1543
- }
1544
-
1545
- async function applyPatch(root, args) {
1546
- const patchText = String(args?.patch || args?.content || '').trim();
1547
- if (!patchText) throw new Error('patch requires patch content');
1548
- const files = parseUnifiedPatch(patchText);
1549
- if (files.length === 0) throw new Error('patch contains no file changes');
1550
-
1551
- const results = [];
1552
- for (const fileChange of files) {
1553
- const newPath = String(fileChange.newPath || '').trim();
1554
- const oldPath = String(fileChange.oldPath || '').trim();
1555
- const targetPath = newPath && newPath !== '/dev/null' ? newPath : oldPath;
1556
- if (!targetPath || targetPath === '/dev/null') {
1557
- throw new Error('patch requires a target file path');
1558
- }
1559
- const absTarget = resolveInWorkspace(root, targetPath);
1560
- let beforeContent = '';
1561
- let beforeLines = [];
1562
- try {
1563
- beforeContent = await fs.readFile(absTarget, 'utf8');
1564
- beforeLines = splitLines(beforeContent);
1565
- } catch (error) {
1566
- if (!(error && error.code === 'ENOENT')) throw error;
1567
- }
1568
-
1569
- let nextLines = beforeLines;
1570
- for (const hunk of fileChange.hunks) {
1571
- nextLines = applyHunkToLines(nextLines, hunk);
1572
- }
1573
- const afterContent = nextLines.join('\n');
1574
-
1575
- if (newPath === '/dev/null') {
1576
- await fs.rm(absTarget, { force: true });
1577
- results.push({
1578
- path: targetPath,
1579
- action: 'delete',
1580
- changed_line: 1,
1581
- diff_preview: `deleted ${targetPath}`,
1582
- diff: buildUnifiedDiff(beforeContent, '', targetPath),
1583
- new_hash: sha256('')
1584
- });
1585
- continue;
1586
- }
1587
-
1588
- await fs.mkdir(path.dirname(absTarget), { recursive: true });
1589
- await fs.writeFile(absTarget, afterContent, 'utf8');
1590
- results.push(editResult(targetPath, beforeContent ? 'patch' : 'create', beforeContent, afterContent, 1));
1591
- }
1592
-
1593
- return results.length === 1 ? results[0] : { ok: true, files: results };
1594
- }
1595
-
1596
1373
  async function openTarget(root, args) {
1597
1374
  const file = String(args?.file || args?.path || '').trim();
1598
1375
  if (!file) throw new Error('open_target requires file');
@@ -1904,6 +1681,26 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1904
1681
  }
1905
1682
  }
1906
1683
  },
1684
+ {
1685
+ type: 'function',
1686
+ function: {
1687
+ name: 'glob',
1688
+ description:
1689
+ 'Find files by glob pattern. Use this when you already know a filename pattern such as src/**/*.ts. Aliases like query and directory are accepted.',
1690
+ parameters: {
1691
+ type: 'object',
1692
+ properties: {
1693
+ pattern: { type: 'string', description: 'Glob pattern' },
1694
+ path: { type: 'string', description: 'Directory to search' },
1695
+ query: { type: 'string', description: 'Alias for pattern' },
1696
+ directory: { type: 'string', description: 'Alias for path' },
1697
+ include_hidden: { type: 'boolean', description: 'Include dotfiles' },
1698
+ max_results: { type: 'number', description: 'Max results' }
1699
+ },
1700
+ required: ['pattern']
1701
+ }
1702
+ }
1703
+ },
1907
1704
  {
1908
1705
  type: 'function',
1909
1706
  function: {
@@ -1975,6 +1772,25 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1975
1772
  }
1976
1773
  }
1977
1774
  },
1775
+ {
1776
+ type: 'function',
1777
+ function: {
1778
+ name: 'delete',
1779
+ description:
1780
+ 'Delete a file or directory inside the workspace. Use path, file, or file_path to point at the target. Missing targets fail. Workspace escape attempts are rejected.',
1781
+ parameters: {
1782
+ type: 'object',
1783
+ properties: {
1784
+ path: { type: 'string', description: 'File or directory path to delete' },
1785
+ file: { type: 'string', description: 'Alias for path' },
1786
+ file_path: { type: 'string', description: 'Alias for path' },
1787
+ directory: { type: 'string', description: 'Alias for path' },
1788
+ dir: { type: 'string', description: 'Alias for path' }
1789
+ },
1790
+ required: ['path']
1791
+ }
1792
+ }
1793
+ },
1978
1794
  {
1979
1795
  type: 'function',
1980
1796
  function: {
@@ -2071,26 +1887,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2071
1887
  }
2072
1888
  }
2073
1889
  },
2074
- glob: {
2075
- type: 'function',
2076
- function: {
2077
- name: 'glob',
2078
- description:
2079
- 'Find files by glob pattern. Use this when you already know a filename pattern such as src/**/*.ts. Aliases like query and directory are accepted.',
2080
- parameters: {
2081
- type: 'object',
2082
- properties: {
2083
- pattern: { type: 'string', description: 'Glob pattern' },
2084
- path: { type: 'string', description: 'Directory to search' },
2085
- query: { type: 'string', description: 'Alias for pattern' },
2086
- directory: { type: 'string', description: 'Alias for path' },
2087
- include_hidden: { type: 'boolean', description: 'Include dotfiles' },
2088
- max_results: { type: 'number', description: 'Max results' }
2089
- },
2090
- required: ['pattern']
2091
- }
2092
- }
2093
- },
2094
1890
  read_ast_node: {
2095
1891
  type: 'function',
2096
1892
  function: {
@@ -2108,36 +1904,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2108
1904
  }
2109
1905
  }
2110
1906
  },
2111
- generate_diff: {
2112
- type: 'function',
2113
- function: {
2114
- name: 'generate_diff',
2115
- description: 'Generate a unified diff for proposed content. Use this when you want to preview or prepare a patch before applying it.',
2116
- parameters: {
2117
- type: 'object',
2118
- properties: {
2119
- path: { type: 'string' },
2120
- new_content: { type: 'string' }
2121
- },
2122
- required: ['path', 'new_content']
2123
- }
2124
- }
2125
- },
2126
- patch: {
2127
- type: 'function',
2128
- function: {
2129
- name: 'patch',
2130
- description: 'Apply one or more unified diff hunks to workspace files. Use this for prepared unified diffs instead of ad-hoc shell patching.',
2131
- parameters: {
2132
- type: 'object',
2133
- properties: {
2134
- patch: { type: 'string' },
2135
- content: { type: 'string' }
2136
- },
2137
- required: ['patch']
2138
- }
2139
- }
2140
- },
2141
1907
  remember_user: {
2142
1908
  type: 'function',
2143
1909
  function: {
@@ -2365,24 +2131,27 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2365
2131
  if (result?.path) await refreshProjectFile(result.path);
2366
2132
  return result;
2367
2133
  },
2368
- generate_diff: (args) => generateDiff(workspaceRoot, args),
2369
- patch: async (args) => {
2134
+ write: async (args) => {
2370
2135
  await ensureProjectIndex();
2371
- const result = await applyPatch(workspaceRoot, args);
2136
+ const result = await writeFile(workspaceRoot, args);
2372
2137
  if (result?.path) await refreshProjectFile(result.path);
2373
- if (Array.isArray(result?.files)) {
2374
- for (const item of result.files) {
2375
- if (item?.path) await refreshProjectFile(item.path);
2376
- }
2377
- }
2378
2138
  return result;
2379
2139
  },
2380
- write: async (args) => {
2140
+ delete: Object.assign(async (args) => {
2381
2141
  await ensureProjectIndex();
2382
- const result = await writeFile(workspaceRoot, args);
2142
+ const result = await deletePath(workspaceRoot, args);
2383
2143
  if (result?.path) await refreshProjectFile(result.path);
2384
2144
  return result;
2385
- },
2145
+ }, {
2146
+ prepareApproval: async (args) => {
2147
+ const target = await prepareDeleteTarget(workspaceRoot, args);
2148
+ return {
2149
+ path: target.path,
2150
+ name: target.name,
2151
+ type: target.type
2152
+ };
2153
+ }
2154
+ }),
2386
2155
  update_todos: async (args = {}) => {
2387
2156
  const oldTodos = normalizeTodos(typeof getTodos === 'function' ? getTodos() : []);
2388
2157
  const nextTodos = normalizeTodos(args?.todos);
@@ -2485,7 +2254,8 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2485
2254
  }
2486
2255
  // Phase 2 content: structured header + head/tail content
2487
2256
  if (result.phase === 'content') {
2488
- const header = `[File: ${result.path}, lines ${result.start_line || 1}-${result.end_line || '?'}${result.total_lines ? ` of ${result.total_lines}` : ''}${result.truncated ? ', truncated' : ''}]`;
2257
+ const enclosing = result.enclosing_symbol ? `, inside ${result.enclosing_kind || 'symbol'} ${result.enclosing_symbol}` : '';
2258
+ const header = `[File: ${result.path}, lines ${result.start_line || 1}-${result.end_line || '?'}${result.total_lines ? ` of ${result.total_lines}` : ''}${result.truncated ? ', truncated' : ''}${enclosing}]`;
2489
2259
  const content = result.content || '';
2490
2260
  if (typeof content !== 'string' || content.length <= 3000) {
2491
2261
  return `${header}\n${content}`;
@@ -2597,6 +2367,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2597
2367
  return summary;
2598
2368
  },
2599
2369
 
2370
+ delete(result) {
2371
+ if (!result || typeof result !== 'object') return String(result);
2372
+ if (result.ok === false) return JSON.stringify(result);
2373
+ const kind = result.type || 'item';
2374
+ const target = result.path || '';
2375
+ return `[delete: ${kind}] deleted ${target}`;
2376
+ },
2377
+
2600
2378
  run(result) {
2601
2379
  if (!result || typeof result !== 'object') return String(result);
2602
2380
  if (result.background) {
@@ -2650,25 +2428,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2650
2428
  return `removed ${Number(result?.removed || 0)} memory item(s)`;
2651
2429
  },
2652
2430
 
2653
- generate_diff(result) {
2654
- if (!result || typeof result !== 'object') return String(result);
2655
- const p = result.path || '';
2656
- const diff = result.diff || '';
2657
- if (diff.length <= 2000) return `${p ? `[${p}]\n` : ''}${diff}`;
2658
- return `${p ? `[${p}]\n` : ''}${diff.slice(0, 1997)}...\n[diff truncated: ${diff.length} chars total]`;
2659
- },
2660
-
2661
- patch(result) {
2662
- if (!result || typeof result !== 'object') return String(result);
2663
- if (Array.isArray(result.files)) {
2664
- const names = result.files.slice(0, 10).map((f) => typeof f === 'string' ? f : f.path || '?');
2665
- return `patched ${result.files.length} file(s): ${names.join(', ')}${result.files.length > 10 ? ` ... +${result.files.length - 10} more` : ''}`;
2666
- }
2667
- const p = result.path || '';
2668
- const line = result.changed_line || 0;
2669
- return `patched ${p}${line > 0 ? ` @L${line}` : ''}${result.ok === false ? ` [FAILED: ${result.error || ''}]` : ''}`;
2670
- },
2671
-
2672
2431
  ast_query(result) {
2673
2432
  if (!result || typeof result !== 'object') return String(result);
2674
2433
  if (!Array.isArray(result.matches)) return JSON.stringify(result);