codemini-cli 0.6.3 → 0.6.5

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 (49) hide show
  1. package/codemini-web/dist/assets/{AboutDialog-jgqGjQgl.js → AboutDialog-BUp8EzDg.js} +2 -2
  2. package/codemini-web/dist/assets/CodeWikiPanel-Fp0VKdzo.js +1 -0
  3. package/codemini-web/dist/assets/ConfigDialog-DIpj779O.js +1 -0
  4. package/codemini-web/dist/assets/GitDiffDialog-ZLEuX8Qm.js +3 -0
  5. package/codemini-web/dist/assets/{MemoryDialog-BhxQgG0I.js → MemoryDialog-D2YbENVd.js} +3 -3
  6. package/codemini-web/dist/assets/MessageBubble-BIgpZsLn.js +12 -0
  7. package/codemini-web/dist/assets/PatchDiff-CvKNaHsw.js +230 -0
  8. package/codemini-web/dist/assets/ProjectSelector-DXIep3lE.js +1 -0
  9. package/codemini-web/dist/assets/{SkillDialog-DxS43NpR.js → SkillDialog-DjPF-XBx.js} +4 -4
  10. package/codemini-web/dist/assets/SoulDialog-BfIoKETs.js +1 -0
  11. package/codemini-web/dist/assets/chevron-right-CfNZHlyU.js +1 -0
  12. package/codemini-web/dist/assets/{chunk-BO2N2NFS-Budy_hfO.js → chunk-BO2N2NFS-DMUdjM9q.js} +6 -6
  13. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-CQS1PAvD.js → highlighted-body-OFNGDK62-8ch0jz7Z.js} +1 -1
  14. package/codemini-web/dist/assets/index-BhMtCC8_.js +65 -0
  15. package/codemini-web/dist/assets/index-DRXwJ-n_.css +2 -0
  16. package/codemini-web/dist/assets/input-CYpdNDlR.js +1 -0
  17. package/codemini-web/dist/assets/lib-BXWizt13.js +1 -0
  18. package/codemini-web/dist/assets/mermaid-GHXKKRXX-KBEtMEB9.js +1 -0
  19. package/codemini-web/dist/assets/{pencil-Ce_LFiEh.js → pencil-BdA2cEeE.js} +1 -1
  20. package/codemini-web/dist/assets/{refresh-cw-BKL-AZu5.js → refresh-cw-CJGgUGiS.js} +1 -1
  21. package/codemini-web/dist/assets/select-BLOccU1M.js +1 -0
  22. package/codemini-web/dist/assets/{trash-2-KmAlCwXd.js → trash-2-CQzNOch5.js} +1 -1
  23. package/codemini-web/dist/index.html +2 -2
  24. package/codemini-web/lib/runtime-bridge.js +332 -296
  25. package/codemini-web/server.js +319 -243
  26. package/package.json +1 -1
  27. package/src/core/agent-loop.js +188 -100
  28. package/src/core/chat-runtime.js +676 -571
  29. package/src/core/config-store.js +9 -3
  30. package/src/core/git-oplog-change-tracker.js +468 -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 +555 -434
  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/input-CNQgbKe6.js +0 -1
  46. package/codemini-web/dist/assets/lib-BOngVP_M.js +0 -11
  47. package/codemini-web/dist/assets/lib-DrOTTm_N.js +0 -1
  48. package/codemini-web/dist/assets/mermaid-GHXKKRXX-DrBu5KyC.js +0 -1
  49. 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,28 +1662,80 @@ function editResult(pathText, action, beforeContent, afterContent, changedLine =
1662
1662
  lines_added: changed.added,
1663
1663
  lines_removed: changed.removed
1664
1664
  };
