codemini-cli 0.6.3 → 0.6.4

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.
Files changed (48) hide show
  1. package/codemini-web/dist/assets/{AboutDialog-jgqGjQgl.js → AboutDialog-MRopwNIL.js} +2 -2
  2. package/codemini-web/dist/assets/CodeWikiPanel-UpK5xGE3.js +1 -0
  3. package/codemini-web/dist/assets/ConfigDialog-CNl28wsj.js +1 -0
  4. package/codemini-web/dist/assets/GitDiffDialog-gSysUg2J.js +3 -0
  5. package/codemini-web/dist/assets/{MemoryDialog-BhxQgG0I.js → MemoryDialog-DFUmo3Kl.js} +3 -3
  6. package/codemini-web/dist/assets/MessageBubble-CGnnViv0.js +12 -0
  7. package/codemini-web/dist/assets/PatchDiff-B8rwvEg5.js +230 -0
  8. package/codemini-web/dist/assets/ProjectSelector-BF59M1zb.js +1 -0
  9. package/codemini-web/dist/assets/{SkillDialog-DxS43NpR.js → SkillDialog-CQTjbSiw.js} +4 -4
  10. package/codemini-web/dist/assets/SoulDialog-BLjUGqqB.js +1 -0
  11. package/codemini-web/dist/assets/chevron-right--85xg7qk.js +1 -0
  12. package/codemini-web/dist/assets/{chunk-BO2N2NFS-Budy_hfO.js → chunk-BO2N2NFS-6uELoidu.js} +6 -6
  13. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-CQS1PAvD.js → highlighted-body-OFNGDK62-gb1UMBZ5.js} +1 -1
  14. package/codemini-web/dist/assets/index-1xqD0R5t.css +2 -0
  15. package/codemini-web/dist/assets/index-CDXQGwPs.js +65 -0
  16. package/codemini-web/dist/assets/{input-CNQgbKe6.js → input-Ca8O_061.js} +1 -1
  17. package/codemini-web/dist/assets/lib-BXWizt13.js +1 -0
  18. package/codemini-web/dist/assets/mermaid-GHXKKRXX-ROliF8Yd.js +1 -0
  19. package/codemini-web/dist/assets/{pencil-Ce_LFiEh.js → pencil-BhT11Ztp.js} +1 -1
  20. package/codemini-web/dist/assets/{refresh-cw-BKL-AZu5.js → refresh-cw-D7R5Lth6.js} +1 -1
  21. package/codemini-web/dist/assets/select-DBvcHBzs.js +1 -0
  22. package/codemini-web/dist/assets/{trash-2-KmAlCwXd.js → trash-2-BfNZcWfX.js} +1 -1
  23. package/codemini-web/dist/index.html +2 -2
  24. package/codemini-web/lib/runtime-bridge.js +325 -296
  25. package/codemini-web/server.js +310 -243
  26. package/package.json +1 -1
  27. package/src/core/agent-loop.js +188 -97
  28. package/src/core/chat-runtime.js +674 -571
  29. package/src/core/config-store.js +11 -3
  30. package/src/core/git-oplog-change-tracker.js +387 -0
  31. package/src/core/non-git-backup.js +116 -0
  32. package/src/core/paths.js +123 -123
  33. package/src/core/session-store.js +148 -99
  34. package/src/core/tools.js +499 -456
  35. package/src/tui/chat-app.js +196 -56
  36. package/codemini-web/dist/assets/CodeWikiPanel-EPuoerNv.js +0 -1
  37. package/codemini-web/dist/assets/ConfigDialog-B5IGZCc9.js +0 -1
  38. package/codemini-web/dist/assets/GitDiffDialog-Bb_Tw5ZK.js +0 -222
  39. package/codemini-web/dist/assets/MessageBubble-wUff4GP4.js +0 -6
  40. package/codemini-web/dist/assets/ProjectSelector-C0leTf6f.js +0 -1
  41. package/codemini-web/dist/assets/SoulDialog-XDTEGWvH.js +0 -1
  42. package/codemini-web/dist/assets/chevron-right-Dbzw7YzA.js +0 -1
  43. package/codemini-web/dist/assets/index-D0EGtNPr.js +0 -65
  44. package/codemini-web/dist/assets/index-wOUf3WkN.css +0 -2
  45. package/codemini-web/dist/assets/lib-BOngVP_M.js +0 -11
  46. package/codemini-web/dist/assets/lib-DrOTTm_N.js +0 -1
  47. package/codemini-web/dist/assets/mermaid-GHXKKRXX-DrBu5KyC.js +0 -1
  48. package/codemini-web/dist/assets/select-BZXfigic.js +0 -1
package/src/core/tools.js CHANGED
@@ -30,13 +30,13 @@ import {
30
30
  sanitizeTextForModel,
31
31
  summarizeRunOutput
32
32
  } from './tool-output.js';
