codemini-cli 0.3.4 → 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');
@@ -1843,7 +1620,7 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1843
1620
  function: {
1844
1621
  name: 'read',
1845
1622
  description:
1846
- 'Inspect a file and return content directly by default. Demo-style aliases like file_path, offset, and limit are accepted. Use metadata_only=true only when you want file metadata without content. Do not use run with cat, head, or tail for file reads.',
1623
+ 'Inspect code or text files. Use read(path) for normal file or line-window reads, read(ast_target=...) for a node-scoped AST read, and read(path, query=..., capture_name=...) to run an inline Tree-sitter query before returning the first matched node. Prefer the AST forms when targeting a function, class, or method and you want tighter context. Demo-style aliases like file_path, offset, and limit are accepted. Use metadata_only=true only when you want file metadata without content. Do not use run with cat, head, or tail for file reads.',
1847
1624
  parameters: {
1848
1625
  type: 'object',
1849
1626
  properties: {
@@ -1856,7 +1633,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1856
1633
  max_chars: { type: 'number', description: 'Max chars to return' },
1857
1634
  include_content: { type: 'boolean', description: 'Legacy compatibility flag. Content is returned by default.' },
1858
1635
  read_token: { type: 'string', description: 'Legacy compatibility token. No longer required for content reads.' },
1859
- metadata_only: { type: 'boolean', description: 'Set true to return metadata without content.' }
1636
+ metadata_only: { type: 'boolean', description: 'Set true to return metadata without content.' },
1637
+ ast_target: { type: 'object', description: 'AST target from ast_query or a prior AST selection. When provided, read returns that node instead of a line window.' },
1638
+ query: { type: 'string', description: 'Optional Tree-sitter query to run inline before reading the first matched AST node. Use with path for one-shot function/class/method reads.' },
1639
+ capture_name: { type: 'string', description: 'Optional capture name to select when query is provided.' },
1640
+ language: { type: 'string', description: 'Optional Tree-sitter language override for AST reads or inline queries.' }
1860
1641
  },
1861
1642
  required: []
1862
1643
  }
@@ -1900,6 +1681,26 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1900
1681
  }
1901
1682
  }