1665
- }
1666
-
1665
+ }
1666
+
1667
1667
  function lineRangeToOffsets(content, startLineRaw, endLineRaw) {
1668
1668
  const lines = splitLines(content);
1669
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;
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
+ function normalizeNewlinesWithMap(text) {
1685
+ const source = String(text || '');
1686
+ const chars = [];
1687
+ const indexMap = [];
1688
+ for (let i = 0; i < source.length; i += 1) {
1689
+ const ch = source[i];
1690
+ if (ch === '\r') {
1691
+ chars.push('\n');
1692
+ indexMap.push(i);
1693
+ if (source[i + 1] === '\n') i += 1;
1694
+ continue;
1695
+ }
1696
+ chars.push(ch);
1697
+ indexMap.push(i);
1675
1698
  }
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;
1699
+ return { text: chars.join(''), indexMap };
1700
+ }
1701
+
1702
+ function detectEol(text) {
1703
+ const sample = String(text || '');
1704
+ const crlf = (sample.match(/\r\n/g) || []).length;
1705
+ const loneLf = (sample.match(/(?<!\r)\n/g) || []).length;
1706
+ const loneCr = (sample.match(/\r(?!\n)/g) || []).length;
1707
+ if (crlf >= loneLf && crlf >= loneCr && crlf > 0) return '\r\n';
1708
+ if (loneCr > loneLf && loneCr > 0) return '\r';
1709
+ return '\n';
1710
+ }
1711
+
1712
+ function applyEol(text, eol) {
1713
+ return String(text || '').replace(/\r\n|\r|\n/g, eol || '\n');
1714
+ }
1715
+
1716
+ function findLineEndingEquivalentMatches(content, oldText) {
1717
+ const normalizedOld = normalizeNewlinesWithMap(oldText).text;
1718
+ if (!normalizedOld) return [];
1719
+ const normalizedContent = normalizeNewlinesWithMap(content);
1720
+ const matches = [];
1721
+ let pos = 0;
1722
+ while (true) {
1723
+ const found = normalizedContent.text.indexOf(normalizedOld, pos);
1724
+ if (found === -1) break;
1725
+ const start = normalizedContent.indexMap[found] ?? 0;
1726
+ const endNorm = found + normalizedOld.length;
1727
+ const end = endNorm >= normalizedContent.text.length
1728
+ ? String(content || '').length
1729
+ : normalizedContent.indexMap[endNorm];
1730
+ matches.push({ start, end });
1731
+ pos = found + Math.max(1, normalizedOld.length);
1680
1732
  }
1681
- return { startLine, endLine, startOffset, endOffset };
1733
+ return matches;
1682
1734
  }
1683
1735
 
1684
1736
  async function replaceBlock(root, args, config = {}) {
1685
- const relativePath = String(args?.path || '').trim();
1686
- const newContent = String(args?.new_content || args?.content || '');
1737
+ const relativePath = String(args?.path || '').trim();
1738
+ const newContent = String(args?.new_content || args?.content || '');
1687
1739
  const target = args?.target || {};
1688
1740
  const state = await getFileState(root, relativePath, config);
1689
1741
  const resolved = resolveReplaceBlockTarget(state, target);
@@ -1700,67 +1752,93 @@ async function replaceBlock(root, args, config = {}) {
1700
1752
  return editResult(relativePath, 'replace_block', state.content, afterContent, resolved.start_line);
1701
1753
  }
1702
1754
 
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;
1755
+ async function replaceText(root, args, config = {}) {
1756
+ const relativePath = String(args?.path || '').trim();
1757
+ const oldText = String(args?.old_text || '');
1758
+ const newText = String(args?.new_text || '');
1759
+ const replaceAll = semanticBoolean(args?.replace_all ?? args?.replaceAll);
1760
+ const state = await getFileState(root, relativePath, config);
1761
+ if (!oldText) {
1762
+ throw new Error('replace_text requires old_text');
1763
+ }
1764
+ const rangeStart = Number(args?.start_line || args?.line);
1765
+ const rangeEnd = Number(args?.end_line || args?.line);
1766
+ const hasRange = Number.isFinite(rangeStart) && rangeStart > 0;
1715
1767
  const range = hasRange
1716
1768
  ? lineRangeToOffsets(state.content, rangeStart, Number.isFinite(rangeEnd) && rangeEnd >= rangeStart ? rangeEnd : rangeStart)
1717
1769
  : null;
1718
1770
  const searchContent = range ? state.content.slice(range.startOffset, range.endOffset) : state.content;
1719
1771
  const occurrences = searchContent.split(oldText).length - 1;
1720
- if (occurrences !== 1) {
1721
- if (replaceAll && occurrences > 0) {
1722
- const replaced = searchContent.replaceAll(oldText, newText);
1772
+ let newlineMatches = null;
1773
+ if (occurrences === 0 && /[\r\n]/.test(oldText)) {
1774
+ newlineMatches = findLineEndingEquivalentMatches(searchContent, oldText);
1775
+ if ((replaceAll && newlineMatches.length > 0) || newlineMatches.length === 1) {
1776
+ let cursor = 0;
1777
+ let replaced = '';
1778
+ for (const match of newlineMatches) {
1779
+ const originalMatch = searchContent.slice(match.start, match.end);
1780
+ replaced += searchContent.slice(cursor, match.start);
1781
+ replaced += applyEol(newText, detectEol(originalMatch));
1782
+ cursor = match.end;
1783
+ if (!replaceAll) break;
1784
+ }
1785
+ replaced += searchContent.slice(cursor);
1723
1786
  const afterContent = range
1724
1787
  ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1725
- : state.content.replaceAll(oldText, newText);
1788
+ : replaced;
1726
1789
  await fs.writeFile(state.target, afterContent, 'utf8');
1790
+ const first = newlineMatches[0];
1727
1791
  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;
1792
+ ? range.startLine + splitLines(searchContent.slice(0, first.start)).length - 1
1793
+ : splitLines(state.content.slice(0, first.start)).length;
1730
1794
  return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1731
1795
  }
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
- }
1796
+ }
1797
+ if (occurrences !== 1) {
1798
+ if (replaceAll && occurrences > 0) {
1799
+ const replaced = searchContent.replaceAll(oldText, newText);
1800
+ const afterContent = range
1801
+ ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1802
+ : state.content.replaceAll(oldText, newText);
1803
+ await fs.writeFile(state.target, afterContent, 'utf8');
1804
+ const changedLine = range
1805
+ ? range.startLine + splitLines(searchContent.slice(0, searchContent.indexOf(oldText))).length - 1
1806
+ : splitLines(state.content.slice(0, state.content.indexOf(oldText))).length;
1807
+ return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1808
+ }
1809
+ const baseLine = hasRange ? range.startLine : 1;
1810
+ const baseOffset = hasRange ? range.startOffset : 0;
1811
+ const lineDetails = [];
1812
+ let searchPos = 0;
1813
+ while (true) {
1814
+ const pos = searchContent.indexOf(oldText, searchPos);
1815
+ if (pos === -1) break;
1816
+ const lineNum = baseLine + splitLines(searchContent.slice(0, pos)).length - 1;
1817
+ const globalPos = baseOffset + pos;
1818
+ const lStart = state.content.lastIndexOf('\n', globalPos) + 1;
1819
+ const lEnd = state.content.indexOf('\n', globalPos);
1820
+ const lineText = state.content.slice(lStart, lEnd >= 0 ? lEnd : void 0).trim();
1821
+ lineDetails.push(` Line ${lineNum}: ${lineText}`);
1822
+ searchPos = pos + oldText.length;
1823
+ }
1747
1824
  const lineHint = lineDetails.length > 0 ? `\n${lineDetails.join('\n')}\n` : ' ';
1825
+ const effectiveOccurrences = newlineMatches?.length || occurrences;
1748
1826
  throw new Error(
1749
- occurrences === 0
1827
+ effectiveOccurrences === 0
1750
1828
  ? '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`
1829
+ : `replace_text old_text not unique; found ${effectiveOccurrences} occurrences:${lineHint}Use path:"${relativePath}:N-M" to narrow the range, set replace_all=true, or provide more unique old_text`
1752
1830
  );
1753
1831
  }
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
- }
1832
+ const replaced = searchContent.replace(oldText, newText);
1833
+ const afterContent = range
1834
+ ? `${state.content.slice(0, range.startOffset)}${replaced}${state.content.slice(range.endOffset)}`
1835
+ : state.content.replace(oldText, newText);
1836
+ await fs.writeFile(state.target, afterContent, 'utf8');
1837
+ const changedLine = range
1838
+ ? range.startLine + splitLines(searchContent.slice(0, searchContent.indexOf(oldText))).length - 1
1839
+ : splitLines(state.content.slice(0, state.content.indexOf(oldText))).length;
1840
+ return editResult(relativePath, 'replace_text', state.content, afterContent, changedLine);
1841
+ }
1764
1842
 
1765
1843
  async function insertRelative(root, args, mode, config = {}) {
1766
1844
  const relativePath = String(args?.path || '').trim();
@@ -1807,62 +1885,62 @@ async function openTarget(root, args, config = {}) {
1807
1885
  };
1808
1886
  }
1809
1887
 
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
- }
1888
+ function normalizeEditTargetArgs(args = {}) {
1889
+ const rawFile = String(args?.file || args?.path || args?.file_path || '').trim();
1890
+ const inlineRange = parseInlineRangePath(rawFile);
1891
+ const file = normalizeFilePathValue(rawFile, { stripInlineRange: true }).trim();
1892
+ const nestedEdit = args?.edit && typeof args.edit === 'object' ? args.edit : null;
1893
+ const startLine = args?.start_line ?? args?.line ?? inlineRange?.start_line;
1894
+ const endLine = args?.end_line ?? inlineRange?.end_line ?? args?.line;
1895
+ if (nestedEdit) {
1896
+ const normalizedEdit = { ...nestedEdit };
1897
+ if (normalizedEdit.new_content == null && normalizedEdit.content != null) {
1898
+ normalizedEdit.new_content = normalizedEdit.content;
1899
+ }
1900
+ if (normalizedEdit.old_text == null && normalizedEdit.old_string != null) {
1901
+ normalizedEdit.old_text = normalizedEdit.old_string;
1902
+ }
1903
+ if (normalizedEdit.new_text == null && normalizedEdit.content != null && normalizedEdit.old_text != null) {
1904
+ normalizedEdit.new_text = normalizedEdit.content;
1905
+ }
1906
+ if (normalizedEdit.new_text == null && normalizedEdit.new_string != null) {
1907
+ normalizedEdit.new_text = normalizedEdit.new_string;
1908
+ }
1909
+ return {
1910
+ path: file,
1911
+ file,
1912
+ start_line: startLine,
1913
+ end_line: endLine,
1914
+ ast_target: normalizedEdit.ast_target ?? args?.ast_target,
1915
+ edit: normalizedEdit
1916
+ };
1917
+ }
1840
1918
  const topLevelOldText = args?.old_text ?? args?.old_string;
1841
1919
  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,
1920
+ return {
1921
+ path: file,
1922
+ file,
1923
+ start_line: startLine,
1924
+ end_line: endLine,
1925
+ ast_target: args?.ast_target,
1926
+ edit: {
1927
+ kind: args?.kind,
1928
+ target: args?.target,
1851
1929
  new_content: args?.new_content ?? args?.content,
1852
1930
  old_text: args?.old_text,
1853
1931
  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
- }
1932
+ old_string: args?.old_string,
1933
+ new_string: args?.new_string,
1934
+ anchor_text: args?.anchor_text,
1935
+ content: args?.content,
1936
+ replace_all: args?.replace_all ?? args?.replaceAll
1937
+ }
1938
+ };
1939
+ }
1862
1940
 
