@mindstudio-ai/remy 0.1.213 → 0.1.214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/headless.js CHANGED
@@ -724,88 +724,23 @@ ${loadPlanStatus()}
724
724
  import fs5 from "fs/promises";
725
725
 
726
726
  // src/tools/spec/_helpers.ts
727
- var HEADING_RE = /^(#{1,6})\s+(.+)$/;
728
- function parseHeadings(content) {
729
- const lines = content.split("\n");
730
- const headings = [];
731
- for (let i = 0; i < lines.length; i++) {
732
- const match = lines[i].match(HEADING_RE);
733
- if (match) {
734
- headings.push({
735
- level: match[1].length,
736
- text: match[2].trim(),
737
- startLine: i,
738
- contentStart: i + 1,
739
- contentEnd: lines.length
740
- // placeholder — resolved below
741
- });
742
- }
743
- }
744
- for (let i = 0; i < headings.length; i++) {
745
- const current = headings[i];
746
- let end = lines.length;
747
- for (let j = i + 1; j < headings.length; j++) {
748
- if (headings[j].level <= current.level) {
749
- end = headings[j].startLine;
750
- break;
751
- }
752
- }
753
- current.contentEnd = end;
754
- }
755
- return headings;
756
- }
757
- function resolveHeadingPath(content, headingPath) {
758
- const lines = content.split("\n");
759
- const headings = parseHeadings(content);
760
- if (headingPath === "") {
761
- const firstHeadingLine = headings.length > 0 ? headings[0].startLine : lines.length;
762
- return {
763
- startLine: 0,
764
- contentStart: 0,
765
- contentEnd: firstHeadingLine
766
- };
767
- }
768
- const segments = headingPath.split(">").map((s) => s.trim());
769
- let searchStart = 0;
770
- let searchEnd = lines.length;
771
- let resolved = null;
772
- for (let si = 0; si < segments.length; si++) {
773
- const segment = segments[si].toLowerCase();
774
- const candidates = headings.filter(
775
- (h) => h.startLine >= searchStart && h.startLine < searchEnd && h.text.toLowerCase() === segment
776
- );
777
- if (candidates.length === 0) {
778
- const available = headings.filter((h) => h.startLine >= searchStart && h.startLine < searchEnd).map((h) => `${"#".repeat(h.level)} ${h.text}`).join("\n");
779
- const searchedPath = segments.slice(0, si + 1).join(" > ");
780
- throw new Error(
781
- `Heading not found: "${searchedPath}"
782
-
783
- Available headings:
784
- ${available || "(none)"}`
785
- );
786
- }
787
- resolved = candidates[0];
788
- searchStart = resolved.contentStart;
789
- searchEnd = resolved.contentEnd;
790
- }
791
- return {
792
- startLine: resolved.startLine,
793
- contentStart: resolved.contentStart,
794
- contentEnd: resolved.contentEnd
795
- };
796
- }
797
727
  function validateSpecPath(filePath) {
798
728
  if (!filePath.startsWith("src/")) {
799
729
  throw new Error(`Spec tool paths must start with src/. Got: "${filePath}"`);
800
730
  }
801
731
  return filePath;
802
732
  }
803
- function getHeadingTree(content) {
804
- const headings = parseHeadings(content);
805
- if (headings.length === 0) {
806
- return "(no headings)";
733
+ function extractFrontmatter(content) {
734
+ const lines = content.split("\n");
735
+ if (lines.length === 0 || lines[0].trim() !== "---") {
736
+ return null;
807
737
  }
808
- return headings.map((h) => `${"#".repeat(h.level)} ${h.text}`).join("\n");
738
+ for (let i = 1; i < lines.length; i++) {
739
+ if (lines[i].trim() === "---") {
740
+ return lines.slice(0, i + 1).join("\n");
741
+ }
742
+ }
743
+ return null;
809
744
  }
810
745
 
811
746
  // src/tools/spec/readSpec.ts
@@ -981,11 +916,92 @@ ${unifiedDiff(input.path, oldContent ?? "", input.content)}`;
981
916
 
982
917
  // src/tools/spec/editSpec.ts
983
918
  import fs7 from "fs/promises";
919
+
920
+ // src/tools/code/editFile/_helpers.ts
921
+ function buildLineOffsets(content) {
922
+ const offsets = [0];
923
+ for (let i = 0; i < content.length; i++) {
924
+ if (content[i] === "\n") {
925
+ offsets.push(i + 1);
926
+ }
927
+ }
928
+ return offsets;
929
+ }
930
+ function lineAtOffset(offsets, charIndex) {
931
+ let lo = 0;
932
+ let hi = offsets.length - 1;
933
+ while (lo < hi) {
934
+ const mid = lo + hi + 1 >> 1;
935
+ if (offsets[mid] <= charIndex) {
936
+ lo = mid;
937
+ } else {
938
+ hi = mid - 1;
939
+ }
940
+ }
941
+ return lo + 1;
942
+ }
943
+ function findOccurrences(content, searchString) {
944
+ if (!searchString) {
945
+ return [];
946
+ }
947
+ const offsets = buildLineOffsets(content);
948
+ const results = [];
949
+ let pos = 0;
950
+ while (pos <= content.length - searchString.length) {
951
+ const idx = content.indexOf(searchString, pos);
952
+ if (idx === -1) {
953
+ break;
954
+ }
955
+ results.push({ index: idx, line: lineAtOffset(offsets, idx) });
956
+ pos = idx + 1;
957
+ }
958
+ return results;
959
+ }
960
+ function flexibleMatch(content, searchString) {
961
+ const contentLines = content.split("\n");
962
+ const searchLines = searchString.split("\n").map((l) => l.trimStart());
963
+ if (searchLines.length === 0) {
964
+ return null;
965
+ }
966
+ const matches = [];
967
+ for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
968
+ let allMatch = true;
969
+ for (let j = 0; j < searchLines.length; j++) {
970
+ if (contentLines[i + j].trimStart() !== searchLines[j]) {
971
+ allMatch = false;
972
+ break;
973
+ }
974
+ }
975
+ if (allMatch) {
976
+ matches.push(i);
977
+ }
978
+ }
979
+ if (matches.length !== 1) {
980
+ return null;
981
+ }
982
+ const startIdx = matches[0];
983
+ const matchedText = contentLines.slice(startIdx, startIdx + searchLines.length).join("\n");
984
+ const offsets = buildLineOffsets(content);
985
+ return {
986
+ matchedText,
987
+ index: offsets[startIdx],
988
+ line: startIdx + 1
989
+ // 1-based
990
+ };
991
+ }
992
+ function replaceAt(content, index, oldLength, newString) {
993
+ return content.slice(0, index) + newString + content.slice(index + oldLength);
994
+ }
995
+ function formatOccurrenceError(count, lines, filePath) {
996
+ return `old_string found ${count} times in ${filePath} (at lines ${lines.join(", ")}) \u2014 must be unique. Include more surrounding context to disambiguate, or use replace_all to replace every occurrence.`;
997
+ }
998
+
999
+ // src/tools/spec/editSpec.ts
984
1000
  var editSpecTool = {
985
1001
  clearable: true,
986
1002
  definition: {
987
1003
  name: "editSpec",
988
- description: 'Make targeted edits to a spec file by heading path. This is the primary tool for modifying existing specs. Each edit targets a section by its heading hierarchy (e.g., "Vendors > Approval Flow") and applies an operation. Multiple edits are applied in order.',
1004
+ description: "Make a targeted find/replace edit to a spec file (src/*.md). old_string must appear exactly once (minor indentation differences are handled automatically); set replace_all to true to replace every occurrence. Read the file with readSpec first so you match the exact text, and include the full enclosing structure rather than an inner fragment. The file's YAML frontmatter (the leading --- \u2026 --- block, which holds required fields like name) is protected \u2014 an edit that would remove or malform it is refused. For a full rewrite, use writeSpec.",
989
1005
  inputSchema: {
990
1006
  type: "object",
991
1007
  properties: {
@@ -993,31 +1009,20 @@ var editSpecTool = {
993
1009
  type: "string",
994
1010
  description: "File path relative to project root, must start with src/ (e.g., src/app.md)."
995
1011
  },
996
- edits: {
997
- type: "array",
998
- items: {
999
- type: "object",
1000
- properties: {
1001
- heading: {
1002
- type: "string",
1003
- description: 'Heading path using " > " to separate nesting levels (e.g., "Vendors > Approval Flow"). Empty string targets the preamble (content before the first heading).'
1004
- },
1005
- operation: {
1006
- type: "string",
1007
- enum: ["replace", "insert_after", "insert_before", "delete"],
1008
- description: "replace: swap content under this heading (keeps the heading line). insert_after: add content after this section. insert_before: add content before this heading. delete: remove this heading and all its content."
1009
- },
1010
- content: {
1011
- type: "string",
1012
- description: "MSFM markdown content for replace/insert operations. Not needed for delete."
1013
- }
1014
- },
1015
- required: ["heading", "operation"]
1016
- },
1017
- description: "Array of edits to apply in order."
1012
+ old_string: {
1013
+ type: "string",
1014
+ description: "The exact string to find and replace. Must be unique in the file unless replace_all is true."
1015
+ },
1016
+ new_string: {
1017
+ type: "string",
1018
+ description: "The replacement string."
1019
+ },
1020
+ replace_all: {
1021
+ type: "boolean",
1022
+ description: "If true, replace every occurrence of old_string in the file. Defaults to false."
1018
1023
  }
1019
1024
  },
1020
- required: ["path", "edits"]
1025
+ required: ["path", "old_string", "new_string"]
1021
1026
  }
1022
1027
  },
1023
1028
  async execute(input) {
@@ -1028,69 +1033,66 @@ var editSpecTool = {
1028
1033
  }
1029
1034
  const release = await acquireFileLock(input.path);
1030
1035
  try {
1031
- let originalContent;
1036
+ let content;
1032
1037
  try {
1033
- originalContent = await fs7.readFile(input.path, "utf-8");
1038
+ content = await fs7.readFile(input.path, "utf-8");
1034
1039
  } catch (err) {
1035
1040
  return `Error reading file: ${err.message}`;
1036
1041
  }
1037
- let content = originalContent;
1038
- for (const edit of input.edits) {
1039
- let range;
1040
- try {
1041
- range = resolveHeadingPath(content, edit.heading);
1042
- } catch (err) {
1043
- const tree = getHeadingTree(content);
1044
- return `Error: ${err.message}
1045
-
1046
- Document structure:
1047
- ${tree}`;
1042
+ const { old_string, new_string, replace_all } = input;
1043
+ const occurrences = findOccurrences(content, old_string);
1044
+ let updated;
1045
+ let notePrefix;
1046
+ if (replace_all) {
1047
+ if (occurrences.length === 0) {
1048
+ return `Error: old_string not found in ${input.path}.`;
1048
1049
  }
1049
- const lines = content.split("\n");
1050
- switch (edit.operation) {
1051
- case "replace": {
1052
- if (edit.content == null) {
1053
- return 'Error: "content" is required for replace operations.';
1054
- }
1055
- const contentLines = edit.content.split("\n");
1056
- lines.splice(
1057
- range.contentStart,
1058
- range.contentEnd - range.contentStart,
1059
- ...contentLines
1060
- );
1061
- break;
1062
- }
1063
- case "insert_after": {
1064
- if (edit.content == null) {
1065
- return 'Error: "content" is required for insert_after operations.';
1066
- }
1067
- const contentLines = edit.content.split("\n");
1068
- lines.splice(range.contentEnd, 0, ...contentLines);
1069
- break;
1070
- }
1071
- case "insert_before": {
1072
- if (edit.content == null) {
1073
- return 'Error: "content" is required for insert_before operations.';
1074
- }
1075
- const contentLines = edit.content.split("\n");
1076
- lines.splice(range.startLine, 0, ...contentLines);
1077
- break;
1078
- }
1079
- case "delete": {
1080
- lines.splice(range.startLine, range.contentEnd - range.startLine);
1081
- break;
1082
- }
1083
- default:
1084
- return `Error: Unknown operation "${edit.operation}". Use replace, insert_after, insert_before, or delete.`;
1050
+ updated = content;
1051
+ for (let i = occurrences.length - 1; i >= 0; i--) {
1052
+ updated = replaceAt(
1053
+ updated,
1054
+ occurrences[i].index,
1055
+ old_string.length,
1056
+ new_string
1057
+ );
1058
+ }
1059
+ notePrefix = `Replaced ${occurrences.length} occurrence${occurrences.length > 1 ? "s" : ""} in ${input.path}
1060
+ `;
1061
+ } else if (occurrences.length === 1) {
1062
+ updated = replaceAt(
1063
+ content,
1064
+ occurrences[0].index,
1065
+ old_string.length,
1066
+ new_string
1067
+ );
1068
+ notePrefix = `Updated ${input.path}
1069
+ `;
1070
+ } else if (occurrences.length > 1) {
1071
+ const lines = occurrences.map((o) => o.line);
1072
+ return `Error: ${formatOccurrenceError(occurrences.length, lines, input.path)}`;
1073
+ } else {
1074
+ const flex = flexibleMatch(content, old_string);
1075
+ if (!flex) {
1076
+ return `Error: old_string not found in ${input.path}. Make sure you've read the file first and copied the exact text.`;
1085
1077
  }
1086
- content = lines.join("\n");
1078
+ updated = replaceAt(
1079
+ content,
1080
+ flex.index,
1081
+ flex.matchedText.length,
1082
+ new_string
1083
+ );
1084
+ notePrefix = `Updated ${input.path} (matched with flexible whitespace at line ${flex.line})
1085
+ `;
1086
+ }
1087
+ if (extractFrontmatter(content) !== null && extractFrontmatter(updated) === null) {
1088
+ return `Error: that edit would remove or malform the spec's YAML frontmatter (the leading \`--- \u2026 ---\` block, which holds required fields like \`name\`). Narrow old_string to the body content you meant to change and leave the frontmatter block intact.`;
1087
1089
  }
1088
1090
  try {
1089
- await fs7.writeFile(input.path, content, "utf-8");
1091
+ await fs7.writeFile(input.path, updated, "utf-8");
1090
1092
  } catch (err) {
1091
1093
  return `Error writing file: ${err.message}`;
1092
1094
  }
1093
- return unifiedDiff(input.path, originalContent, content);
1095
+ return `${notePrefix}${unifiedDiff(input.path, content, updated)}`;
1094
1096
  } finally {
1095
1097
  release();
1096
1098
  }