33
- import {
34
- normalizeFilePathValue,
35
- normalizePathArgs,
36
- parseInlineRangePath,
37
- normalizePatternArgs,
38
- normalizeReadArgs,
39
- normalizeWebFetchArgs,
33
+ import {
34
+ normalizeFilePathValue,
35
+ normalizePathArgs,
36
+ parseInlineRangePath,
37
+ normalizePatternArgs,
38
+ normalizeReadArgs,
39
+ normalizeWebFetchArgs,
40
40
  normalizeWebSearchArgs,
41
41
  normalizeWriteArgs
42
42
  } from './tool-args.js';
@@ -150,64 +150,64 @@ function trimLinePreview(line, maxLen = 180) {
150
150
  return `${text.slice(0, maxLen - 3)}...`;
151
151
  }
152
152
 
153
- function splitLines(text) {
154
- return String(text || '').split('\n');
155
- }
156
-
157
- function buildDiffPreview(beforeContent, afterContent) {
158
- const beforeLines = splitLines(beforeContent);
159
- const afterLines = splitLines(afterContent);
160
- let prefix = 0;
161
- while (
162
- prefix < beforeLines.length &&
163
- prefix < afterLines.length &&
164
- beforeLines[prefix] === afterLines[prefix]
165
- ) {
166
- prefix += 1;
167
- }
168
-
169
- let beforeEnd = beforeLines.length - 1;
170
- let afterEnd = afterLines.length - 1;
171
- while (
172
- beforeEnd >= prefix &&
173
- afterEnd >= prefix &&
174
- beforeLines[beforeEnd] === afterLines[afterEnd]
175
- ) {
176
- beforeEnd -= 1;
177
- afterEnd -= 1;
178
- }
179
-
180
- const lines = [];
181
- for (let i = prefix; i <= beforeEnd; i += 1) {
182
- lines.push(`-${i + 1}| ${beforeLines[i]}`);
183
- }
184
- for (let i = prefix; i <= afterEnd; i += 1) {
185
- lines.push(`+${i + 1}| ${afterLines[i]}`);
186
- }
187
- return lines.join('\n');
188
- }
189
-
190
- function clampNumber(value, min, max, fallback) {
191
- const num = Number(value);
192
- if (!Number.isFinite(num)) return fallback;
153
+ function splitLines(text) {
154
+ return String(text || '').split('\n');
155
+ }
156
+
157
+ function buildDiffPreview(beforeContent, afterContent) {
158
+ const beforeLines = splitLines(beforeContent);
159
+ const afterLines = splitLines(afterContent);
160
+ let prefix = 0;
161
+ while (
162
+ prefix < beforeLines.length &&
163
+ prefix < afterLines.length &&
164
+ beforeLines[prefix] === afterLines[prefix]
165
+ ) {
166
+ prefix += 1;
167
+ }
168
+
169
+ let beforeEnd = beforeLines.length - 1;
170
+ let afterEnd = afterLines.length - 1;
171
+ while (
172
+ beforeEnd >= prefix &&
173
+ afterEnd >= prefix &&
174
+ beforeLines[beforeEnd] === afterLines[afterEnd]
175
+ ) {
176
+ beforeEnd -= 1;
177
+ afterEnd -= 1;
178
+ }
179
+
180
+ const lines = [];
181
+ for (let i = prefix; i <= beforeEnd; i += 1) {
182
+ lines.push(`-${i + 1}| ${beforeLines[i]}`);
183
+ }
184
+ for (let i = prefix; i <= afterEnd; i += 1) {
185
+ lines.push(`+${i + 1}| ${afterLines[i]}`);
186
+ }
187
+ return lines.join('\n');
188
+ }
189
+
190
+ function clampNumber(value, min, max, fallback) {
191
+ const num = Number(value);
192
+ if (!Number.isFinite(num)) return fallback;
193
193
  return Math.min(max, Math.max(min, num));
194
194
  }
195
195
 
196
- function normalizeWhitespace(value) {
197
- return String(value || '')
198
- .replace(/\s+/g, ' ')
199
- .trim();
200
- }
201
-
202
- function semanticBoolean(value, fallback = false) {
203
- if (typeof value === 'boolean') return value;
204
- if (typeof value === 'number') return value !== 0;
205
- const text = String(value ?? '').trim().toLowerCase();
206
- if (!text) return fallback;
207
- if (['true', '1', 'yes', 'y', 'on'].includes(text)) return true;
208
- if (['false', '0', 'no', 'n', 'off'].includes(text)) return false;
209
- return Boolean(value);
210
- }
196
+ function normalizeWhitespace(value) {
197
+ return String(value || '')
198
+ .replace(/\s+/g, ' ')
199
+ .trim();
200
+ }
201
+
202
+ function semanticBoolean(value, fallback = false) {
203
+ if (typeof value === 'boolean') return value;
204
+ if (typeof value === 'number') return value !== 0;
205
+ const text = String(value ?? '').trim().toLowerCase();
206
+ if (!text) return fallback;
207
+ if (['true', '1', 'yes', 'y', 'on'].includes(text)) return true;
208
+ if (['false', '0', 'no', 'n', 'off'].includes(text)) return false;
209
+ return Boolean(value);
210
+ }
211
211
 
212
212
  function trimPreview(value, maxLen = 300) {
213
213
  const text = normalizeWhitespace(value);
@@ -900,7 +900,7 @@ async function getFileState(root, relativePath, config = {}) {
900
900
  };
901
901
  }
902
902
 
903
- async function readFile(root, args, config = {}) {
903
+ async function readFile(root, args, config = {}) {
904
904
  const normalizedArgs = normalizeReadArgs(args);
905
905
  const target = await resolveInWorkspace(root, normalizedArgs?.path, config);
906
906
  const stat = await fs.stat(target);
@@ -979,21 +979,21 @@ async function readFile(root, args, config = {}) {
979
979
  truncated,
980
980
  content,
981
981
  ...(enclosing ? { enclosing_symbol: enclosing.name, enclosing_kind: enclosing.kind, enclosing_line: enclosing.start_line } : {})
982
- };
983
- }
984
-
985
- async function writeFile(root, args, config = {}) {
986
- const normalizedArgs = normalizeWriteArgs(args);
987
- const rawPath = String(normalizedArgs?.path || '').trim();
982
+ };
983
+ }
984
+
985
+ async function writeFile(root, args, config = {}) {
986
+ const normalizedArgs = normalizeWriteArgs(args);
987
+ const rawPath = String(normalizedArgs?.path || '').trim();
988
988
  if (!rawPath) {
989
989
  throw new Error('write requires a file path like weather/WeatherForecast.js');
990
990
  }
991
- if (rawPath === '.' || rawPath === './') {
992
- throw new Error('write requires a file path, not the workspace root');
993
- }
994
- if (normalizedArgs?.content == null) {
995
- throw new Error('write requires content. For existing files, use edit with old_text/new_text or pass content with full_file_rewrite=true.');
996
- }
991
+ if (rawPath === '.' || rawPath === './') {
992
+ throw new Error('write requires a file path, not the workspace root');
993
+ }
994
+ if (normalizedArgs?.content == null) {
995
+ throw new Error('write requires content. For existing files, use edit with old_text/new_text or pass content with full_file_rewrite=true.');
996
+ }
997
997
  const target = await resolveInWorkspace(root, rawPath, config);
998
998
  try {
999
999
  const stat = await fs.stat(target);
@@ -1007,33 +1007,33 @@ async function writeFile(root, args, config = {}) {
1007
1007
  let existed = true;
1008
1008
  try {
1009
1009
  before = await fs.readFile(target, 'utf8');
1010
- } catch {
1011
- existed = false;
1012
- }
1013
- const nextContent = String(normalizedArgs.content ?? '');
1014
- if (existed && before === nextContent && !normalizedArgs?.append) {
1015
- return {
1016
- ok: true,
1017
- path: rawPath,
1018
- action: 'unchanged',
1019
- changed_line: 1,
1020
- diff_preview: '',
1021
- lines_added: 0,
1022
- lines_removed: 0
1023
- };
1024
- }
1025
- if (existed && !normalizedArgs?.append && !normalizedArgs?.full_file_rewrite) {
1026
- throw new Error(
1027
- `write target exists: ${rawPath}. Use edit for source changes, append=true to append, or full_file_rewrite=true to replace the whole file.`
1028
- );
1029
- }
1030
- await fs.mkdir(path.dirname(target), { recursive: true });
1031
- if (normalizedArgs?.append) {
1032
- await fs.appendFile(target, nextContent, 'utf8');
1033
- } else {
1034
- await fs.writeFile(target, nextContent, 'utf8');
1035
- }
1036
- const after = normalizedArgs?.append ? `${before}${nextContent}` : nextContent;
1010
+ } catch {
1011
+ existed = false;
1012
+ }
1013
+ const nextContent = String(normalizedArgs.content ?? '');
1014
+ if (existed && before === nextContent && !normalizedArgs?.append) {
1015
+ return {
1016
+ ok: true,
1017
+ path: rawPath,
1018
+ action: 'unchanged',
1019
+ changed_line: 1,
1020
+ diff_preview: '',
1021
+ lines_added: 0,
1022
+ lines_removed: 0
1023
+ };
1024
+ }
1025
+ if (existed && !normalizedArgs?.append && !normalizedArgs?.full_file_rewrite) {
1026
+ throw new Error(
1027
+ `write target exists: ${rawPath}. Use edit for source changes, append=true to append, or full_file_rewrite=true to replace the whole file.`
1028
+ );
1029
+ }
1030
+ await fs.mkdir(path.dirname(target), { recursive: true });
1031
+ if (normalizedArgs?.append) {
1032
+ await fs.appendFile(target, nextContent, 'utf8');
1033
+ } else {
1034
+ await fs.writeFile(target, nextContent, 'utf8');
1035
+ }
1036
+ const after = normalizedArgs?.append ? `${before}${nextContent}` : nextContent;
1037
1037
  const beforeLines = splitLines(before);
1038
1038
  const afterLines = splitLines(after);
1039
1039
  let changeLine = 0;
@@ -1044,17 +1044,17 @@ async function writeFile(root, args, config = {}) {
1044
1044
  break;
1045
1045
  }
1046
1046
  }
1047
- const changed = countChangedLines(before, after);
1048
- return {
1049
- ok: true,
1050
- path: rawPath,
1051
- action: normalizedArgs?.append ? 'append' : existed ? 'overwrite' : 'create',
1052
- changed_line: changeLine || Math.max(1, afterLines.length),
1053
- diff_preview: buildDiffPreview(before, after),
1054
- lines_added: changed.added,
1055
- lines_removed: changed.removed
1056
- };
1057
- }
1047
+ const changed = countChangedLines(before, after);
1048
+ return {
1049
+ ok: true,
1050
+ path: rawPath,
1051
+ action: normalizedArgs?.append ? 'append' : existed ? 'overwrite' : 'create',
1052
+ changed_line: changeLine || Math.max(1, afterLines.length),
1053
+ diff_preview: buildDiffPreview(before, after),
1054
+ lines_added: changed.added,
1055
+ lines_removed: changed.removed
1056
+ };
1057
+ }
1058
1058
 
1059
1059
  async function prepareDeleteTarget(root, args, config = {}) {
1060
1060
  const normalizedArgs = normalizePathArgs(args, ['file', 'file_path', 'target', 'directory', 'dir']);
@@ -1452,8 +1452,8 @@ async function stopBackgroundTask(_root, args) {
1452
1452
  return { ...snapshotBackgroundTask(task), stopped: true };
1453
1453
  }
1454
1454
 
1455
- async function builtinGrep(root, args, config = {}) {
1456
- const normalizedArgs = normalizePatternArgs(args, ['query', 'symbol', 'q'], ['directory', 'dir', 'cwd', 'file_path', 'file']);
1455
+ async function builtinGrep(root, args, config = {}) {
1456
+ const normalizedArgs = normalizePatternArgs(args, ['query', 'symbol', 'q'], ['directory', 'dir', 'cwd', 'file_path', 'file']);
1457
1457
  const pattern = String(normalizedArgs?.pattern || '').trim();
1458
1458
  if (!pattern) throw new Error('grep requires pattern');
1459
1459
  const maxResults = Math.max(1, Math.min(200, Number(normalizedArgs?.max_results || 50)));
@@ -1487,8 +1487,8 @@ async function builtinGrep(root, args, config = {}) {
1487
1487
  return { pattern, matches, truncated: false };
1488
1488
  }
1489
1489
 
1490
- async function builtinGlob(root, args, config = {}) {
1491
- const normalizedArgs = normalizePatternArgs(args, ['glob', 'query'], ['directory', 'dir', 'cwd', 'file_path', 'file']);
1490
+ async function builtinGlob(root, args, config = {}) {
1491
+ const normalizedArgs = normalizePatternArgs(args, ['glob', 'query'], ['directory', 'dir', 'cwd', 'file_path', 'file']);
1492
1492
  const pattern = String(normalizedArgs?.pattern || '').trim();
1493
1493
  if (!pattern) throw new Error('glob requires pattern');
1494
1494
  const maxResults = Math.max(1, Math.min(500, Number(normalizedArgs?.max_results || 200)));
@@ -1508,8 +1508,8 @@ async function builtinGlob(root, args, config = {}) {
1508
1508
  };
1509
1509
  }
1510
1510
 
1511
- async function builtinList(root, args, config = {}) {
1512
- const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'file_path', 'file', 'target']);
1511
+ async function builtinList(root, args, config = {}) {
1512
+ const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'file_path', 'file', 'target']);
1513
1513
  const relativePath = String(normalizedArgs?.path || '.').trim() || '.';
1514
1514
  const target = await resolveInWorkspace(root, relativePath, config);
1515
1515
  const entries = await fs.readdir(target, { withFileTypes: true });
@@ -1649,10 +1649,10 @@ function countChangedLines(beforeContent, afterContent) {
1649
1649
  return { added: n - lcsLen, removed: m - lcsLen };
1650
1650
  }
1651
1651
 
1652
- function editResult(pathText, action, beforeContent, afterContent, changedLine = 1) {
1653
- const diffPreview = buildDiffPreview(beforeContent, afterContent);
1654
- const changed = countChangedLines(beforeContent, afterContent);
1655
- return {
1652
+ function editResult(pathText, action, beforeContent, afterContent, changedLine = 1) {
1653
+ const diffPreview = buildDiffPreview(beforeContent, afterContent);
1654
+ const changed = countChangedLines(beforeContent, afterContent);
1655
+ return {
1656
1656
  ok: true,
1657
1657
  path: pathText,
1658
1658
  action,
@@ -1662,26 +1662,26 @@ function editResult(pathText, action, beforeContent, afterContent, changedLine =
1662
1662
  lines_added: changed.added,
1663
1663
  lines_removed: changed.removed
1664
1664
  };
1665
- }
1666
-
1667
- function lineRangeToOffsets(content, startLineRaw, endLineRaw) {
1668
- const lines = splitLines(content);
1669
- const totalLines = lines.length;
1670
- const startLine = Math.max(1, Math.min(totalLines, Number(startLineRaw) || 1));
1671
- const endLine = Math.max(startLine, Math.min(totalLines, Number(endLineRaw) || startLine));
1672
- let startOffset = 0;
1673
- for (let i = 1; i < startLine; i += 1) {
1674
- startOffset += lines[i - 1].length + 1;
1675
- }
1676
- let endOffset = startOffset;
1677
- for (let i = startLine; i <= endLine; i += 1) {
1678
- endOffset += lines[i - 1].length;
1679
- if (i < endLine) endOffset += 1;
1680
- }
1681
- return { startLine, endLine, startOffset, endOffset };
1682
- }
1683
-
1684
- async function replaceBlock(root, args, config = {}) {
1665
+ }
1666
+
1667
+ function lineRangeToOffsets(content, startLineRaw, endLineRaw) {
1668
+ const lines = splitLines(content);
1669
+ const totalLines = lines.length;
1670
+ const startLine = Math.max(1, Math.min(totalLines, Number(startLineRaw) || 1));
1671
+ const endLine = Math.max(startLine, Math.min(totalLines, Number(endLineRaw) || startLine));
1672
+ let startOffset = 0;
1673
+ for (let i = 1; i < startLine; i += 1) {
1674
+ startOffset += lines[i - 1].length + 1;
1675
+ }
1676
+ let endOffset = startOffset;
1677
+ for (let i = startLine; i <= endLine; i += 1) {
1678
+ endOffset += lines[i - 1].length;
1679
+ if (i < endLine) endOffset += 1;
1680
+ }
1681
+ return { startLine, endLine, startOffset, endOffset };
1682
+ }
1683
+
1684
+ async function replaceBlock(root, args, config = {}) {
1685
1685
  const relativePath = String(args?.path || '').trim();
1686
1686
  const newContent = String(args?.new_content || args?.content || '');
1687
1687
  const target = args?.target || {};
@@ -1700,67 +1700,67 @@ async function replaceBlock(root, args, config = {}) {
1700
1700
  return editResult(relativePath, 'replace_block', state.content, afterContent, resolved.start_line);
1701
1701
  }
1702
1702
 
1703
- async function replaceText(root, args, config = {}) {
1704
- const relativePath = String(args?.path || '').trim();
1705
- const oldText = String(args?.old_text || '');
1706
- const newText = String(args?.new_text || '');
1707
- const replaceAll = semanticBoolean(args?.replace_all ?? args?.replaceAll);
1708
- const state = await getFileState(root, relativePath, config);
1709
- if (!oldText) {
1710
- throw new Error('replace_text requires old_text');
1711
- }
1712
- const rangeStart = Number(args?.start_line || args?.line);
1713
- const rangeEnd = Number(args?.end_line || args?.line);
1714
- const hasRange = Number.isFinite(rangeStart) && rangeStart > 0;
1715
- const range = hasRange
1716
- ? lineRangeToOffsets(state.content, rangeStart, Number.isFinite(rangeEnd) && rangeEnd >= rangeStart ? rangeEnd : rangeStart)
1717
- : null;
1718
- const searchContent = range ? state.content.slice(range.startOffset, range.endOffset) : state.content;
1719
- const occurrences = searchContent.split(oldText).length - 1;
1720
- if (occurrences !== 1) {
1721
- if (replaceAll && occurrences > 0) {
1722
- const replaced = searchContent.replaceAll(oldText, newText);
1723
- const afterContent = range
1724
- ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1725
- : state.content.replaceAll(oldText, newText);
1726
- await fs.writeFile(state.target, afterContent, 'utf8');
1727
- const changedLine = range
1728
- ? range.startLine + splitLines(searchContent.slice(0, searchContent.indexOf(oldText))).length - 1
1729
- : splitLines(state.content.slice(0, state.content.indexOf(oldText))).length;
1730
- return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1731
- }
1732
- const baseLine = hasRange ? range.startLine : 1;
1733
- const baseOffset = hasRange ? range.startOffset : 0;
1734
- const lineDetails = [];
1735
- let searchPos = 0;
1736
- while (true) {
1737
- const pos = searchContent.indexOf(oldText, searchPos);
1738
- if (pos === -1) break;
1739
- const lineNum = baseLine + splitLines(searchContent.slice(0, pos)).length - 1;
1740
- const globalPos = baseOffset + pos;
1741
- const lStart = state.content.lastIndexOf('\n', globalPos) + 1;
1742
- const lEnd = state.content.indexOf('\n', globalPos);
1743
- const lineText = state.content.slice(lStart, lEnd >= 0 ? lEnd : void 0).trim();
1744
- lineDetails.push(` Line ${lineNum}: ${lineText}`);
1745
- searchPos = pos + oldText.length;
1746
- }
1747
- const lineHint = lineDetails.length > 0 ? `\n${lineDetails.join('\n')}\n` : ' ';
1748
- throw new Error(
1749
- occurrences === 0
1750
- ? 'replace_text old_text not found'
1751
- : `replace_text old_text not unique; found ${occurrences} occurrences:${lineHint}Use path:"${relativePath}:N-M" to narrow the range, set replace_all=true, or provide more unique old_text`
1752
- );
1753
- }
1754
- const replaced = searchContent.replace(oldText, newText);
1755
- const afterContent = range
1756
- ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1757
- : state.content.replace(oldText, newText);
1758
- await fs.writeFile(state.target, afterContent, 'utf8');
1759
- const changedLine = range
1760
- ? range.startLine + splitLines(searchContent.slice(0, searchContent.indexOf(oldText))).length - 1
1761
- : splitLines(state.content.slice(0, state.content.indexOf(oldText))).length;
1762
- return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1763
- }
1703
+ async function replaceText(root, args, config = {}) {
1704
+ const relativePath = String(args?.path || '').trim();
1705
+ const oldText = String(args?.old_text || '');
1706
+ const newText = String(args?.new_text || '');
1707
+ const replaceAll = semanticBoolean(args?.replace_all ?? args?.replaceAll);
1708
+ const state = await getFileState(root, relativePath, config);
1709
+ if (!oldText) {
1710
+ throw new Error('replace_text requires old_text');
1711
+ }
1712
+ const rangeStart = Number(args?.start_line || args?.line);
1713
+ const rangeEnd = Number(args?.end_line || args?.line);
1714
+ const hasRange = Number.isFinite(rangeStart) && rangeStart > 0;
1715
+ const range = hasRange
1716
+ ? lineRangeToOffsets(state.content, rangeStart, Number.isFinite(rangeEnd) && rangeEnd >= rangeStart ? rangeEnd : rangeStart)
1717
+ : null;
1718
+ const searchContent = range ? state.content.slice(range.startOffset, range.endOffset) : state.content;
1719
+ const occurrences = searchContent.split(oldText).length - 1;
1720
+ if (occurrences !== 1) {
1721
+ if (replaceAll && occurrences > 0) {
1722
+ const replaced = searchContent.replaceAll(oldText, newText);
1723
+ const afterContent = range
1724
+ ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1725
+ : state.content.replaceAll(oldText, newText);
1726
+ await fs.writeFile(state.target, afterContent, 'utf8');
1727
+ const changedLine = range
1728
+ ? range.startLine + splitLines(searchContent.slice(0, searchContent.indexOf(oldText))).length - 1
1729
+ : splitLines(state.content.slice(0, state.content.indexOf(oldText))).length;
1730
+ return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1731
+ }
1732
+ const baseLine = hasRange ? range.startLine : 1;
1733
+ const baseOffset = hasRange ? range.startOffset : 0;
1734
+ const lineDetails = [];
1735
+ let searchPos = 0;
1736
+ while (true) {
1737
+ const pos = searchContent.indexOf(oldText, searchPos);
1738
+ if (pos === -1) break;
1739
+ const lineNum = baseLine + splitLines(searchContent.slice(0, pos)).length - 1;
1740
+ const globalPos = baseOffset + pos;
1741
+ const lStart = state.content.lastIndexOf('\n', globalPos) + 1;
1742
+ const lEnd = state.content.indexOf('\n', globalPos);
1743
+ const lineText = state.content.slice(lStart, lEnd >= 0 ? lEnd : void 0).trim();
1744
+ lineDetails.push(` Line ${lineNum}: ${lineText}`);
1745
+ searchPos = pos + oldText.length;
1746
+ }
1747
+ const lineHint = lineDetails.length > 0 ? `\n${lineDetails.join('\n')}\n` : ' ';
1748
+ throw new Error(
1749
+ occurrences === 0
1750
+ ? 'replace_text old_text not found'
1751
+ : `replace_text old_text not unique; found ${occurrences} occurrences:${lineHint}Use path:"${relativePath}:N-M" to narrow the range, set replace_all=true, or provide more unique old_text`
1752
+ );
1753
+ }
1754
+ const replaced = searchContent.replace(oldText, newText);
1755
+ const afterContent = range
1756
+ ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1757
+ : state.content.replace(oldText, newText);
1758
+ await fs.writeFile(state.target, afterContent, 'utf8');
1759
+ const changedLine = range
1760
+ ? range.startLine + splitLines(searchContent.slice(0, searchContent.indexOf(oldText))).length - 1
1761
+ : splitLines(state.content.slice(0, state.content.indexOf(oldText))).length;
1762
+ return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1763
+ }
1764
1764
 
1765
1765
  async function insertRelative(root, args, mode, config = {}) {
1766
1766
  const relativePath = String(args?.path || '').trim();
@@ -1807,62 +1807,62 @@ async function openTarget(root, args, config = {}) {
1807
1807
  };
1808
1808
  }
1809
1809
 
1810
- function normalizeEditTargetArgs(args = {}) {
1811
- const rawFile = String(args?.file || args?.path || args?.file_path || '').trim();
1812
- const inlineRange = parseInlineRangePath(rawFile);
1813
- const file = normalizeFilePathValue(rawFile, { stripInlineRange: true }).trim();
1814
- const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1815
- const startLine = args?.start_line ?? args?.line ?? inlineRange?.start_line;
1816
- const endLine = args?.end_line ?? inlineRange?.end_line ?? args?.line;
1817
- if (nestedEdit) {
1818
- const normalizedEdit = { ...nestedEdit };
1819
- if (normalizedEdit.new_content == null && normalizedEdit.content != null) {
1820
- normalizedEdit.new_content = normalizedEdit.content;
1821
- }
1822
- if (normalizedEdit.old_text == null && normalizedEdit.old_string != null) {
1823
- normalizedEdit.old_text = normalizedEdit.old_string;
1824
- }
1825
- if (normalizedEdit.new_text == null && normalizedEdit.content != null && normalizedEdit.old_text != null) {
1826
- normalizedEdit.new_text = normalizedEdit.content;
1827
- }
1828
- if (normalizedEdit.new_text == null && normalizedEdit.new_string != null) {
1829
- normalizedEdit.new_text = normalizedEdit.new_string;
1830
- }
1831
- return {
1832
- path: file,
1833
- file,
1834
- start_line: startLine,
1835
- end_line: endLine,
1836
- ast_target: normalizedEdit.ast_target ?? args?.ast_target,
1837
- edit: normalizedEdit
1838
- };
1839
- }
1810
+ function normalizeEditTargetArgs(args = {}) {
1811
+ const rawFile = String(args?.file || args?.path || args?.file_path || '').trim();
1812
+ const inlineRange = parseInlineRangePath(rawFile);
1813
+ const file = normalizeFilePathValue(rawFile, { stripInlineRange: true }).trim();
1814
+ const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1815
+ const startLine = args?.start_line ?? args?.line ?? inlineRange?.start_line;
1816
+ const endLine = args?.end_line ?? inlineRange?.end_line ?? args?.line;
1817
+ if (nestedEdit) {
1818
+ const normalizedEdit = { ...nestedEdit };
1819
+ if (normalizedEdit.new_content == null && normalizedEdit.content != null) {
1820
+ normalizedEdit.new_content = normalizedEdit.content;
1821
+ }
1822
+ if (normalizedEdit.old_text == null && normalizedEdit.old_string != null) {
1823
+ normalizedEdit.old_text = normalizedEdit.old_string;
1824
+ }
1825
+ if (normalizedEdit.new_text == null && normalizedEdit.content != null && normalizedEdit.old_text != null) {
1826
+ normalizedEdit.new_text = normalizedEdit.content;
1827
+ }
1828
+ if (normalizedEdit.new_text == null && normalizedEdit.new_string != null) {
1829
+ normalizedEdit.new_text = normalizedEdit.new_string;
1830
+ }
1831
+ return {
1832
+ path: file,
1833
+ file,
1834
+ start_line: startLine,
1835
+ end_line: endLine,
1836
+ ast_target: normalizedEdit.ast_target ?? args?.ast_target,
1837
+ edit: normalizedEdit
1838
+ };
1839
+ }
1840
1840
  const topLevelOldText = args?.old_text ?? args?.old_string;
1841
1841
  const topLevelContent = args?.content;
1842
- return {
1843
- path: file,
1844
- file,
1845
- start_line: startLine,
1846
- end_line: endLine,
1847
- ast_target: args?.ast_target,
1848
- edit: {
1849
- kind: args?.kind,
1850
- target: args?.target,
1842
+ return {
1843
+ path: file,
1844
+ file,
1845
+ start_line: startLine,
1846
+ end_line: endLine,
1847
+ ast_target: args?.ast_target,
1848
+ edit: {
1849
+ kind: args?.kind,
1850
+ target: args?.target,
1851
1851
  new_content: args?.new_content ?? args?.content,
1852
1852
  old_text: args?.old_text,
1853
1853
  new_text: args?.new_text ?? (topLevelOldText != null && topLevelContent != null ? topLevelContent : undefined),
1854
- old_string: args?.old_string,
1855
- new_string: args?.new_string,
1856
- anchor_text: args?.anchor_text,
1857
- content: args?.content,
1858
- replace_all: args?.replace_all ?? args?.replaceAll
1859
- }
1860
- };
1861
- }
1854
+ old_string: args?.old_string,
1855
+ new_string: args?.new_string,
1856
+ anchor_text: args?.anchor_text,
1857
+ content: args?.content,
1858
+ replace_all: args?.replace_all ?? args?.replaceAll
1859
+ }
1860
+ };
1861
+ }
1862
1862
 
1863
1863
  async function editTarget(root, args, config = {}) {
1864
- const normalized = normalizeEditTargetArgs(args);
1865
- const file = normalized.file || normalizeFilePathValue(args?.recent_file || '', { stripInlineRange: true }).trim();
1864
+ const normalized = normalizeEditTargetArgs(args);
1865
+ const file = normalized.file || normalizeFilePathValue(args?.recent_file || '', { stripInlineRange: true }).trim();
1866
1866
  const astTarget = normalized.ast_target;
1867
1867
  const edit = normalized.edit || {};
1868
1868
  let kind = String(edit.kind || '').trim();
@@ -1872,8 +1872,8 @@ async function editTarget(root, args, config = {}) {
1872
1872
  if (edit.new_text == null && edit.new_string != null) {
1873
1873
  edit.new_text = edit.new_string;
1874
1874
  }
1875
- const hasContent = edit.new_content != null || edit.content != null;
1876
- const hasExplicitRewrite = edit.kind === 'rewrite_file' || args?.kind === 'rewrite_file';
1875
+ const hasContent = edit.new_content != null || edit.content != null;
1876
+ const hasExplicitRewrite = edit.kind === 'rewrite_file' || args?.kind === 'rewrite_file';
1877
1877
  const hasTargetHint = Boolean(edit.symbol || args?.symbol || edit.line || args?.line || edit.target);
1878
1878
  if (!kind) {
1879
1879
  if (hasContent && hasTargetHint) {
@@ -1882,23 +1882,23 @@ async function editTarget(root, args, config = {}) {
1882
1882
  kind = 'replace_text';
1883
1883
  } else if ((edit.anchor_text != null || edit.target_text != null) && (edit.content != null || edit.new_content != null)) {
1884
1884
  kind = String(edit.position || edit.mode || args?.position || '').trim() === 'after' ? 'insert_after' : 'insert_before';
1885
- } else if (hasContent && hasExplicitRewrite) {
1886
- kind = 'rewrite_file';
1887
- }
1885
+ } else if (hasContent && hasExplicitRewrite) {
1886
+ kind = 'rewrite_file';
1887
+ }
1888
+ }
1889
+ if (!file || !kind) {
1890
+ const recentFile = String(args?.recent_file || '').trim();
1891
+ const rawArgs = typeof args?._raw === 'string' && args._raw.trim() ? ` Raw tool arguments: ${args._raw.trim()}.` : '';
1892
+ const missing = !file
1893
+ ? 'file path'
1894
+ : edit.old_text != null && edit.new_text == null && edit.content == null
1895
+ ? 'new_text'
1896
+ : 'edit operation';
1897
+ const hint = recentFile
1898
+ ? ` If you meant the recently read file ${recentFile}, use edit with {file:"${recentFile}", old_text:"...", new_text:"..."} for a text replacement, or {file:"${recentFile}", kind:"rewrite_file", new_content:"..."} for a full rewrite.`
1899
+ : ' Use edit with {file:"path", old_text:"...", new_text:"..."} for a text replacement, or {file:"path", kind:"rewrite_file", new_content:"..."} for a full rewrite.';
1900
+ throw new Error(`edit requires ${missing}.${rawArgs}${hint}`);
1888
1901
  }
1889
- if (!file || !kind) {
1890
- const recentFile = String(args?.recent_file || '').trim();
1891
- const rawArgs = typeof args?._raw === 'string' && args._raw.trim() ? ` Raw tool arguments: ${args._raw.trim()}.` : '';
1892
- const missing = !file
1893
- ? 'file path'
1894
- : edit.old_text != null && edit.new_text == null && edit.content == null
1895
- ? 'new_text'
1896
- : 'edit operation';
1897
- const hint = recentFile
1898
- ? ` If you meant the recently read file ${recentFile}, use edit with {file:"${recentFile}", old_text:"...", new_text:"..."} for a text replacement, or {file:"${recentFile}", kind:"rewrite_file", new_content:"..."} for a full rewrite.`
1899
- : ' Use edit with {file:"path", old_text:"...", new_text:"..."} for a text replacement, or {file:"path", kind:"rewrite_file", new_content:"..."} for a full rewrite.';
1900
- throw new Error(`edit requires ${missing}.${rawArgs}${hint}`);
1901
- }
1902
1902
  if (astTarget) {
1903
1903
  if (kind !== 'replace_block') {
1904
1904
  throw new Error('AST-scoped edit only supports replace_block');
@@ -1942,16 +1942,16 @@ async function editTarget(root, args, config = {}) {
1942
1942
  }, config);
1943
1943
  }
1944
1944
  }
1945
- if (kind === 'replace_text') {
1946
- return replaceText(root, {
1947
- path: file,
1948
- old_text: edit.old_text,
1949
- new_text: edit.new_text,
1950
- replace_all: edit.replace_all ?? args?.replace_all ?? args?.replaceAll,
1951
- start_line: edit.start_line ?? normalized.start_line,
1952
- end_line: edit.end_line ?? normalized.end_line
1953
- }, config);
1954
- }
1945
+ if (kind === 'replace_text') {
1946
+ return replaceText(root, {
1947
+ path: file,
1948
+ old_text: edit.old_text,
1949
+ new_text: edit.new_text,
1950
+ replace_all: edit.replace_all ?? args?.replace_all ?? args?.replaceAll,
1951
+ start_line: edit.start_line ?? normalized.start_line,
1952
+ end_line: edit.end_line ?? normalized.end_line
1953
+ }, config);
1954
+ }
1955
1955
  if (kind === 'insert_before') {
1956
1956
  return insertRelative(root, { path: file, anchor_text: edit.anchor_text, content: edit.content }, 'insert_before', config);
1957
1957
  }
@@ -1968,19 +1968,19 @@ async function editTarget(root, args, config = {}) {
1968
1968
  throw new Error(`edit does not support kind: ${kind}`);
1969
1969
  }
1970
1970
 
1971
- export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate, getPlanState, onPlanStateUpdate, fffAdapter }) {
1971
+ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate, getPlanState, onPlanStateUpdate, fffAdapter, backupManager }) {
1972
1972
  const emitSystemTool = (event) => {
1973
1973
  if (typeof onSystemEvent === 'function' && event) onSystemEvent(event);
1974
1974
  };
1975
- const astSelectionCache = new Map();
1976
- let lastAstTarget = null;
1977
- let lastReadPath = '';
1978
- let lastReadRange = null;
1979
- const rememberAstSelection = (filePath, astTarget) => {
1980
- const key = normalizePath(filePath).trim();
1981
- if (!key || !astTarget) return;
1982
- lastAstTarget = astTarget;
1983
- astSelectionCache.set(key, astTarget);
1975
+ const astSelectionCache = new Map();
1976
+ let lastAstTarget = null;
1977
+ let lastReadPath = '';
1978
+ let lastReadRange = null;
1979
+ const rememberAstSelection = (filePath, astTarget) => {
1980
+ const key = normalizePath(filePath).trim();
1981
+ if (!key || !astTarget) return;
1982
+ lastAstTarget = astTarget;
1983
+ astSelectionCache.set(key, astTarget);
1984
1984
  };
1985
1985
  const hasExplicitBlockHints = (args = {}) =>
1986
1986
  Boolean(
@@ -1993,15 +1993,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1993
1993
  args?.edit?.line ||
1994
1994
  args?.edit?.target
1995
1995
  );
1996
- const resolveCachedAstTarget = (args = {}, { requireAstScope = false } = {}) => {
1997
- const file = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.ast_target?.path || '', { stripInlineRange: true }).trim();
1998
- if (args?.ast_target) return args.ast_target;
1999
- if (file) {
2000
- if (requireAstScope && hasExplicitBlockHints(args)) return null;
2001
- return astSelectionCache.get(file) || null;
2002
- }
2003
- return lastAstTarget || null;
2004
- };
1996
+ const resolveCachedAstTarget = (args = {}, { requireAstScope = false } = {}) => {
1997
+ const file = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.ast_target?.path || '', { stripInlineRange: true }).trim();
1998
+ if (args?.ast_target) return args.ast_target;
1999
+ if (file) {
2000
+ if (requireAstScope && hasExplicitBlockHints(args)) return null;
2001
+ return astSelectionCache.get(file) || null;
2002
+ }
2003
+ return lastAstTarget || null;
2004
+ };
2005
2005
  const ensureProjectIndex = async () => {
2006
2006
  const eventId = `project-index:${Date.now()}`;
2007
2007
  const name = 'project_index(.codemini/project-map.json,.codemini/file-index.json)';
@@ -2053,17 +2053,17 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2053
2053
  {
2054
2054
  type: 'function',
2055
2055
  function: {
2056
- name: 'read',
2057
- description:
2058
- 'Inspect code or text files. Use {path} for normal reads; file_path/file are accepted aliases. Use start_line/end_line or path:"src/app.ts:10-40" for ranges. Normal code reads include enclosing symbol metadata when available; read with query returns the matched AST node and ast_target.',
2059
- parameters: {
2060
- type: 'object',
2061
- properties: {
2062
- path: { type: 'string', description: 'File path to read. You can also include an inline range like src/app.ts:10-40.' },
2063
- file_path: { type: 'string', description: 'Alias for path' },
2064
- file: { type: 'string', description: 'Alias for path' },
2065
- start_line: { type: 'number', description: '1-based start line' },
2066
- end_line: { type: 'number', description: 'Inclusive end line' },
2056
+ name: 'read',
2057
+ description:
2058
+ 'Inspect code or text files. Use {path} for normal reads; file_path/file are accepted aliases. Use start_line/end_line or path:"src/app.ts:10-40" for ranges. Normal code reads include enclosing symbol metadata when available; read with query returns the matched AST node and ast_target.',
2059
+ parameters: {
2060
+ type: 'object',
2061
+ properties: {
2062
+ path: { type: 'string', description: 'File path to read. You can also include an inline range like src/app.ts:10-40.' },
2063
+ file_path: { type: 'string', description: 'Alias for path' },
2064
+ file: { type: 'string', description: 'Alias for path' },
2065
+ start_line: { type: 'number', description: '1-based start line' },
2066
+ end_line: { type: 'number', description: 'Inclusive end line' },
2067
2067
  max_chars: { type: 'number', description: 'Max chars to return' },
2068
2068
  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.' },
2069
2069
  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.' },
@@ -2082,10 +2082,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2082
2082
  'Search file contents. Use this for code search before read or edit. Do not use run with grep or rg for normal code search.',
2083
2083
  parameters: {
2084
2084
  type: 'object',
2085
- properties: {
2086
- pattern: { type: 'string', description: 'Search pattern' },
2087
- path: { type: 'string', description: 'Directory or file to search. file_path/file/dir/directory/cwd are accepted aliases.' },
2088
- regex: { type: 'boolean', description: 'Treat pattern as regex' },
2085
+ properties: {
2086
+ pattern: { type: 'string', description: 'Search pattern' },
2087
+ path: { type: 'string', description: 'Directory or file to search. file_path/file/dir/directory/cwd are accepted aliases.' },
2088
+ regex: { type: 'boolean', description: 'Treat pattern as regex' },
2089
2089
  case_sensitive: { type: 'boolean', description: 'Case-sensitive matching' },
2090
2090
  max_results: { type: 'number', description: 'Max matches to return' },
2091
2091
  language: { type: 'string', description: 'Filter by language' },
@@ -2102,9 +2102,9 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2102
2102
  description: 'List files and directories in a workspace path. Use this for quick directory discovery before deeper reads.',
2103
2103
  parameters: {
2104
2104
  type: 'object',
2105
- properties: {
2106
- path: { type: 'string', description: 'Directory path to list. file_path/file/dir/directory are accepted aliases.' },
2107
- include_hidden: { type: 'boolean', description: 'Include dotfiles' }
2105
+ properties: {
2106
+ path: { type: 'string', description: 'Directory path to list. file_path/file/dir/directory are accepted aliases.' },
2107
+ include_hidden: { type: 'boolean', description: 'Include dotfiles' }
2108
2108
  }
2109
2109
  }
2110
2110
  }
@@ -2112,9 +2112,9 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2112
2112
  {
2113
2113
  type: 'function',
2114
2114
  function: {
2115
- name: 'query_project_index',
2116
- description:
2117
- 'Query the lightweight project index before broad file reads. Returns relevant files plus Symbol Graph summaries: symbol_id, type, range, signature, calls, called_by, imports, writes, and emits.',
2115
+ name: 'query_project_index',
2116
+ description:
2117
+ 'Query the lightweight project index before broad file reads. Returns relevant files plus Symbol Graph summaries: symbol_id, type, range, signature, calls, called_by, imports, writes, and emits.',
2118
2118
  parameters: {
2119
2119
  type: 'object',
2120
2120
  properties: {
@@ -2130,24 +2130,24 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2130
2130
  {
2131
2131
  type: 'function',
2132
2132
  function: {
2133
- name: 'edit',
2134
- description:
2135
- 'Edit existing files. Prefer {path, old_text, new_text}; old_string/new_string and file_path/file are accepted aliases. If old_text is repeated, use path:"file:10-30" or rely on the most recent read range. Set replace_all=true to replace every match. Advanced kind/ast_target edits are still supported.',
2136
- parameters: {
2137
- type: 'object',
2138
- properties: {
2139
- path: { type: 'string', description: 'File path to edit. Inline ranges like src/app.js:10-30 are accepted.' },
2140
- file_path: { type: 'string', description: 'Alias for path' },
2141
- file: { type: 'string', description: 'Alias for path' },
2142
- new_content: { type: 'string', description: 'Replacement content' },
2143
- old_text: { type: 'string', description: 'Exact text to replace' },
2144
- new_text: { type: 'string', description: 'Replacement text' },
2145
- old_string: { type: 'string', description: 'Alias for old_text' },
2146
- new_string: { type: 'string', description: 'Alias for new_text' },
2147
- replace_all: { type: 'boolean', description: 'Replace all matching old_text occurrences' },
2148
- start_line: { type: 'number', description: 'Optional range start for disambiguating old_text' },
2149
- end_line: { type: 'number', description: 'Optional range end for disambiguating old_text' },
2150
- anchor_text: { type: 'string', description: 'Anchor text for inserts' },
2133
+ name: 'edit',
2134
+ description:
2135
+ 'Edit existing files. Prefer {path, old_text, new_text}; old_string/new_string and file_path/file are accepted aliases. If old_text is repeated, use path:"file:10-30" or rely on the most recent read range. Set replace_all=true to replace every match. Advanced kind/ast_target edits are still supported.',
2136
+ parameters: {
2137
+ type: 'object',
2138
+ properties: {
2139
+ path: { type: 'string', description: 'File path to edit. Inline ranges like src/app.js:10-30 are accepted.' },
2140
+ file_path: { type: 'string', description: 'Alias for path' },
2141
+ file: { type: 'string', description: 'Alias for path' },
2142
+ new_content: { type: 'string', description: 'Replacement content' },
2143
+ old_text: { type: 'string', description: 'Exact text to replace' },
2144
+ new_text: { type: 'string', description: 'Replacement text' },
2145
+ old_string: { type: 'string', description: 'Alias for old_text' },
2146
+ new_string: { type: 'string', description: 'Alias for new_text' },
2147
+ replace_all: { type: 'boolean', description: 'Replace all matching old_text occurrences' },
2148
+ start_line: { type: 'number', description: 'Optional range start for disambiguating old_text' },
2149
+ end_line: { type: 'number', description: 'Optional range end for disambiguating old_text' },
2150
+ anchor_text: { type: 'string', description: 'Anchor text for inserts' },
2151
2151
  content: { type: 'string', description: 'Content to insert or append' },
2152
2152
  position: { type: 'string', description: 'before or after' },
2153
2153
  kind: { type: 'string', description: 'replace_block, replace_text, insert_before, insert_after, or rewrite_file' },
@@ -2157,23 +2157,23 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2157
2157
  line: { type: 'number', description: 'Line to target' },
2158
2158
  edit: { type: 'object', description: 'Structured edit input' }
2159
2159
  },
2160
- required: ['path', 'content']
2161
- }
2162
- }
2163
- },
2160
+ required: ['path', 'content']
2161
+ }
2162
+ }
2163
+ },
2164
2164
  {
2165
2165
  type: 'function',
2166
2166
  function: {
2167
- name: 'write',
2168
- description:
2169
- 'Create a new file, append to a file, or perform an explicit whole-file rewrite. Always include path and content; file_path/file are accepted aliases. For existing files, prefer edit after reading the relevant range. Overwriting an existing file requires full_file_rewrite=true.',
2170
- parameters: {
2171
- type: 'object',
2172
- properties: {
2173
- path: { type: 'string', description: 'Required file path like src/app.js or pages/index.html. Never omit this.' },
2174
- file_path: { type: 'string', description: 'Alias for path' },
2175
- file: { type: 'string', description: 'Alias for path' },
2176
- content: { type: 'string', description: 'Content to write' },
2167
+ name: 'write',
2168
+ description:
2169
+ 'Create a new file, append to a file, or perform an explicit whole-file rewrite. Always include path and content; file_path/file are accepted aliases. For existing files, prefer edit after reading the relevant range. Overwriting an existing file requires full_file_rewrite=true.',
2170
+ parameters: {
2171
+ type: 'object',
2172
+ properties: {
2173
+ path: { type: 'string', description: 'Required file path like src/app.js or pages/index.html. Never omit this.' },
2174
+ file_path: { type: 'string', description: 'Alias for path' },
2175
+ file: { type: 'string', description: 'Alias for path' },
2176
+ content: { type: 'string', description: 'Content to write' },
2177
2177
  append: { type: 'boolean', description: 'Append instead of overwrite' },
2178
2178
  full_file_rewrite: { type: 'boolean', description: 'Set true for whole-file rewrites' }
2179
2179
  },
@@ -2189,12 +2189,12 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2189
2189
  'Delete a file or directory inside the workspace. Missing targets fail. Workspace escape attempts are rejected.',
2190
2190
  parameters: {
2191
2191
  type: 'object',
2192
- properties: {
2193
- path: { type: 'string', description: 'File or directory path to delete. file_path/file/target are accepted aliases.' },
2194
- file_path: { type: 'string', description: 'Alias for path' },
2195
- file: { type: 'string', description: 'Alias for path' },
2196
- target: { type: 'string', description: 'Alias for path' }
2197
- },
2192
+ properties: {
2193
+ path: { type: 'string', description: 'File or directory path to delete. file_path/file/target are accepted aliases.' },
2194
+ file_path: { type: 'string', description: 'Alias for path' },
2195
+ file: { type: 'string', description: 'Alias for path' },
2196
+ target: { type: 'string', description: 'Alias for path' }
2197
+ },
2198
2198
  required: ['path']
2199
2199
  }
2200
2200
  }
@@ -2562,7 +2562,36 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2562
2562
  };
2563
2563
 
2564
2564
  const definitions = [...primaryDefinitions];
2565
- const activeFffAdapter = fffAdapter || createFffAdapter({ workspaceRoot, config });
2565
+ const activeFffAdapter = fffAdapter || createFffAdapter({ workspaceRoot, config });
2566
+ async function backupNonGitPathOnce(rawPath) {
2567
+ if (!backupManager || typeof backupManager.backupOnce !== 'function') return null;
2568
+ const normalized = normalizeFilePathValue(rawPath || '', { stripInlineRange: true }).trim();
2569
+ if (!normalized) return null;
2570
+ try {
2571
+ const backup = await backupManager.backupOnce(normalized);
2572
+ return backup?.ok ? backup : null;
2573
+ } catch (error) {
2574
+ return {
2575
+ ok: false,
2576
+ path: normalized,
2577
+ error: error instanceof Error ? error.message : String(error)
2578
+ };
2579
+ }
2580
+ }
2581
+ function attachBackup(result, backup) {
2582
+ if (!backup || !result || typeof result !== 'object') return result;
2583
+ return {
2584
+ ...result,
2585
+ non_git_backup: true,
2586
+ backupPath: backup.backupPath || '',
2587
+ backupRelativePath: backup.backupRelativePath || '',
2588
+ backupCreated: backup.created === true,
2589
+ backupReused: backup.reused === true,
2590
+ backupSkipped: backup.skipped === true || (!backup.backupPath && backup.existed === true),
2591
+ backupError: backup.error || '',
2592
+ backupReason: backup.reason || ''
2593
+ };
2594
+ }
2566
2595
  let fffConnected = false;
2567
2596
 
2568
2597
  async function ensureFffConnected() {
@@ -2595,8 +2624,8 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2595
2624
  return builtinGlob(workspaceRoot, args, config);
2596
2625
  }
2597
2626
 
2598
- async function list(args) {
2599
- const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'file_path', 'file', 'target']);
2627
+ async function list(args) {
2628
+ const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'file_path', 'file', 'target']);
2600
2629
  if (!resolvesOutsideRoot(workspaceRoot, normalizedArgs?.path || '.') && activeFffAdapter?.list) {
2601
2630
  try {
2602
2631
  await ensureFffConnected();
@@ -2617,15 +2646,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2617
2646
  ...args,
2618
2647
  path: args?.path || directAstTarget?.path,
2619
2648
  ast_target: directAstTarget
2620
- });
2621
- if (directAstTarget?.path) rememberAstSelection(directAstTarget.path, directAstTarget);
2622
- const readPath = normalizePath(result?.path || directAstTarget?.path || '').trim();
2623
- if (readPath) {
2624
- lastReadPath = readPath;
2625
- lastReadRange = null;
2626
- }
2627
- return { ...result, ast_target: directAstTarget };
2628
- }
2649
+ });
2650
+ if (directAstTarget?.path) rememberAstSelection(directAstTarget.path, directAstTarget);
2651
+ const readPath = normalizePath(result?.path || directAstTarget?.path || '').trim();
2652
+ if (readPath) {
2653
+ lastReadPath = readPath;
2654
+ lastReadRange = null;
2655
+ }
2656
+ return { ...result, ast_target: directAstTarget };
2657
+ }
2629
2658
 
2630
2659
  if (inlineQuery) {
2631
2660
  const queryResult = await queryAst(workspaceRoot, args);
@@ -2646,30 +2675,30 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2646
2675
  path: firstTarget.path,
2647
2676
  ast_target: firstTarget
2648
2677
  });
2649
- const readPath = normalizePath(result?.path || firstTarget?.path || '').trim();
2650
- if (readPath) {
2651
- lastReadPath = readPath;
2652
- lastReadRange = null;
2653
- }
2654
- return {
2655
- path: result.path,
2656
- language: result.language,
2657
- node: result.node,
2658
- content: result.content,
2659
- ast_target: firstTarget,
2660
- symbol: {
2661
- symbol_id: `${result.path}#${firstTarget.name || firstTarget.node_type || `${result.node.start_line}-${result.node.end_line}`}`,
2662
- type: result.node.node_type,
2663
- file: result.path,
2664
- range: {
2665
- start_line: result.node.start_line,
2666
- end_line: result.node.end_line
2667
- }
2668
- },
2669
- query: inlineQuery,
2670
- capture_name: String(args?.capture_name || '').trim() || undefined,
2671
- matches: queryResult.matches.length
2672
- };
2678
+ const readPath = normalizePath(result?.path || firstTarget?.path || '').trim();
2679
+ if (readPath) {
2680
+ lastReadPath = readPath;
2681
+ lastReadRange = null;
2682
+ }
2683
+ return {
2684
+ path: result.path,
2685
+ language: result.language,
2686
+ node: result.node,
2687
+ content: result.content,
2688
+ ast_target: firstTarget,
2689
+ symbol: {
2690
+ symbol_id: `${result.path}#${firstTarget.name || firstTarget.node_type || `${result.node.start_line}-${result.node.end_line}`}`,
2691
+ type: result.node.node_type,
2692
+ file: result.path,
2693
+ range: {
2694
+ start_line: result.node.start_line,
2695
+ end_line: result.node.end_line
2696
+ }
2697
+ },
2698
+ query: inlineQuery,
2699
+ capture_name: String(args?.capture_name || '').trim() || undefined,
2700
+ matches: queryResult.matches.length
2701
+ };
2673
2702
  }
2674
2703
 
2675
2704
  const result = await readFile(workspaceRoot, {
@@ -2680,14 +2709,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2680
2709
  ? args.max_chars
2681
2710
  : config.context?.read_file_max_chars ?? 24000
2682
2711
  }, config);
2683
- const readPath = normalizePath(result?.path || args?.path || '').trim();
2684
- if (readPath) {
2685
- lastReadPath = readPath;
2686
- lastReadRange = result?.phase === 'content'
2687
- ? { path: readPath, start_line: result.start_line, end_line: result.end_line }
2688
- : null;
2689
- }
2690
- return result;
2712
+ const readPath = normalizePath(result?.path || args?.path || '').trim();
2713
+ if (readPath) {
2714
+ lastReadPath = readPath;
2715
+ lastReadRange = result?.phase === 'content'
2716
+ ? { path: readPath, start_line: result.start_line, end_line: result.end_line }
2717
+ : null;
2718
+ }
2719
+ return result;
2691
2720
  },
2692
2721
  query_project_index: async (args) => {
2693
2722
  await ensureProjectIndex();
@@ -2713,19 +2742,20 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2713
2742
  edit: async (args) => {
2714
2743
  await ensureProjectIndex();
2715
2744
  const normalizedKind = String(args?.edit?.kind || args?.kind || '').trim();
2716
- const hasReplaceTextArgs = args?.edit?.old_text != null || args?.old_text != null || args?.old_string != null;
2717
- const astTarget = hasReplaceTextArgs || (normalizedKind && normalizedKind !== 'replace_block')
2718
- ? null
2719
- : resolveCachedAstTarget(args, { requireAstScope: normalizedKind === 'replace_block' });
2720
- const editPath = normalizeFilePathValue(args?.path || args?.file || args?.file_path || '', { stripInlineRange: true }).trim();
2721
- const shouldUseRecentReadRange =
2722
- editPath &&
2723
- lastReadRange?.path === editPath &&
2724
- !Number.isFinite(Number(args?.start_line || args?.line || args?.edit?.start_line)) &&
2725
- !Number.isFinite(Number(args?.end_line || args?.edit?.end_line));
2726
- const rangeArgs = shouldUseRecentReadRange
2727
- ? { start_line: lastReadRange.start_line, end_line: lastReadRange.end_line }
2728
- : {};
2745
+ const hasReplaceTextArgs = args?.edit?.old_text != null || args?.old_text != null || args?.old_string != null;
2746
+ const astTarget = hasReplaceTextArgs || (normalizedKind && normalizedKind !== 'replace_block')
2747
+ ? null
2748
+ : resolveCachedAstTarget(args, { requireAstScope: normalizedKind === 'replace_block' });
2749
+ const editPath = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.ast_target?.path || args?.edit?.target?.path || '', { stripInlineRange: true }).trim();
2750
+ const shouldUseRecentReadRange =
2751
+ editPath &&
2752
+ lastReadRange?.path === editPath &&
2753
+ !Number.isFinite(Number(args?.start_line || args?.line || args?.edit?.start_line)) &&
2754
+ !Number.isFinite(Number(args?.end_line || args?.edit?.end_line));
2755
+ const rangeArgs = shouldUseRecentReadRange
2756
+ ? { start_line: lastReadRange.start_line, end_line: lastReadRange.end_line }
2757
+ : {};
2758
+ const backup = await backupNonGitPathOnce(editPath || astTarget?.path);
2729
2759
  const result = await editTarget(
2730
2760
  workspaceRoot,
2731
2761
  astTarget
@@ -2733,21 +2763,25 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2733
2763
  : { ...args, ...rangeArgs, recent_file: lastReadPath },
2734
2764
  config
2735
2765
  );
2736
- if (result?.path) await refreshProjectFile(result.path);
2737
- return result;
2738
- },
2739
- write: async (args) => {
2740
- await ensureProjectIndex();
2741
- const result = await writeFile(workspaceRoot, args, config);
2742
- if (result?.path) await refreshProjectFile(result.path);
2743
- return result;
2744
- },
2745
- delete: Object.assign(async (args) => {
2746
- await ensureProjectIndex();
2747
- const result = await deletePath(workspaceRoot, args, config);
2748
- if (result?.path) await refreshProjectFile(result.path);
2749
- return result;
2750
- }, {
2766
+ if (result?.path) await refreshProjectFile(result.path);
2767
+ return attachBackup(result, backup);
2768
+ },
2769
+ write: async (args) => {
2770
+ await ensureProjectIndex();
2771
+ const writePath = normalizeFilePathValue(args?.path || args?.file || args?.file_path || '', { stripInlineRange: true }).trim();
2772
+ const backup = await backupNonGitPathOnce(writePath);
2773
+ const result = await writeFile(workspaceRoot, args, config);
2774
+ if (result?.path) await refreshProjectFile(result.path);
2775
+ return attachBackup(result, backup);
2776
+ },
2777
+ delete: Object.assign(async (args) => {
2778
+ await ensureProjectIndex();
2779
+ const deletePathValue = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.target || '', { stripInlineRange: true }).trim();
2780
+ const backup = await backupNonGitPathOnce(deletePathValue);
2781
+ const result = await deletePath(workspaceRoot, args, config);
2782
+ if (result?.path) await refreshProjectFile(result.path);
2783
+ return attachBackup(result, backup);
2784
+ }, {
2751
2785
  prepareApproval: async (args) => {
2752
2786
  const target = await prepareDeleteTarget(workspaceRoot, args, config);
2753
2787
  return {
@@ -3029,41 +3063,50 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
3029
3063
  return lines.join('\n');
3030
3064
  },
3031
3065
 
3032
- edit(result) {
3033
- if (!result || typeof result !== 'object') return String(result);
3034
- const p = result.path || '';
3035
- const action = result.action || '';
3036
- const line = result.changed_line || 0;
3037
- const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}`;
3038
- const diffPreview = result.diff_preview || '';
3039
- if (diffPreview) {
3040
- const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
3066
+ edit(result) {
3067
+ if (!result || typeof result !== 'object') return String(result);
3068
+ const p = result.path || '';
3069
+ const action = result.action || '';
3070
+ const line = result.changed_line || 0;
3071
+ const backup = result.backupPath
3072
+ ? `\nbackup: ${result.backupPath}${result.backupReused ? ' (reused)' : ''}`
3073
+ : '';
3074
+ const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}${backup}`;
3075
+ const diffPreview = result.diff_preview || '';
3076
+ if (diffPreview) {
3077
+ const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
3041
3078
  return `${summary}\n${trimmed}`;
3042
3079
  }
3043
3080
  return summary + (result.ok !== false ? '' : ` [FAILED: ${result.error || 'unknown'}]`);
3044
3081
  },
3045
3082
 
3046
- write(result) {
3047
- if (!result || typeof result !== 'object') return String(result);
3048
- const p = result.path || '';
3049
- const action = result.action || 'write';
3050
- const line = result.changed_line || 0;
3051
- const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}`;
3052
- const diffPreview = result.diff_preview || '';
3053
- if (diffPreview) {
3054
- const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
3083
+ write(result) {
3084
+ if (!result || typeof result !== 'object') return String(result);
3085
+ const p = result.path || '';
3086
+ const action = result.action || 'write';
3087
+ const line = result.changed_line || 0;
3088
+ const backup = result.backupPath
3089
+ ? `\nbackup: ${result.backupPath}${result.backupReused ? ' (reused)' : ''}`
3090
+ : '';
3091
+ const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}${backup}`;
3092
+ const diffPreview = result.diff_preview || '';
3093
+ if (diffPreview) {
3094
+ const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
3055
3095
  return `${summary}\n${trimmed}`;
3056
3096
  }
3057
3097
  return summary;
3058
3098
  },
3059
3099
 
3060
- delete(result) {
3061
- if (!result || typeof result !== 'object') return String(result);
3062
- if (result.ok === false) return JSON.stringify(result);
3063
- const kind = result.type || 'item';
3064
- const target = result.path || '';
3065
- return `[delete: ${kind}] deleted ${target}`;
3066
- },
3100
+ delete(result) {
3101
+ if (!result || typeof result !== 'object') return String(result);
3102
+ if (result.ok === false) return JSON.stringify(result);
3103
+ const kind = result.type || 'item';
3104
+ const target = result.path || '';
3105
+ const backup = result.backupPath
3106
+ ? `\nbackup: ${result.backupPath}${result.backupReused ? ' (reused)' : ''}`
3107
+ : '';
3108
+ return `[delete: ${kind}] deleted ${target}${backup}`;
3109
+ },
3067
3110
 
3068
3111
  run(result) {
3069
3112
  if (!result || typeof result !== 'object') return String(result);