1863
1941
  async function editTarget(root, args, config = {}) {
1864
- const normalized = normalizeEditTargetArgs(args);
1865
- const file = normalized.file || normalizeFilePathValue(args?.recent_file || '', { stripInlineRange: true }).trim();
1942
+ const normalized = normalizeEditTargetArgs(args);
1943
+ const file = normalized.file || normalizeFilePathValue(args?.recent_file || '', { stripInlineRange: true }).trim();
1866
1944
  const astTarget = normalized.ast_target;
1867
1945
  const edit = normalized.edit || {};
1868
1946
  let kind = String(edit.kind || '').trim();
@@ -1872,8 +1950,8 @@ async function editTarget(root, args, config = {}) {
1872
1950
  if (edit.new_text == null && edit.new_string != null) {
1873
1951
  edit.new_text = edit.new_string;
1874
1952
  }
1875
- const hasContent = edit.new_content != null || edit.content != null;
1876
- const hasExplicitRewrite = edit.kind === 'rewrite_file' || args?.kind === 'rewrite_file';
1953
+ const hasContent = edit.new_content != null || edit.content != null;
1954
+ const hasExplicitRewrite = edit.kind === 'rewrite_file' || args?.kind === 'rewrite_file';
1877
1955
  const hasTargetHint = Boolean(edit.symbol || args?.symbol || edit.line || args?.line || edit.target);
1878
1956
  if (!kind) {
1879
1957
  if (hasContent && hasTargetHint) {
@@ -1882,23 +1960,23 @@ async function editTarget(root, args, config = {}) {
1882
1960
  kind = 'replace_text';
1883
1961
  } else if ((edit.anchor_text != null || edit.target_text != null) && (edit.content != null || edit.new_content != null)) {
1884
1962
  kind = String(edit.position || edit.mode || args?.position || '').trim() === 'after' ? 'insert_after' : 'insert_before';
1885
- } else if (hasContent && hasExplicitRewrite) {
1886
- kind = 'rewrite_file';
1887
- }
1963
+ } else if (hasContent && hasExplicitRewrite) {
1964
+ kind = 'rewrite_file';
1965
+ }
1966
+ }
1967
+ if (!file || !kind) {
1968
+ const recentFile = String(args?.recent_file || '').trim();
1969
+ const rawArgs = typeof args?._raw === 'string' && args._raw.trim() ? ` Raw tool arguments: ${args._raw.trim()}.` : '';
1970
+ const missing = !file
1971
+ ? 'file path'
1972
+ : edit.old_text != null && edit.new_text == null && edit.content == null
1973
+ ? 'new_text'
1974
+ : 'edit operation';
1975
+ const hint = recentFile
1976
+ ? ` 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.`
1977
+ : ' 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.';
1978
+ throw new Error(`edit requires ${missing}.${rawArgs}${hint}`);
1888
1979
  }
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
1980
  if (astTarget) {
1903
1981
  if (kind !== 'replace_block') {
1904
1982
  throw new Error('AST-scoped edit only supports replace_block');
@@ -1942,16 +2020,16 @@ async function editTarget(root, args, config = {}) {
1942
2020
  }, config);
1943
2021
  }
1944
2022
  }
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
- }
2023
+ if (kind === 'replace_text') {
2024
+ return replaceText(root, {
2025
+ path: file,
2026
+ old_text: edit.old_text,
2027
+ new_text: edit.new_text,
2028
+ replace_all: edit.replace_all ?? args?.replace_all ?? args?.replaceAll,
2029
+ start_line: edit.start_line ?? normalized.start_line,
2030
+ end_line: edit.end_line ?? normalized.end_line
2031
+ }, config);
2032
+ }
1955
2033
  if (kind === 'insert_before') {
1956
2034
  return insertRelative(root, { path: file, anchor_text: edit.anchor_text, content: edit.content }, 'insert_before', config);
1957
2035
  }