@@ -1868,87 +1870,6 @@ ${unifiedDiff(input.path, oldContent ?? "", input.content)}`;
1868
1870
 
1869
1871
  // src/tools/code/editFile/index.ts
1870
1872
  import fs14 from "fs/promises";
1871
-
1872
- // src/tools/code/editFile/_helpers.ts
1873
- function buildLineOffsets(content) {
1874
- const offsets = [0];
1875
- for (let i = 0; i < content.length; i++) {
1876
- if (content[i] === "\n") {
1877
- offsets.push(i + 1);
1878
- }
1879
- }
1880
- return offsets;
1881
- }
1882
- function lineAtOffset(offsets, charIndex) {
1883
- let lo = 0;
1884
- let hi = offsets.length - 1;
1885
- while (lo < hi) {
1886
- const mid = lo + hi + 1 >> 1;
1887
- if (offsets[mid] <= charIndex) {
1888
- lo = mid;
1889
- } else {
1890
- hi = mid - 1;
1891
- }
1892
- }
1893
- return lo + 1;
1894
- }
1895
- function findOccurrences(content, searchString) {
1896
- if (!searchString) {
1897
- return [];
1898
- }
1899
- const offsets = buildLineOffsets(content);
1900
- const results = [];
1901
- let pos = 0;
1902
- while (pos <= content.length - searchString.length) {
1903
- const idx = content.indexOf(searchString, pos);
1904
- if (idx === -1) {
1905
- break;
1906
- }
1907
- results.push({ index: idx, line: lineAtOffset(offsets, idx) });
1908
- pos = idx + 1;
1909
- }
1910
- return results;
1911
- }
1912
- function flexibleMatch(content, searchString) {
1913
- const contentLines = content.split("\n");
1914
- const searchLines = searchString.split("\n").map((l) => l.trimStart());
1915
- if (searchLines.length === 0) {
1916
- return null;
1917
- }
1918
- const matches = [];
1919
- for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
1920
- let allMatch = true;
1921
- for (let j = 0; j < searchLines.length; j++) {
1922
- if (contentLines[i + j].trimStart() !== searchLines[j]) {
1923
- allMatch = false;
1924
- break;
1925
- }
1926
- }
1927
- if (allMatch) {
1928
- matches.push(i);
1929
- }
1930
- }
1931
- if (matches.length !== 1) {
1932
- return null;
1933
- }
1934
- const startIdx = matches[0];
1935
- const matchedText = contentLines.slice(startIdx, startIdx + searchLines.length).join("\n");
1936
- const offsets = buildLineOffsets(content);
1937
- return {
1938
- matchedText,
1939
- index: offsets[startIdx],
1940
- line: startIdx + 1
1941
- // 1-based
1942
- };
1943
- }
1944
- function replaceAt(content, index, oldLength, newString) {
1945
- return content.slice(0, index) + newString + content.slice(index + oldLength);
1946
- }
1947
- function formatOccurrenceError(count, lines, filePath) {
1948
- return `old_string found ${count} times in ${filePath} (at lines ${lines.join(", ")}) \u2014 must be unique. Include more surrounding context to disambiguate, or use replace_all to replace every occurrence.`;
1949
- }
1950
-
1951
- // src/tools/code/editFile/index.ts
1952
1873
  var editFileTool = {
1953
1874
  clearable: true,
1954
1875
  definition: {
package/dist/index.js CHANGED
@@ -369,93 +369,27 @@ var init_api = __esm({
369
369
  });
370
370
 
371
371
  // src/tools/spec/_helpers.ts
372
- function parseHeadings(content) {
373
- const lines = content.split("\n");
374
- const headings = [];
375
- for (let i = 0; i < lines.length; i++) {
376
- const match = lines[i].match(HEADING_RE);
377
- if (match) {
378
- headings.push({
379
- level: match[1].length,
380
- text: match[2].trim(),
381
- startLine: i,
382
- contentStart: i + 1,
383
- contentEnd: lines.length
384
- // placeholder — resolved below
385
- });
386
- }
387
- }
388
- for (let i = 0; i < headings.length; i++) {
389
- const current = headings[i];
390
- let end = lines.length;
391
- for (let j = i + 1; j < headings.length; j++) {
392
- if (headings[j].level <= current.level) {
393
- end = headings[j].startLine;
394
- break;
395
- }
396
- }
397
- current.contentEnd = end;
398
- }
399
- return headings;
400
- }
401
- function resolveHeadingPath(content, headingPath) {
402
- const lines = content.split("\n");
403
- const headings = parseHeadings(content);
404
- if (headingPath === "") {
405
- const firstHeadingLine = headings.length > 0 ? headings[0].startLine : lines.length;
406
- return {
407
- startLine: 0,
408
- contentStart: 0,
409
- contentEnd: firstHeadingLine
410
- };
411
- }
412
- const segments = headingPath.split(">").map((s) => s.trim());
413
- let searchStart = 0;
414
- let searchEnd = lines.length;
415
- let resolved = null;
416
- for (let si = 0; si < segments.length; si++) {
417
- const segment = segments[si].toLowerCase();
418
- const candidates = headings.filter(
419
- (h) => h.startLine >= searchStart && h.startLine < searchEnd && h.text.toLowerCase() === segment
420
- );
421
- if (candidates.length === 0) {
422
- const available = headings.filter((h) => h.startLine >= searchStart && h.startLine < searchEnd).map((h) => `${"#".repeat(h.level)} ${h.text}`).join("\n");
423
- const searchedPath = segments.slice(0, si + 1).join(" > ");
424
- throw new Error(
425
- `Heading not found: "${searchedPath}"
426
-
427
- Available headings:
428
- ${available || "(none)"}`
429
- );
430
- }
431
- resolved = candidates[0];
432
- searchStart = resolved.contentStart;
433
- searchEnd = resolved.contentEnd;
434
- }
435
- return {
436
- startLine: resolved.startLine,
437
- contentStart: resolved.contentStart,
438
- contentEnd: resolved.contentEnd
439
- };
440
- }
441
372
  function validateSpecPath(filePath) {
442
373
  if (!filePath.startsWith("src/")) {
443
374
  throw new Error(`Spec tool paths must start with src/. Got: "${filePath}"`);
444
375
  }
445
376
  return filePath;
446
377
  }
447
- function getHeadingTree(content) {
448
- const headings = parseHeadings(content);
449
- if (headings.length === 0) {
450
- return "(no headings)";
378
+ function extractFrontmatter(content) {
379
+ const lines = content.split("\n");
380
+ if (lines.length === 0 || lines[0].trim() !== "---") {
381
+ return null;
382
+ }
383
+ for (let i = 1; i < lines.length; i++) {
384
+ if (lines[i].trim() === "---") {
385
+ return lines.slice(0, i + 1).join("\n");
386
+ }
451
387
  }
452
- return headings.map((h) => `${"#".repeat(h.level)} ${h.text}`).join("\n");
388
+ return null;
453
389
  }
454
- var HEADING_RE;
455
390
  var init_helpers = __esm({
456
391
  "src/tools/spec/_helpers.ts"() {
457
392
  "use strict";
458
- HEADING_RE = /^(#{1,6})\s+(.+)$/;
459
393
  }
460
394
  });
461
395
 
@@ -657,6 +591,90 @@ ${unifiedDiff(input.path, oldContent ?? "", input.content)}`;
657
591
  }