1902
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
+ },
1903
1704
  {
1904
1705
  type: 'function',
1905
1706
  function: {
@@ -1923,7 +1724,7 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1923
1724
  function: {
1924
1725
  name: 'edit',
1925
1726
  description:
1926
- 'Edit existing files. Prefer one of these shapes: 1) {file, old_text, new_text} for exact text replacement, 2) {file, symbol, edit:{kind:"replace_block", new_content:"..."}} for block replacement, 3) {file, anchor_text, position:"before"|"after", content:"..."} for inserts. Demo-style aliases {file_path, old_string, new_string} are also accepted. Read first unless the exact target is already known. Prefer this over write for existing code changes.',
1727
+ 'Edit existing files. Prefer one of these shapes: 1) {file, old_text, new_text} for exact text replacement, 2) {file, symbol, edit:{kind:"replace_block", new_content:"..."}} for block replacement, 3) {file, anchor_text, position:"before"|"after", content:"..."} for inserts. Demo-style aliases {file_path, old_string, new_string} are also accepted. Read first unless the exact target is already known, and prefer read(ast_target=...) or read(path, query=...) before symbol- or block-level edits when you want tighter context. Prefer this over write for existing code changes.',
1927
1728
  parameters: {
1928
1729
  type: 'object',
1929
1730
  properties: {
@@ -1971,6 +1772,25 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1971
1772
  }
1972
1773
  }
1973
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
+ },
1974
1794
  {
1975
1795
  type: 'function',
1976
1796
  function: {
@@ -2053,7 +1873,7 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2053
1873
  function: {
2054
1874
  name: 'ast_query',
2055
1875
  description:
2056
- 'Run a Tree-sitter query on a code file and return ast_target objects. Use this when you need node-scoped reads or edits for functions, classes, or methods.',
1876
+ 'Run a Tree-sitter query on a code file and return ast_target objects. Use this for advanced AST workflows such as multi-match selection, explicit node caching, or when you plan to reuse ast_target across follow-up reads or edits. For a common one-shot function, class, or method read, prefer read(path, query=...) or read(ast_target=...).',
2057
1877
  parameters: {
2058
1878
  type: 'object',
2059
1879
  properties: {
@@ -2067,32 +1887,12 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2067
1887
  }
2068
1888
  }
2069
1889
  },
2070
- glob: {
2071
- type: 'function',
2072
- function: {
2073
- name: 'glob',
2074
- description:
2075
- '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.',
2076
- parameters: {
2077
- type: 'object',
2078
- properties: {
2079
- pattern: { type: 'string', description: 'Glob pattern' },
2080
- path: { type: 'string', description: 'Directory to search' },
2081
- query: { type: 'string', description: 'Alias for pattern' },
2082
- directory: { type: 'string', description: 'Alias for path' },
2083
- include_hidden: { type: 'boolean', description: 'Include dotfiles' },
2084
- max_results: { type: 'number', description: 'Max results' }
2085
- },
2086
- required: ['pattern']
2087
- }
2088
- }
2089
- },
2090
1890
  read_ast_node: {
2091
1891
  type: 'function',
2092
1892
  function: {
2093
1893
  name: 'read_ast_node',
2094
1894
  description:
2095
- 'Read a previously selected AST node with compact structural context. Use this after ast_query before a scoped structural edit.',
1895
+ 'Read a previously selected AST node with compact structural context. Use this after ast_query when you want an explicit follow-up read of a cached node before a scoped structural edit. For common one-shot AST reads, prefer read(ast_target=...) or read(path, query=...).',
2096
1896
  parameters: {
2097
1897
  type: 'object',
2098
1898
  properties: {
@@ -2104,36 +1904,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2104
1904
  }
2105
1905
  }
2106
1906
  },
2107
- generate_diff: {
2108
- type: 'function',
2109
- function: {
2110
- name: 'generate_diff',
2111
- description: 'Generate a unified diff for proposed content. Use this when you want to preview or prepare a patch before applying it.',
2112
- parameters: {
2113
- type: 'object',
2114
- properties: {
2115
- path: { type: 'string' },
2116
- new_content: { type: 'string' }
2117
- },
2118
- required: ['path', 'new_content']
2119
- }
2120
- }
2121
- },
2122
- patch: {
2123
- type: 'function',
2124
- function: {
2125
- name: 'patch',
2126
- description: 'Apply one or more unified diff hunks to workspace files. Use this for prepared unified diffs instead of ad-hoc shell patching.',
2127
- parameters: {
2128
- type: 'object',
2129
- properties: {
2130
- patch: { type: 'string' },
2131
- content: { type: 'string' }
2132
- },
2133
- required: ['patch']
2134
- }
2135
- }
2136
- },
2137
1907
  remember_user: {
2138
1908
  type: 'function',
2139
1909
  function: {
@@ -2274,19 +2044,63 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2274
2044
  const definitions = [...primaryDefinitions];
2275
2045
 
2276
2046
  const handlers = {
2277
- read: (args) =>
2278
- readFile(workspaceRoot, {
2047
+ read: async (args) => {
2048
+ const inlineQuery = String(args?.query || '').trim();
2049
+ const directAstTarget = args?.ast_target;
2050
+
2051
+ if (directAstTarget) {
2052
+ const result = await readAstNode(workspaceRoot, {
2053
+ ...args,
2054
+ path: args?.path || directAstTarget?.path,
2055
+ ast_target: directAstTarget
2056
+ });
2057
+ if (directAstTarget?.path) rememberAstSelection(directAstTarget.path, directAstTarget);
2058
+ const readPath = String(result?.path || directAstTarget?.path || '').trim();
2059
+ if (readPath) lastReadPath = readPath;
2060
+ return result;
2061
+ }
2062
+
2063
+ if (inlineQuery) {
2064
+ const queryResult = await queryAst(workspaceRoot, args);
2065
+ const firstTarget = queryResult?.matches?.[0]?.ast_target;
2066
+ if (!firstTarget) {
2067
+ return {
2068
+ path: String(args?.path || '').trim(),
2069
+ language: queryResult?.language,
2070
+ query: inlineQuery,
2071
+ capture_name: String(args?.capture_name || '').trim() || undefined,
2072
+ matches: 0,
2073
+ content: ''
2074
+ };
2075
+ }
2076
+ rememberAstSelection(firstTarget.path, firstTarget);
2077
+ const result = await readAstNode(workspaceRoot, {
2078
+ ...args,
2079
+ path: firstTarget.path,
2080
+ ast_target: firstTarget
2081
+ });
2082
+ const readPath = String(result?.path || firstTarget?.path || '').trim();
2083
+ if (readPath) lastReadPath = readPath;
2084
+ return {
2085
+ ...result,
2086
+ query: inlineQuery,
2087
+ capture_name: String(args?.capture_name || '').trim() || undefined,
2088
+ matches: queryResult.matches.length
2089
+ };
2090
+ }
2091
+
2092
+ const result = await readFile(workspaceRoot, {
2279
2093
  ...args,
2280
2094
  default_lines: config.context?.read_file_default_lines ?? 220,
2281
2095
  max_chars:
2282
2096
  typeof args?.max_chars === 'number'
2283
2097
  ? args.max_chars
2284
2098
  : config.context?.read_file_max_chars ?? 24000
2285
- }).then((result) => {
2286
- const readPath = String(result?.path || args?.path || '').trim();
2287
- if (readPath) lastReadPath = readPath;
2288
- return result;
2289
- }),
2099
+ });
2100
+ const readPath = String(result?.path || args?.path || '').trim();
2101
+ if (readPath) lastReadPath = readPath;
2102
+ return result;
2103
+ },
2290
2104
  query_project_index: async (args) => {
2291
2105
  await ensureProjectIndex();
2292
2106
  return queryProjectIndex(workspaceRoot, args);
@@ -2317,24 +2131,27 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2317
2131
  if (result?.path) await refreshProjectFile(result.path);
2318
2132
  return result;
2319
2133
  },
2320
- generate_diff: (args) => generateDiff(workspaceRoot, args),
2321
- patch: async (args) => {
2134
+ write: async (args) => {
2322
2135
  await ensureProjectIndex();
2323
- const result = await applyPatch(workspaceRoot, args);
2136
+ const result = await writeFile(workspaceRoot, args);
2324
2137
  if (result?.path) await refreshProjectFile(result.path);
2325
- if (Array.isArray(result?.files)) {
2326
- for (const item of result.files) {
2327
- if (item?.path) await refreshProjectFile(item.path);
2328
- }
2329
- }
2330
2138
  return result;
2331
2139
  },
2332
- write: async (args) => {
2140
+ delete: Object.assign(async (args) => {
2333
2141
  await ensureProjectIndex();
2334
- const result = await writeFile(workspaceRoot, args);
2142
+ const result = await deletePath(workspaceRoot, args);
2335
2143
  if (result?.path) await refreshProjectFile(result.path);
2336
2144
  return result;
2337
- },
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
+ }),
2338
2155
  update_todos: async (args = {}) => {
2339
2156
  const oldTodos = normalizeTodos(typeof getTodos === 'function' ? getTodos() : []);
2340
2157
  const nextTodos = normalizeTodos(args?.todos);
@@ -2427,13 +2244,18 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2427
2244
  read(result) {
2428
2245
  if (typeof result === 'string') return result;
2429
2246
  if (!result || typeof result !== 'object') return String(result);
2247
+ if (result.node && typeof result.content === 'string') {
2248
+ const header = `[AST: ${result.path || '?'} ${result.node.node_type || 'node'} ${result.node.start_line || '?'}-${result.node.end_line || '?'}${result.matches ? `, matches ${result.matches}` : ''}]`;
2249
+ return `${header}\n${result.content}`;
2250
+ }
2430
2251
  // Phase 1 metadata: small, return as-is
2431
2252
  if (result.phase === 'metadata') {
2432
2253
  return JSON.stringify(result);
2433
2254
  }
2434
2255
  // Phase 2 content: structured header + head/tail content
2435
2256
  if (result.phase === 'content') {
2436
- 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}]`;
2437
2259
  const content = result.content || '';
2438
2260
  if (typeof content !== 'string' || content.length <= 3000) {
2439
2261
  return `${header}\n${content}`;
@@ -2545,6 +2367,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2545
2367
  return summary;
2546
2368
  },
2547
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
+
2548
2378
  run(result) {
2549
2379
  if (!result || typeof result !== 'object') return String(result);
2550
2380
  if (result.background) {
@@ -2598,25 +2428,6 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2598
2428
  return `removed ${Number(result?.removed || 0)} memory item(s)`;
2599
2429
  },
2600
2430
 
2601
- generate_diff(result) {
2602
- if (!result || typeof result !== 'object') return String(result);
2603
- const p = result.path || '';
2604
- const diff = result.diff || '';
2605
- if (diff.length <= 2000) return `${p ? `[${p}]\n` : ''}${diff}`;
2606
- return `${p ? `[${p}]\n` : ''}${diff.slice(0, 1997)}...\n[diff truncated: ${diff.length} chars total]`;
2607
- },
2608
-
2609
- patch(result) {
2610
- if (!result || typeof result !== 'object') return String(result);
2611
- if (Array.isArray(result.files)) {
2612
- const names = result.files.slice(0, 10).map((f) => typeof f === 'string' ? f : f.path || '?');
2613
- return `patched ${result.files.length} file(s): ${names.join(', ')}${result.files.length > 10 ? ` ... +${result.files.length - 10} more` : ''}`;
2614
- }
2615
- const p = result.path || '';
2616
- const line = result.changed_line || 0;
2617
- return `patched ${p}${line > 0 ? ` @L${line}` : ''}${result.ok === false ? ` [FAILED: ${result.error || ''}]` : ''}`;
2618
- },
2619
-
2620
2431
  ast_query(result) {
2621
2432
  if (!result || typeof result !== 'object') return String(result);
2622
2433
  if (!Array.isArray(result.matches)) return JSON.stringify(result);