@@ -1968,19 +2046,19 @@ async function editTarget(root, args, config = {}) {
1968
2046
  throw new Error(`edit does not support kind: ${kind}`);
1969
2047
  }
1970
2048
 
1971
- export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate, getPlanState, onPlanStateUpdate, fffAdapter }) {
2049
+ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate, getPlanState, onPlanStateUpdate, fffAdapter, backupManager }) {
1972
2050
  const emitSystemTool = (event) => {
1973
2051
  if (typeof onSystemEvent === 'function' && event) onSystemEvent(event);
1974
2052
  };
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);
2053
+ const astSelectionCache = new Map();
2054
+ let lastAstTarget = null;
2055
+ let lastReadPath = '';
2056
+ let lastReadRange = null;
2057
+ const rememberAstSelection = (filePath, astTarget) => {
2058
+ const key = normalizePath(filePath).trim();
2059
+ if (!key || !astTarget) return;
2060
+ lastAstTarget = astTarget;
2061
+ astSelectionCache.set(key, astTarget);
1984
2062
  };
1985
2063
  const hasExplicitBlockHints = (args = {}) =>
1986
2064
  Boolean(
@@ -1993,15 +2071,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1993
2071
  args?.edit?.line ||
1994
2072
  args?.edit?.target
1995
2073
  );
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
- };
2074
+ const resolveCachedAstTarget = (args = {}, { requireAstScope = false } = {}) => {
2075
+ const file = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.ast_target?.path || '', { stripInlineRange: true }).trim();
2076
+ if (args?.ast_target) return args.ast_target;
2077
+ if (file) {
2078
+ if (requireAstScope && hasExplicitBlockHints(args)) return null;
2079
+ return astSelectionCache.get(file) || null;
2080
+ }
2081
+ return lastAstTarget || null;
2082
+ };
2005
2083
  const ensureProjectIndex = async () => {
2006
2084
  const eventId = `project-index:${Date.now()}`;
2007
2085
  const name = 'project_index(.codemini/project-map.json,.codemini/file-index.json)';
@@ -2053,17 +2131,17 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2053
2131
  {
2054
2132
  type: 'function',
2055
2133
  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' },
2134
+ name: 'read',
2135
+ description:
2136
+ '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.',
2137
+ parameters: {
2138
+ type: 'object',
2139
+ properties: {
2140
+ path: { type: 'string', description: 'File path to read. You can also include an inline range like src/app.ts:10-40.' },
2141
+ file_path: { type: 'string', description: 'Alias for path' },
2142
+ file: { type: 'string', description: 'Alias for path' },
2143
+ start_line: { type: 'number', description: '1-based start line' },
2144
+ end_line: { type: 'number', description: 'Inclusive end line' },
2067
2145
  max_chars: { type: 'number', description: 'Max chars to return' },
2068
2146
  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
2147
  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 +2160,10 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2082
2160
  '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
2161
  parameters: {
2084
2162
  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' },
2163
+ properties: {
2164
+ pattern: { type: 'string', description: 'Search pattern' },
2165
+ path: { type: 'string', description: 'Directory or file to search. file_path/file/dir/directory/cwd are accepted aliases.' },
2166
+ regex: { type: 'boolean', description: 'Treat pattern as regex' },
2089
2167
  case_sensitive: { type: 'boolean', description: 'Case-sensitive matching' },
2090
2168
  max_results: { type: 'number', description: 'Max matches to return' },
2091
2169
  language: { type: 'string', description: 'Filter by language' },
@@ -2102,9 +2180,9 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2102
2180
  description: 'List files and directories in a workspace path. Use this for quick directory discovery before deeper reads.',
2103
2181
  parameters: {
2104
2182
  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' }
2183
+ properties: {
2184
+ path: { type: 'string', description: 'Directory path to list. file_path/file/dir/directory are accepted aliases.' },
2185
+ include_hidden: { type: 'boolean', description: 'Include dotfiles' }
2108
2186
  }
2109
2187
  }
2110
2188
  }
@@ -2112,9 +2190,9 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2112
2190
  {
2113
2191
  type: 'function',
2114
2192
  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.',
2193
+ name: 'query_project_index',
2194
+ description:
2195
+ '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
2196
  parameters: {
2119
2197
  type: 'object',
2120
2198
  properties: {
@@ -2130,24 +2208,24 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2130
2208
  {
2131
2209
  type: 'function',
2132
2210
  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' },
2211
+ name: 'edit',
2212
+ description:
2213
+ '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.',
2214
+ parameters: {
2215
+ type: 'object',
2216
+ properties: {
2217
+ path: { type: 'string', description: 'File path to edit. Inline ranges like src/app.js:10-30 are accepted.' },
2218
+ file_path: { type: 'string', description: 'Alias for path' },
2219
+ file: { type: 'string', description: 'Alias for path' },
2220
+ new_content: { type: 'string', description: 'Replacement content' },
2221
+ old_text: { type: 'string', description: 'Exact text to replace' },
2222
+ new_text: { type: 'string', description: 'Replacement text' },
2223
+ old_string: { type: 'string', description: 'Alias for old_text' },
2224
+ new_string: { type: 'string', description: 'Alias for new_text' },
2225
+ replace_all: { type: 'boolean', description: 'Replace all matching old_text occurrences' },
2226
+ start_line: { type: 'number', description: 'Optional range start for disambiguating old_text' },
2227
+ end_line: { type: 'number', description: 'Optional range end for disambiguating old_text' },
2228
+ anchor_text: { type: 'string', description: 'Anchor text for inserts' },
2151
2229
  content: { type: 'string', description: 'Content to insert or append' },
2152
2230
  position: { type: 'string', description: 'before or after' },
2153
2231
  kind: { type: 'string', description: 'replace_block, replace_text, insert_before, insert_after, or rewrite_file' },
@@ -2157,23 +2235,23 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2157
2235
  line: { type: 'number', description: 'Line to target' },
2158
2236
  edit: { type: 'object', description: 'Structured edit input' }
2159
2237
  },
2160
- required: ['path', 'content']
2161
- }
2162
- }
2163
- },
2238
+ required: ['path', 'content']
2239
+ }
2240
+ }
2241
+ },
2164
2242
  {
2165
2243
  type: 'function',
2166
2244
  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' },
2245
+ name: 'write',
2246
+ description:
2247
+ '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.',
2248
+ parameters: {
2249
+ type: 'object',
2250
+ properties: {
2251
+ path: { type: 'string', description: 'Required file path like src/app.js or pages/index.html. Never omit this.' },
2252
+ file_path: { type: 'string', description: 'Alias for path' },
2253
+ file: { type: 'string', description: 'Alias for path' },
2254
+ content: { type: 'string', description: 'Content to write' },
2177
2255
  append: { type: 'boolean', description: 'Append instead of overwrite' },
2178
2256
  full_file_rewrite: { type: 'boolean', description: 'Set true for whole-file rewrites' }
2179
2257
  },
@@ -2189,12 +2267,12 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2189
2267
  'Delete a file or directory inside the workspace. Missing targets fail. Workspace escape attempts are rejected.',
2190
2268
  parameters: {
2191
2269
  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
- },
2270
+ properties: {
2271
+ path: { type: 'string', description: 'File or directory path to delete. file_path/file/target are accepted aliases.' },
2272
+ file_path: { type: 'string', description: 'Alias for path' },
2273
+ file: { type: 'string', description: 'Alias for path' },
2274
+ target: { type: 'string', description: 'Alias for path' }
2275
+ },
2198
2276
  required: ['path']
2199
2277
  }
2200
2278
  }
@@ -2562,7 +2640,36 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2562
2640
  };
2563
2641
 
2564
2642
  const definitions = [...primaryDefinitions];
2565
- const activeFffAdapter = fffAdapter || createFffAdapter({ workspaceRoot, config });
2643
+ const activeFffAdapter = fffAdapter || createFffAdapter({ workspaceRoot, config });
2644
+ async function backupNonGitPathOnce(rawPath) {
2645
+ if (!backupManager || typeof backupManager.backupOnce !== 'function') return null;
2646
+ const normalized = normalizeFilePathValue(rawPath || '', { stripInlineRange: true }).trim();
2647
+ if (!normalized) return null;
2648
+ try {
2649
+ const backup = await backupManager.backupOnce(normalized);
2650
+ return backup?.ok ? backup : null;
2651
+ } catch (error) {
2652
+ return {
2653
+ ok: false,
2654
+ path: normalized,
2655
+ error: error instanceof Error ? error.message : String(error)
2656
+ };
2657
+ }
2658
+ }
2659
+ function attachBackup(result, backup) {
2660
+ if (!backup || !result || typeof result !== 'object') return result;
2661
+ return {
2662
+ ...result,
2663
+ non_git_backup: true,
2664
+ backupPath: backup.backupPath || '',
2665
+ backupRelativePath: backup.backupRelativePath || '',
2666
+ backupCreated: backup.created === true,
2667
+ backupReused: backup.reused === true,
2668
+ backupSkipped: backup.skipped === true || (!backup.backupPath && backup.existed === true),
2669
+ backupError: backup.error || '',
2670
+ backupReason: backup.reason || ''
2671
+ };
2672
+ }
2566
2673
  let fffConnected = false;
2567
2674
 
2568
2675
  async function ensureFffConnected() {
@@ -2595,8 +2702,8 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2595
2702
  return builtinGlob(workspaceRoot, args, config);
2596
2703
  }
2597
2704
 
2598
- async function list(args) {
2599
- const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'file_path', 'file', 'target']);
2705
+ async function list(args) {
2706
+ const normalizedArgs = normalizePathArgs(args, ['dir', 'directory', 'file_path', 'file', 'target']);
2600
2707
  if (!resolvesOutsideRoot(workspaceRoot, normalizedArgs?.path || '.') && activeFffAdapter?.list) {
2601
2708
  try {
2602
2709
  await ensureFffConnected();
@@ -2617,15 +2724,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2617
2724
  ...args,
2618
2725
  path: args?.path || directAstTarget?.path,
2619
2726
  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
- }
2727
+ });
2728
+ if (directAstTarget?.path) rememberAstSelection(directAstTarget.path, directAstTarget);
2729
+ const readPath = normalizePath(result?.path || directAstTarget?.path || '').trim();
2730
+ if (readPath) {
2731
+ lastReadPath = readPath;
2732
+ lastReadRange = null;
2733
+ }
2734
+ return { ...result, ast_target: directAstTarget };
2735
+ }
2629
2736
 
2630
2737
  if (inlineQuery) {
2631
2738
  const queryResult = await queryAst(workspaceRoot, args);
@@ -2646,30 +2753,30 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2646
2753
  path: firstTarget.path,
2647
2754
  ast_target: firstTarget
2648
2755
  });
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
- };
2756
+ const readPath = normalizePath(result?.path || firstTarget?.path || '').trim();
2757
+ if (readPath) {
2758
+ lastReadPath = readPath;
2759
+ lastReadRange = null;
2760
+ }
2761
+ return {
2762
+ path: result.path,
2763
+ language: result.language,
2764
+ node: result.node,
2765
+ content: result.content,
2766
+ ast_target: firstTarget,
2767
+ symbol: {
2768
+ symbol_id: `${result.path}#${firstTarget.name || firstTarget.node_type || `${result.node.start_line}-${result.node.end_line}`}`,
2769
+ type: result.node.node_type,
2770
+ file: result.path,
2771
+ range: {
2772
+ start_line: result.node.start_line,
2773
+ end_line: result.node.end_line
2774
+ }
2775
+ },
2776
+ query: inlineQuery,
2777
+ capture_name: String(args?.capture_name || '').trim() || undefined,
2778
+ matches: queryResult.matches.length
2779
+ };
2673
2780
  }