658
592
  });
659
593
 
594
+ // src/tools/code/editFile/_helpers.ts
595
+ function buildLineOffsets(content) {
596
+ const offsets = [0];
597
+ for (let i = 0; i < content.length; i++) {
598
+ if (content[i] === "\n") {
599
+ offsets.push(i + 1);
600
+ }
601
+ }
602
+ return offsets;
603
+ }
604
+ function lineAtOffset(offsets, charIndex) {
605
+ let lo = 0;
606
+ let hi = offsets.length - 1;
607
+ while (lo < hi) {
608
+ const mid = lo + hi + 1 >> 1;
609
+ if (offsets[mid] <= charIndex) {
610
+ lo = mid;
611
+ } else {
612
+ hi = mid - 1;
613
+ }
614
+ }
615
+ return lo + 1;
616
+ }
617
+ function findOccurrences(content, searchString) {
618
+ if (!searchString) {
619
+ return [];
620
+ }
621
+ const offsets = buildLineOffsets(content);
622
+ const results = [];
623
+ let pos = 0;
624
+ while (pos <= content.length - searchString.length) {
625
+ const idx = content.indexOf(searchString, pos);
626
+ if (idx === -1) {
627
+ break;
628
+ }
629
+ results.push({ index: idx, line: lineAtOffset(offsets, idx) });
630
+ pos = idx + 1;
631
+ }
632
+ return results;
633
+ }
634
+ function flexibleMatch(content, searchString) {
635
+ const contentLines = content.split("\n");
636
+ const searchLines = searchString.split("\n").map((l) => l.trimStart());
637
+ if (searchLines.length === 0) {
638
+ return null;
639
+ }
640
+ const matches = [];
641
+ for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
642
+ let allMatch = true;
643
+ for (let j = 0; j < searchLines.length; j++) {
644
+ if (contentLines[i + j].trimStart() !== searchLines[j]) {
645
+ allMatch = false;
646
+ break;
647
+ }
648
+ }
649
+ if (allMatch) {
650
+ matches.push(i);
651
+ }
652
+ }
653
+ if (matches.length !== 1) {
654
+ return null;
655
+ }
656
+ const startIdx = matches[0];
657
+ const matchedText = contentLines.slice(startIdx, startIdx + searchLines.length).join("\n");
658
+ const offsets = buildLineOffsets(content);
659
+ return {
660
+ matchedText,
661
+ index: offsets[startIdx],
662
+ line: startIdx + 1
663
+ // 1-based
664
+ };
665
+ }
666
+ function replaceAt(content, index, oldLength, newString) {
667
+ return content.slice(0, index) + newString + content.slice(index + oldLength);
668
+ }
669
+ function formatOccurrenceError(count, lines, filePath) {
670
+ return `old_string found ${count} times in ${filePath} (at lines ${lines.join(", ")}) \u2014 must be unique. Include more surrounding context to disambiguate, or use replace_all to replace every occurrence.`;
671
+ }
672
+ var init_helpers2 = __esm({
673
+ "src/tools/code/editFile/_helpers.ts"() {
674
+ "use strict";
675
+ }
676
+ });
677
+
660
678
  // src/tools/spec/editSpec.ts
661
679
  import fs4 from "fs/promises";
662
680
  var editSpecTool;
@@ -664,13 +682,14 @@ var init_editSpec = __esm({
664
682
  "src/tools/spec/editSpec.ts"() {
665
683
  "use strict";
666
684
  init_helpers();
685
+ init_helpers2();
667
686
  init_diff();
668
687
  init_fileLock();
669
688
  editSpecTool = {
670
689
  clearable: true,
671
690
  definition: {
672
691
  name: "editSpec",
673
- description: 'Make targeted edits to a spec file by heading path. This is the primary tool for modifying existing specs. Each edit targets a section by its heading hierarchy (e.g., "Vendors > Approval Flow") and applies an operation. Multiple edits are applied in order.',
692
+ description: "Make a targeted find/replace edit to a spec file (src/*.md). old_string must appear exactly once (minor indentation differences are handled automatically); set replace_all to true to replace every occurrence. Read the file with readSpec first so you match the exact text, and include the full enclosing structure rather than an inner fragment. The file's YAML frontmatter (the leading --- \u2026 --- block, which holds required fields like name) is protected \u2014 an edit that would remove or malform it is refused. For a full rewrite, use writeSpec.",
674
693
  inputSchema: {
675
694
  type: "object",
676
695
  properties: {
@@ -678,31 +697,20 @@ var init_editSpec = __esm({
678
697
  type: "string",
679
698
  description: "File path relative to project root, must start with src/ (e.g., src/app.md)."
680
699
  },
681
- edits: {
682
- type: "array",
683
- items: {
684
- type: "object",
685
- properties: {
686
- heading: {
687
- type: "string",
688
- description: 'Heading path using " > " to separate nesting levels (e.g., "Vendors > Approval Flow"). Empty string targets the preamble (content before the first heading).'
689
- },
690
- operation: {
691
- type: "string",
692
- enum: ["replace", "insert_after", "insert_before", "delete"],
693
- description: "replace: swap content under this heading (keeps the heading line). insert_after: add content after this section. insert_before: add content before this heading. delete: remove this heading and all its content."
694
- },
695
- content: {
696
- type: "string",
697
- description: "MSFM markdown content for replace/insert operations. Not needed for delete."
698
- }
699
- },
700
- required: ["heading", "operation"]
701
- },
702
- description: "Array of edits to apply in order."
700
+ old_string: {
701
+ type: "string",
702
+ description: "The exact string to find and replace. Must be unique in the file unless replace_all is true."
703
+ },
704
+ new_string: {
705
+ type: "string",
706
+ description: "The replacement string."
707
+ },
708
+ replace_all: {
709
+ type: "boolean",
710
+ description: "If true, replace every occurrence of old_string in the file. Defaults to false."
703
711
  }
704
712
  },
705
- required: ["path", "edits"]
713
+ required: ["path", "old_string", "new_string"]
706
714
  }
707
715
  },
708
716
  async execute(input) {
@@ -713,69 +721,66 @@ var init_editSpec = __esm({
713
721
  }
714
722
  const release = await acquireFileLock(input.path);
715
723
  try {
716
- let originalContent;
724
+ let content;
717
725
  try {
718
- originalContent = await fs4.readFile(input.path, "utf-8");
726
+ content = await fs4.readFile(input.path, "utf-8");
719
727
  } catch (err) {
720
728
  return `Error reading file: ${err.message}`;
721
729
  }
722
- let content = originalContent;
723
- for (const edit of input.edits) {
724
- let range;
725
- try {
726
- range = resolveHeadingPath(content, edit.heading);
727
- } catch (err) {
728
- const tree = getHeadingTree(content);
729
- return `Error: ${err.message}
730
-
731
- Document structure:
732
- ${tree}`;
730
+ const { old_string, new_string, replace_all } = input;
731
+ const occurrences = findOccurrences(content, old_string);
732
+ let updated;
733
+ let notePrefix;
734
+ if (replace_all) {
735
+ if (occurrences.length === 0) {
736
+ return `Error: old_string not found in ${input.path}.`;
733
737
  }
734
- const lines = content.split("\n");
735
- switch (edit.operation) {
736
- case "replace": {
737
- if (edit.content == null) {
738
- return 'Error: "content" is required for replace operations.';
739
- }
740
- const contentLines = edit.content.split("\n");
741
- lines.splice(
742
- range.contentStart,
743
- range.contentEnd - range.contentStart,
744
- ...contentLines
745
- );
746
- break;
747
- }
748
- case "insert_after": {
749
- if (edit.content == null) {
750
- return 'Error: "content" is required for insert_after operations.';
751
- }
752
- const contentLines = edit.content.split("\n");
753
- lines.splice(range.contentEnd, 0, ...contentLines);
754
- break;
755
- }
756
- case "insert_before": {
757
- if (edit.content == null) {
758
- return 'Error: "content" is required for insert_before operations.';
759
- }
760
- const contentLines = edit.content.split("\n");
761
- lines.splice(range.startLine, 0, ...contentLines);
762
- break;
763
- }
764
- case "delete": {
765
- lines.splice(range.startLine, range.contentEnd - range.startLine);
766
- break;
767
- }
768
- default:
769
- return `Error: Unknown operation "${edit.operation}". Use replace, insert_after, insert_before, or delete.`;
738
+ updated = content;
739
+ for (let i = occurrences.length - 1; i >= 0; i--) {
740
+ updated = replaceAt(
741
+ updated,
742
+ occurrences[i].index,
743
+ old_string.length,
744
+ new_string
745
+ );
746
+ }
747
+ notePrefix = `Replaced ${occurrences.length} occurrence${occurrences.length > 1 ? "s" : ""} in ${input.path}
748
+ `;
749
+ } else if (occurrences.length === 1) {
750
+ updated = replaceAt(
751
+ content,
752
+ occurrences[0].index,
753
+ old_string.length,
754
+ new_string
755
+ );
756
+ notePrefix = `Updated ${input.path}
757
+ `;
758
+ } else if (occurrences.length > 1) {
759
+ const lines = occurrences.map((o) => o.line);
760
+ return `Error: ${formatOccurrenceError(occurrences.length, lines, input.path)}`;
761
+ } else {
762
+ const flex = flexibleMatch(content, old_string);
763
+ if (!flex) {
764
+ return `Error: old_string not found in ${input.path}. Make sure you've read the file first and copied the exact text.`;
770
765
  }
771
- content = lines.join("\n");
766
+ updated = replaceAt(
767
+ content,
768
+ flex.index,
769
+ flex.matchedText.length,
770
+ new_string
771
+ );
772
+ notePrefix = `Updated ${input.path} (matched with flexible whitespace at line ${flex.line})
773
+ `;
774
+ }
775
+ if (extractFrontmatter(content) !== null && extractFrontmatter(updated) === null) {
776
+ return `Error: that edit would remove or malform the spec's YAML frontmatter (the leading \`--- \u2026 ---\` block, which holds required fields like \`name\`). Narrow old_string to the body content you meant to change and leave the frontmatter block intact.`;
772
777
  }
773
778
  try {
774
- await fs4.writeFile(input.path, content, "utf-8");
779
+ await fs4.writeFile(input.path, updated, "utf-8");
775
780
  } catch (err) {
776
781
  return `Error writing file: ${err.message}`;
777
782
  }
778
- return unifiedDiff(input.path, originalContent, content);
783
+ return `${notePrefix}${unifiedDiff(input.path, content, updated)}`;
779
784
  } finally {
780
785
  release();
781
786
  }
@@ -2485,90 +2490,6 @@ ${unifiedDiff(input.path, oldContent ?? "", input.content)}`;
2485
2490
  }
2486
2491
  });
2487
2492
 
2488
- // src/tools/code/editFile/_helpers.ts
2489
- function buildLineOffsets(content) {
2490
- const offsets = [0];
2491
- for (let i = 0; i < content.length; i++) {
2492
- if (content[i] === "\n") {
2493
- offsets.push(i + 1);
2494
- }
2495
- }
2496
- return offsets;
2497
- }
2498
- function lineAtOffset(offsets, charIndex) {
2499
- let lo = 0;
2500
- let hi = offsets.length - 1;
2501
- while (lo < hi) {
2502
- const mid = lo + hi + 1 >> 1;
2503
- if (offsets[mid] <= charIndex) {
2504
- lo = mid;
2505
- } else {
2506
- hi = mid - 1;
2507
- }
2508
- }
2509
- return lo + 1;
2510
- }
2511
- function findOccurrences(content, searchString) {
2512
- if (!searchString) {
2513
- return [];
2514
- }
2515
- const offsets = buildLineOffsets(content);
2516
- const results = [];
2517
- let pos = 0;
2518
- while (pos <= content.length - searchString.length) {
2519
- const idx = content.indexOf(searchString, pos);
2520
- if (idx === -1) {
2521
- break;
2522
- }
2523
- results.push({ index: idx, line: lineAtOffset(offsets, idx) });
2524
- pos = idx + 1;
2525
- }
2526
- return results;
2527
- }
2528
- function flexibleMatch(content, searchString) {
2529
- const contentLines = content.split("\n");
2530
- const searchLines = searchString.split("\n").map((l) => l.trimStart());
2531
- if (searchLines.length === 0) {
2532
- return null;
2533
- }
2534
- const matches = [];
2535
- for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
2536
- let allMatch = true;
2537
- for (let j = 0; j < searchLines.length; j++) {
2538
- if (contentLines[i + j].trimStart() !== searchLines[j]) {
2539
- allMatch = false;
2540
- break;
2541
- }
2542
- }
2543
- if (allMatch) {
2544
- matches.push(i);
2545
- }
2546
- }
2547
- if (matches.length !== 1) {
2548
- return null;
2549
- }
2550
- const startIdx = matches[0];
2551
- const matchedText = contentLines.slice(startIdx, startIdx + searchLines.length).join("\n");
2552
- const offsets = buildLineOffsets(content);
2553
- return {
2554
- matchedText,
2555
- index: offsets[startIdx],
2556
- line: startIdx + 1
2557
- // 1-based
2558
- };
2559
- }
2560
- function replaceAt(content, index, oldLength, newString) {
2561
- return content.slice(0, index) + newString + content.slice(index + oldLength);
2562
- }
2563
- function formatOccurrenceError(count, lines, filePath) {
2564
- return `old_string found ${count} times in ${filePath} (at lines ${lines.join(", ")}) \u2014 must be unique. Include more surrounding context to disambiguate, or use replace_all to replace every occurrence.`;
2565
- }
2566
- var init_helpers2 = __esm({
2567
- "src/tools/code/editFile/_helpers.ts"() {
2568
- "use strict";
2569
- }
2570
- });
2571
-
2572
2493
  // src/tools/code/editFile/index.ts
2573
2494
  import fs13 from "fs/promises";
2574
2495
  var editFileTool;
@@ -90,5 +90,5 @@ When generated code exists in `dist/`, you have both spec tools and code tools.
90
90
  **Key principle: spec and code stay in sync.**
91
91
  - When editing the spec, also update the affected code in the same turn.
92
92
  - When the user asks for a code change that represents a behavioral change, also update the spec.
93
- - Spec tools (`readSpec`, `writeSpec`, `editSpec`, `listSpecFiles`) work on `src/` files.
93
+ - Spec tools work on `src/` files: `readSpec` to read (do this before editing), `editSpec` for targeted find/replace edits (same `old_string`/`new_string` model as `editFile`), `writeSpec` for a full-file write, `listSpecFiles` to see what exists.
94
94
  - Code tools (`readFile`, `writeFile`, `editFile`, etc.) work on `dist/` and other project files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.213",
3
+ "version": "0.1.214",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",