2674
2781
 
2675
2782
  const result = await readFile(workspaceRoot, {
@@ -2680,14 +2787,14 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2680
2787
  ? args.max_chars
2681
2788
  : config.context?.read_file_max_chars ?? 24000
2682
2789
  }, 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;
2790
+ const readPath = normalizePath(result?.path || args?.path || '').trim();
2791
+ if (readPath) {
2792
+ lastReadPath = readPath;
2793
+ lastReadRange = result?.phase === 'content'
2794
+ ? { path: readPath, start_line: result.start_line, end_line: result.end_line }
2795
+ : null;
2796
+ }
2797
+ return result;
2691
2798
  },
2692
2799
  query_project_index: async (args) => {
2693
2800
  await ensureProjectIndex();
@@ -2713,19 +2820,20 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2713
2820
  edit: async (args) => {
2714
2821
  await ensureProjectIndex();
2715
2822
  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
- : {};
2823
+ const hasReplaceTextArgs = args?.edit?.old_text != null || args?.old_text != null || args?.old_string != null;
2824
+ const astTarget = hasReplaceTextArgs || (normalizedKind && normalizedKind !== 'replace_block')
2825
+ ? null
2826
+ : resolveCachedAstTarget(args, { requireAstScope: normalizedKind === 'replace_block' });
2827
+ const editPath = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.ast_target?.path || args?.edit?.target?.path || '', { stripInlineRange: true }).trim();
2828
+ const shouldUseRecentReadRange =
2829
+ editPath &&
2830
+ lastReadRange?.path === editPath &&
2831
+ !Number.isFinite(Number(args?.start_line || args?.line || args?.edit?.start_line)) &&
2832
+ !Number.isFinite(Number(args?.end_line || args?.edit?.end_line));
2833
+ const rangeArgs = shouldUseRecentReadRange
2834
+ ? { start_line: lastReadRange.start_line, end_line: lastReadRange.end_line }
2835
+ : {};
2836
+ const backup = await backupNonGitPathOnce(editPath || astTarget?.path);
2729
2837
  const result = await editTarget(
2730
2838
  workspaceRoot,
2731
2839
  astTarget
@@ -2733,21 +2841,25 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2733
2841
  : { ...args, ...rangeArgs, recent_file: lastReadPath },
2734
2842
  config
2735
2843
  );
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
- }, {
2844
+ if (result?.path) await refreshProjectFile(result.path);
2845
+ return attachBackup(result, backup);
2846
+ },
2847
+ write: async (args) => {
2848
+ await ensureProjectIndex();
2849
+ const writePath = normalizeFilePathValue(args?.path || args?.file || args?.file_path || '', { stripInlineRange: true }).trim();
2850
+ const backup = await backupNonGitPathOnce(writePath);
2851
+ const result = await writeFile(workspaceRoot, args, config);
2852
+ if (result?.path) await refreshProjectFile(result.path);
2853
+ return attachBackup(result, backup);
2854
+ },
2855
+ delete: Object.assign(async (args) => {
2856
+ await ensureProjectIndex();
2857
+ const deletePathValue = normalizeFilePathValue(args?.path || args?.file || args?.file_path || args?.target || '', { stripInlineRange: true }).trim();
2858
+ const backup = await backupNonGitPathOnce(deletePathValue);
2859
+ const result = await deletePath(workspaceRoot, args, config);
2860
+ if (result?.path) await refreshProjectFile(result.path);
2861
+ return attachBackup(result, backup);
2862
+ }, {
2751
2863
  prepareApproval: async (args) => {
2752
2864
  const target = await prepareDeleteTarget(workspaceRoot, args, config);
2753
2865
  return {
@@ -3029,41 +3141,50 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
3029
3141
  return lines.join('\n');
3030
3142
  },
3031
3143
 
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;
3144
+ edit(result) {
3145
+ if (!result || typeof result !== 'object') return String(result);
3146
+ const p = result.path || '';
3147
+ const action = result.action || '';
3148
+ const line = result.changed_line || 0;
3149
+ const backup = result.backupPath
3150
+ ? `\nbackup: ${result.backupPath}${result.backupReused ? ' (reused)' : ''}`
3151
+ : '';
3152
+ const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}${backup}`;
3153
+ const diffPreview = result.diff_preview || '';
3154
+ if (diffPreview) {
3155
+ const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
3041
3156
  return `${summary}\n${trimmed}`;
3042
3157
  }
3043
3158
  return summary + (result.ok !== false ? '' : ` [FAILED: ${result.error || 'unknown'}]`);
3044
3159
  },
3045
3160
 
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;
3161
+ write(result) {
3162
+ if (!result || typeof result !== 'object') return String(result);
3163
+ const p = result.path || '';
3164
+ const action = result.action || 'write';
3165
+ const line = result.changed_line || 0;
3166
+ const backup = result.backupPath
3167
+ ? `\nbackup: ${result.backupPath}${result.backupReused ? ' (reused)' : ''}`
3168
+ : '';
3169
+ const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}${backup}`;
3170
+ const diffPreview = result.diff_preview || '';
3171
+ if (diffPreview) {
3172
+ const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
3055
3173
  return `${summary}\n${trimmed}`;
3056
3174
  }
3057
3175
  return summary;
3058
3176
  },
3059
3177
 
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
- },
3178
+ delete(result) {
3179
+ if (!result || typeof result !== 'object') return String(result);
3180
+ if (result.ok === false) return JSON.stringify(result);
3181
+ const kind = result.type || 'item';
3182
+ const target = result.path || '';
3183
+ const backup = result.backupPath
3184
+ ? `\nbackup: ${result.backupPath}${result.backupReused ? ' (reused)' : ''}`
3185
+ : '';
3186
+ return `[delete: ${kind}] deleted ${target}${backup}`;
3187
+ },
3067
3188
 
3068
3189
  run(result) {
3069
3190
  if (!result || typeof result !== 'object') return String(result);