@levnikolaevich/hex-line-mcp 1.4.0 → 1.6.0

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/server.mjs CHANGED
@@ -109,64 +109,6 @@ async function checkForUpdates(packageName, currentVersion) {
109
109
  // lib/read.mjs
110
110
  import { statSync as statSync4 } from "node:fs";
111
111
 
112
- // ../hex-common/src/text-protocol/hash.mjs
113
- var FNV_OFFSET = 2166136261;
114
- var FNV_PRIME = 16777619;
115
- var TAG_CHARS = "abcdefghijklmnopqrstuvwxyz234567";
116
- function fnv1a(str) {
117
- const normalized = str.replace(/\r$/, "").replace(/\s+/g, "");
118
- let h = FNV_OFFSET;
119
- for (let i = 0; i < normalized.length; i++) {
120
- let code = normalized.charCodeAt(i);
121
- if (code >= 55296 && code <= 56319 && i + 1 < normalized.length) {
122
- const lo = normalized.charCodeAt(i + 1);
123
- if (lo >= 56320 && lo <= 57343) {
124
- code = (code - 55296 << 10) + (lo - 56320) + 65536;
125
- i++;
126
- }
127
- }
128
- if (code < 128) {
129
- h = Math.imul(h ^ code, FNV_PRIME) >>> 0;
130
- } else if (code < 2048) {
131
- h = Math.imul(h ^ (192 | code >> 6), FNV_PRIME) >>> 0;
132
- h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
133
- } else if (code < 65536) {
134
- h = Math.imul(h ^ (224 | code >> 12), FNV_PRIME) >>> 0;
135
- h = Math.imul(h ^ (128 | code >> 6 & 63), FNV_PRIME) >>> 0;
136
- h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
137
- } else {
138
- h = Math.imul(h ^ (240 | code >> 18), FNV_PRIME) >>> 0;
139
- h = Math.imul(h ^ (128 | code >> 12 & 63), FNV_PRIME) >>> 0;
140
- h = Math.imul(h ^ (128 | code >> 6 & 63), FNV_PRIME) >>> 0;
141
- h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
142
- }
143
- }
144
- return h;
145
- }
146
- function lineTag(hash32) {
147
- return TAG_CHARS[hash32 & 31] + TAG_CHARS[hash32 >>> 8 & 31];
148
- }
149
- function rangeChecksum(lineHashes, startLine, endLine) {
150
- let acc = FNV_OFFSET;
151
- for (const h of lineHashes) {
152
- acc = Math.imul(acc ^ h & 255, FNV_PRIME) >>> 0;
153
- acc = Math.imul(acc ^ h >>> 8 & 255, FNV_PRIME) >>> 0;
154
- acc = Math.imul(acc ^ h >>> 16 & 255, FNV_PRIME) >>> 0;
155
- acc = Math.imul(acc ^ h >>> 24 & 255, FNV_PRIME) >>> 0;
156
- }
157
- return `${startLine}-${endLine}:${acc.toString(16).padStart(8, "0")}`;
158
- }
159
- function parseRef(ref) {
160
- const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
161
- if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12" (tag.lineNum)`);
162
- return { tag: m[1], line: parseInt(m[2], 10) };
163
- }
164
- function parseChecksum(cs) {
165
- const m = cs.trim().match(/^(\d+)-(\d+):([0-9a-f]{8})$/);
166
- if (!m) throw new Error(`Bad checksum: "${cs}". Expected "1-50:f7e2a1b0"`);
167
- return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
168
- }
169
-
170
112
  // lib/security.mjs
171
113
  import { realpathSync, statSync as statSync2, existsSync, openSync, readSync, closeSync } from "node:fs";
172
114
  import { resolve, isAbsolute, dirname } from "node:path";
@@ -476,9 +418,69 @@ function findProjectRoot(filePath) {
476
418
  return null;
477
419
  }
478
420
 
479
- // lib/revisions.mjs
421
+ // lib/snapshot.mjs
480
422
  import { statSync as statSync3 } from "node:fs";
481
423
  import { diffLines } from "diff";
424
+
425
+ // ../hex-common/src/text-protocol/hash.mjs
426
+ var FNV_OFFSET = 2166136261;
427
+ var FNV_PRIME = 16777619;
428
+ var TAG_CHARS = "abcdefghijklmnopqrstuvwxyz234567";
429
+ function fnv1a(str) {
430
+ const normalized = str.replace(/\r$/, "").replace(/\s+/g, "");
431
+ let h = FNV_OFFSET;
432
+ for (let i = 0; i < normalized.length; i++) {
433
+ let code = normalized.charCodeAt(i);
434
+ if (code >= 55296 && code <= 56319 && i + 1 < normalized.length) {
435
+ const lo = normalized.charCodeAt(i + 1);
436
+ if (lo >= 56320 && lo <= 57343) {
437
+ code = (code - 55296 << 10) + (lo - 56320) + 65536;
438
+ i++;
439
+ }
440
+ }
441
+ if (code < 128) {
442
+ h = Math.imul(h ^ code, FNV_PRIME) >>> 0;
443
+ } else if (code < 2048) {
444
+ h = Math.imul(h ^ (192 | code >> 6), FNV_PRIME) >>> 0;
445
+ h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
446
+ } else if (code < 65536) {
447
+ h = Math.imul(h ^ (224 | code >> 12), FNV_PRIME) >>> 0;
448
+ h = Math.imul(h ^ (128 | code >> 6 & 63), FNV_PRIME) >>> 0;
449
+ h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
450
+ } else {
451
+ h = Math.imul(h ^ (240 | code >> 18), FNV_PRIME) >>> 0;
452
+ h = Math.imul(h ^ (128 | code >> 12 & 63), FNV_PRIME) >>> 0;
453
+ h = Math.imul(h ^ (128 | code >> 6 & 63), FNV_PRIME) >>> 0;
454
+ h = Math.imul(h ^ (128 | code & 63), FNV_PRIME) >>> 0;
455
+ }
456
+ }
457
+ return h;
458
+ }
459
+ function lineTag(hash32) {
460
+ return TAG_CHARS[hash32 & 31] + TAG_CHARS[hash32 >>> 8 & 31];
461
+ }
462
+ function rangeChecksum(lineHashes, startLine, endLine) {
463
+ let acc = FNV_OFFSET;
464
+ for (const h of lineHashes) {
465
+ acc = Math.imul(acc ^ h & 255, FNV_PRIME) >>> 0;
466
+ acc = Math.imul(acc ^ h >>> 8 & 255, FNV_PRIME) >>> 0;
467
+ acc = Math.imul(acc ^ h >>> 16 & 255, FNV_PRIME) >>> 0;
468
+ acc = Math.imul(acc ^ h >>> 24 & 255, FNV_PRIME) >>> 0;
469
+ }
470
+ return `${startLine}-${endLine}:${acc.toString(16).padStart(8, "0")}`;
471
+ }
472
+ function parseRef(ref) {
473
+ const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
474
+ if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12" (tag.lineNum)`);
475
+ return { tag: m[1], line: parseInt(m[2], 10) };
476
+ }
477
+ function parseChecksum(cs) {
478
+ const m = cs.trim().match(/^(\d+)-(\d+):([0-9a-f]{8})$/);
479
+ if (!m) throw new Error(`Bad checksum: "${cs}". Expected "1-50:f7e2a1b0"`);
480
+ return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
481
+ }
482
+
483
+ // lib/snapshot.mjs
482
484
  var MAX_FILES = 200;
483
485
  var MAX_REVISIONS_PER_FILE = 5;
484
486
  var TTL_MS = 5 * 60 * 1e3;
@@ -658,17 +660,223 @@ function getSnapshotByRevision(revision) {
658
660
  return revisionsById.get(revision) || null;
659
661
  }
660
662
  function overlapsChangedRanges(ranges, startLine, endLine) {
661
- return (ranges || []).some((range) => range.start <= endLine && startLine <= range.end);
663
+ return (ranges || []).some((range) => range.start <= endLine && range.end >= startLine);
662
664
  }
663
665
  function buildRangeChecksum(snapshot, startLine, endLine) {
664
- const startIdx = startLine - 1;
665
- const endIdx = endLine - 1;
666
- if (startIdx < 0 || endIdx >= snapshot.lineHashes.length || startIdx > endIdx) return null;
667
- return rangeChecksum(snapshot.lineHashes.slice(startIdx, endIdx + 1), startLine, endLine);
666
+ if (startLine < 1 || endLine > snapshot.lineHashes.length || startLine > endLine) return null;
667
+ return rangeChecksum(snapshot.lineHashes.slice(startLine - 1, endLine), startLine, endLine);
668
+ }
669
+
670
+ // lib/block-protocol.mjs
671
+ function normalizeEntry(entry) {
672
+ const text = String(entry.text ?? "");
673
+ const hash32 = entry.hash32 ?? fnv1a(text);
674
+ return {
675
+ lineNumber: entry.lineNumber,
676
+ text,
677
+ hash32,
678
+ tag: entry.tag ?? lineTag(hash32),
679
+ role: entry.role ?? "content",
680
+ annotation: entry.annotation ?? ""
681
+ };
682
+ }
683
+ function renderRequestedSpan(block) {
684
+ if (block.requestedStartLine === null || block.requestedEndLine === null) return null;
685
+ if (block.requestedStartLine === block.startLine && block.requestedEndLine === block.endLine) return null;
686
+ return `requested_span: ${block.requestedStartLine}-${block.requestedEndLine}`;
687
+ }
688
+ function renderBaseEntry(entry, plain = false) {
689
+ return plain ? `${entry.lineNumber}|${entry.text}` : `${entry.tag}.${entry.lineNumber} ${entry.text}`;
690
+ }
691
+ function createSnapshotEntries(snapshot, startLine, endLine, extra = {}) {
692
+ const entries = [];
693
+ for (let lineNumber = startLine; lineNumber <= endLine; lineNumber++) {
694
+ const idx = lineNumber - 1;
695
+ entries.push(normalizeEntry({
696
+ lineNumber,
697
+ text: snapshot.lines[idx],
698
+ hash32: snapshot.lineHashes[idx],
699
+ ...extra
700
+ }));
701
+ }
702
+ return entries;
703
+ }
704
+ function buildEditReadyBlock({ path, kind, entries, requestedStartLine = null, requestedEndLine = null, meta = {} }) {
705
+ if (!Array.isArray(entries) || entries.length === 0) {
706
+ throw new Error("EditReadyBlock requires at least one entry");
707
+ }
708
+ const normalized = entries.map(normalizeEntry).sort((a, b) => a.lineNumber - b.lineNumber);
709
+ const startLine = normalized[0].lineNumber;
710
+ const endLine = normalized[normalized.length - 1].lineNumber;
711
+ return {
712
+ type: "edit_ready_block",
713
+ path,
714
+ kind,
715
+ startLine,
716
+ endLine,
717
+ requestedStartLine: requestedStartLine ?? startLine,
718
+ requestedEndLine: requestedEndLine ?? endLine,
719
+ entries: normalized,
720
+ checksum: rangeChecksum(normalized.map((entry) => entry.hash32), startLine, endLine),
721
+ meta
722
+ };
723
+ }
724
+ function buildDiagnosticBlock({
725
+ path = "",
726
+ kind,
727
+ message,
728
+ requestedStartLine = null,
729
+ requestedEndLine = null,
730
+ startLine = null,
731
+ endLine = null,
732
+ meta = {}
733
+ }) {
734
+ return {
735
+ type: "diagnostic_block",
736
+ path,
737
+ kind,
738
+ message,
739
+ requestedStartLine,
740
+ requestedEndLine,
741
+ startLine,
742
+ endLine,
743
+ meta
744
+ };
745
+ }
746
+ function serializeReadEntry(entry, opts = {}) {
747
+ return renderBaseEntry(entry, opts.plain);
748
+ }
749
+ function serializeSearchEntry(entry, opts = {}) {
750
+ const body = renderBaseEntry(entry, opts.plain);
751
+ if (opts.plain) return body;
752
+ const prefix = entry.role === "match" ? ">>" : " ";
753
+ const suffix = entry.annotation ? ` ${entry.annotation}` : "";
754
+ return `${prefix}${body}${suffix}`;
755
+ }
756
+ function serializeReadBlock(block, opts = {}) {
757
+ if (block.type !== "edit_ready_block") return serializeDiagnosticBlock(block);
758
+ const lines = [
759
+ `block: ${block.kind}`,
760
+ `span: ${block.startLine}-${block.endLine}`
761
+ ];
762
+ const requestedSpan = renderRequestedSpan(block);
763
+ if (requestedSpan) lines.push(requestedSpan);
764
+ lines.push(...block.entries.map((entry) => serializeReadEntry(entry, opts)));
765
+ lines.push(`checksum: ${block.checksum}`);
766
+ return lines.join("\n");
767
+ }
768
+ function serializeSearchBlock(block, opts = {}) {
769
+ if (block.type !== "edit_ready_block") return serializeDiagnosticBlock(block);
770
+ const lines = [
771
+ `block: ${block.kind}`,
772
+ `file: ${block.path}`,
773
+ `span: ${block.startLine}-${block.endLine}`
774
+ ];
775
+ const requestedSpan = renderRequestedSpan(block);
776
+ if (requestedSpan) lines.push(requestedSpan);
777
+ if (Array.isArray(block.meta.matchLines) && block.meta.matchLines.length > 0) {
778
+ lines.push(`match_lines: ${block.meta.matchLines.join(",")}`);
779
+ }
780
+ if (block.meta.summary) lines.push(`summary: ${block.meta.summary}`);
781
+ lines.push(...block.entries.map((entry) => serializeSearchEntry(entry, opts)));
782
+ lines.push(`checksum: ${block.checksum}`);
783
+ return lines.join("\n");
784
+ }
785
+ function serializeDiagnosticBlock(block) {
786
+ const lines = ["block: diagnostic"];
787
+ if (block.path) lines.push(`file: ${block.path}`);
788
+ lines.push(`kind: ${block.kind}`);
789
+ if (block.startLine !== null && block.endLine !== null) {
790
+ lines.push(`span: ${block.startLine}-${block.endLine}`);
791
+ }
792
+ if (block.requestedStartLine !== null && block.requestedEndLine !== null) {
793
+ lines.push(`requested_span: ${block.requestedStartLine}-${block.requestedEndLine}`);
794
+ }
795
+ lines.push(`message: ${block.message}`);
796
+ return lines.join("\n");
668
797
  }
669
798
 
670
799
  // lib/read.mjs
671
800
  var DEFAULT_LIMIT = 2e3;
801
+ function parseRangeEntry(entry, total) {
802
+ if (typeof entry === "string") {
803
+ const match = entry.trim().match(/^(\d+)(?:-(\d*)?)?$/);
804
+ if (!match) throw new Error(`Invalid range "${entry}". Use "10", "10-25", or "10-"`);
805
+ const start2 = Number(match[1]);
806
+ const end2 = match[2] === void 0 || match[2] === "" ? total : Number(match[2]);
807
+ return { start: start2, end: end2 };
808
+ }
809
+ if (!entry || typeof entry !== "object") {
810
+ throw new Error("ranges entries must be strings or {start,end} objects");
811
+ }
812
+ const start = Number(entry.start ?? 1);
813
+ const end = entry.end === void 0 || entry.end === null ? total : Number(entry.end);
814
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
815
+ throw new Error("ranges entries must contain numeric start/end values");
816
+ }
817
+ return { start, end };
818
+ }
819
+ function normalizeRange(entry, total) {
820
+ const parsed = parseRangeEntry(entry, total);
821
+ if (!Number.isInteger(parsed.start) || !Number.isInteger(parsed.end)) {
822
+ throw new Error("ranges entries must resolve to integer start/end values");
823
+ }
824
+ if (parsed.start > parsed.end) {
825
+ return buildDiagnosticBlock({
826
+ kind: "invalid_range",
827
+ message: `Requested range ${parsed.start}-${parsed.end} has start greater than end.`,
828
+ requestedStartLine: parsed.start,
829
+ requestedEndLine: parsed.end
830
+ });
831
+ }
832
+ if (parsed.end < 1 || parsed.start > total) {
833
+ return buildDiagnosticBlock({
834
+ kind: "invalid_range",
835
+ message: `Requested range ${parsed.start}-${parsed.end} is outside file length ${total}.`,
836
+ requestedStartLine: parsed.start,
837
+ requestedEndLine: parsed.end
838
+ });
839
+ }
840
+ return {
841
+ requestedStartLine: parsed.start,
842
+ requestedEndLine: parsed.end,
843
+ startLine: Math.max(1, parsed.start),
844
+ endLine: Math.min(total, parsed.end)
845
+ };
846
+ }
847
+ function buildReadBlock(snapshot, range, plain, remainingChars) {
848
+ const entries = [];
849
+ let nextBudget = remainingChars;
850
+ let cappedAtLine = 0;
851
+ for (let lineNumber = range.startLine; lineNumber <= range.endLine; lineNumber++) {
852
+ const entry = createSnapshotEntries(snapshot, lineNumber, lineNumber)[0];
853
+ const rendered = serializeReadEntry(entry, { plain });
854
+ const nextCost = rendered.length + 1;
855
+ if (entries.length > 0 && nextBudget - nextCost < 0) {
856
+ cappedAtLine = lineNumber;
857
+ break;
858
+ }
859
+ entries.push(entry);
860
+ nextBudget -= nextCost;
861
+ }
862
+ if (entries.length === 0) {
863
+ const entry = createSnapshotEntries(snapshot, range.startLine, range.startLine)[0];
864
+ entries.push(entry);
865
+ nextBudget -= serializeReadEntry(entry, { plain }).length + 1;
866
+ if (range.endLine > range.startLine) cappedAtLine = range.startLine + 1;
867
+ }
868
+ return {
869
+ block: buildEditReadyBlock({
870
+ path: snapshot.path,
871
+ kind: "read_range",
872
+ entries,
873
+ requestedStartLine: range.requestedStartLine,
874
+ requestedEndLine: range.requestedEndLine
875
+ }),
876
+ remainingChars: nextBudget,
877
+ cappedAtLine
878
+ };
879
+ }
672
880
  function readFile2(filePath, opts = {}) {
673
881
  filePath = normalizePath(filePath);
674
882
  const real = validatePath(filePath);
@@ -681,62 +889,55 @@ function readFile2(filePath, opts = {}) {
681
889
  ${text}
682
890
  \`\`\``;
683
891
  }
684
- const snapshot = rememberSnapshot(real, readText(real), { mtimeMs: stat.mtimeMs, size: stat.size });
685
- const lines = snapshot.lines;
686
- const total = lines.length;
687
- let ranges;
892
+ const snapshot = readSnapshot(real);
893
+ const total = snapshot.lines.length;
894
+ let requestedRanges;
688
895
  if (opts.ranges && opts.ranges.length > 0) {
689
- ranges = opts.ranges.map((r) => ({
690
- start: Math.max(1, r.start || 1),
691
- end: Math.min(total, r.end || total)
692
- }));
896
+ requestedRanges = opts.ranges;
693
897
  } else {
694
898
  const startLine = Math.max(1, opts.offset || 1);
695
899
  const maxLines = opts.limit && opts.limit > 0 ? opts.limit : DEFAULT_LIMIT;
696
- ranges = [{ start: startLine, end: Math.min(total, startLine - 1 + maxLines) }];
900
+ requestedRanges = [{ start: startLine, end: Math.min(total, startLine - 1 + maxLines) }];
697
901
  }
698
- const parts = [];
902
+ const blocks = [];
903
+ const diagnostics = [];
904
+ let remainingChars = MAX_OUTPUT_CHARS;
699
905
  let cappedAtLine = 0;
700
- for (const range of ranges) {
701
- const selected = lines.slice(range.start - 1, range.end);
702
- const lineHashes = [];
703
- const formatted = [];
704
- let charCount = 0;
705
- for (let i = 0; i < selected.length; i++) {
706
- const line = selected[i];
707
- const num = range.start + i;
708
- const hash32 = fnv1a(line);
709
- const entry = opts.plain ? `${num}|${line}` : `${lineTag(hash32)}.${num} ${line}`;
710
- if (charCount + entry.length > MAX_OUTPUT_CHARS && formatted.length > 0) {
711
- cappedAtLine = num;
712
- break;
713
- }
714
- lineHashes.push(hash32);
715
- formatted.push(entry);
716
- charCount += entry.length + 1;
717
- }
718
- const actualEnd = formatted.length > 0 ? range.start + formatted.length - 1 : range.start;
719
- range.end = actualEnd;
720
- parts.push(formatted.join("\n"));
721
- const cs = rangeChecksum(lineHashes, range.start, actualEnd);
722
- parts.push(`
723
- checksum: ${cs}`);
724
- if (cappedAtLine) break;
725
- }
726
- const sizeKB = (stat.size / 1024).toFixed(1);
727
- const mtime = stat.mtime;
728
- const ago = relativeTime(mtime);
729
- let header = `File: ${filePath} (${total} lines, ${sizeKB}KB, ${ago})`;
730
- if (ranges.length === 1) {
731
- const r = ranges[0];
732
- if (r.start > 1 || r.end < total) {
733
- header += ` [showing ${r.start}-${r.end}]`;
906
+ for (const requested of requestedRanges) {
907
+ const normalized = normalizeRange(requested, total);
908
+ if (normalized.type === "diagnostic_block") {
909
+ diagnostics.push(normalized);
910
+ continue;
734
911
  }
735
- if (r.end < total) {
736
- header += ` (${total - r.end} more below)`;
912
+ const built = buildReadBlock(snapshot, normalized, opts.plain, remainingChars);
913
+ blocks.push(built.block);
914
+ remainingChars = built.remainingChars;
915
+ if (built.cappedAtLine) {
916
+ cappedAtLine = built.cappedAtLine;
917
+ diagnostics.push(buildDiagnosticBlock({
918
+ kind: "output_capped",
919
+ message: `OUTPUT_CAPPED at line ${built.cappedAtLine} (${MAX_OUTPUT_CHARS} char limit). Use offset=${built.cappedAtLine} to continue reading.`,
920
+ requestedStartLine: built.cappedAtLine,
921
+ requestedEndLine: built.cappedAtLine,
922
+ startLine: built.block.startLine,
923
+ endLine: built.block.endLine
924
+ }));
925
+ break;
737
926
  }
738
927
  }
739
- const db = getGraphDB(real);
928
+ const sizeText = formatSize(stat.size);
929
+ const ago = relativeTime(stat.mtime);
930
+ let meta = `${total} lines, ${sizeText}, ${ago}`;
931
+ if (requestedRanges.length === 1 && blocks.length === 1) {
932
+ const block = blocks[0];
933
+ if (block.startLine > 1 || block.endLine < total) {
934
+ meta += `, showing ${block.startLine}-${block.endLine}`;
935
+ }
936
+ if (block.endLine < total) {
937
+ meta += `, ${total - block.endLine} more below`;
938
+ }
939
+ }
940
+ const db = opts.includeGraph ? getGraphDB(real) : null;
740
941
  const relFile = db ? getRelativePath(real) : null;
741
942
  let graphLine = "";
742
943
  if (db && relFile) {
@@ -750,29 +951,112 @@ checksum: ${cs}`);
750
951
  Graph: ${items.join(" | ")}`;
751
952
  }
752
953
  }
753
- let result = `${header}${graphLine}
954
+ const serializedBlocks = [
955
+ ...blocks.map((block) => serializeReadBlock(block, { plain: opts.plain })),
956
+ ...diagnostics.map((block) => serializeDiagnosticBlock(block))
957
+ ];
958
+ if (cappedAtLine && serializedBlocks.length === 0) {
959
+ serializedBlocks.push(serializeDiagnosticBlock(buildDiagnosticBlock({
960
+ kind: "output_capped",
961
+ message: `OUTPUT_CAPPED at line ${cappedAtLine} (${MAX_OUTPUT_CHARS} char limit). Use offset=${cappedAtLine} to continue reading.`,
962
+ requestedStartLine: cappedAtLine,
963
+ requestedEndLine: cappedAtLine
964
+ })));
965
+ }
966
+ return `File: ${filePath}${graphLine}
967
+ meta: ${meta}
754
968
  revision: ${snapshot.revision}
755
969
  file: ${snapshot.fileChecksum}
756
970
 
757
- \`\`\`
758
- ${parts.join("\n")}
759
- \`\`\``;
760
- if (total > 200 && (!opts.offset || opts.offset <= 1) && !cappedAtLine) {
761
- result += `
971
+ ${serializedBlocks.join("\n\n")}`.trim();
972
+ }
762
973
 
763
- \u26A1 Tip: This file has ${total} lines. Use outline first, then read_file with offset/limit for 75% fewer tokens.`;
764
- }
765
- if (cappedAtLine) {
766
- result += `
974
+ // lib/edit.mjs
975
+ import { statSync as statSync5, writeFileSync } from "node:fs";
976
+ import { diffLines as diffLines2 } from "diff";
767
977
 
768
- OUTPUT_CAPPED at line ${cappedAtLine} (${MAX_OUTPUT_CHARS} char limit). Use offset=${cappedAtLine} to continue reading.`;
978
+ // lib/edit-resolution.mjs
979
+ function normalizeAnchoredEdits(edits) {
980
+ const anchored = [];
981
+ for (const edit of edits) {
982
+ if (edit.set_line || edit.replace_lines || edit.insert_after || edit.replace_between) {
983
+ anchored.push(edit);
984
+ continue;
985
+ }
986
+ if (edit.replace) {
987
+ throw new Error("REPLACE_REMOVED: replace is no longer supported in edit_file. Use set_line/replace_lines for single edits, bulk_replace tool for rename/refactor.");
988
+ }
989
+ throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(edit)}`);
990
+ }
991
+ return anchored;
992
+ }
993
+ function getEditStartLine(edit) {
994
+ if (edit.set_line) return parseRef(edit.set_line.anchor).line;
995
+ if (edit.replace_lines) return parseRef(edit.replace_lines.start_anchor).line;
996
+ if (edit.insert_after) return parseRef(edit.insert_after.anchor).line;
997
+ if (edit.replace_between) return parseRef(edit.replace_between.start_anchor).line;
998
+ throw new Error(`BAD_INPUT: unsupported edit shape: ${JSON.stringify(edit)}`);
999
+ }
1000
+ function collectEditTargets(edits) {
1001
+ return edits.map((edit) => {
1002
+ if (edit.set_line) {
1003
+ const line = parseRef(edit.set_line.anchor).line;
1004
+ return { start: line, end: line, insert: false, kind: "set_line" };
1005
+ }
1006
+ if (edit.replace_lines) {
1007
+ const start = parseRef(edit.replace_lines.start_anchor).line;
1008
+ const end = parseRef(edit.replace_lines.end_anchor).line;
1009
+ return { start, end, insert: false, kind: "replace_lines" };
1010
+ }
1011
+ if (edit.insert_after) {
1012
+ const line = parseRef(edit.insert_after.anchor).line;
1013
+ return { start: line, end: line, insert: true, kind: "insert_after" };
1014
+ }
1015
+ if (edit.replace_between) {
1016
+ const start = parseRef(edit.replace_between.start_anchor).line;
1017
+ const end = parseRef(edit.replace_between.end_anchor).line;
1018
+ return { start, end, insert: false, kind: "replace_between" };
1019
+ }
1020
+ throw new Error(`BAD_INPUT: unsupported edit shape: ${JSON.stringify(edit)}`);
1021
+ });
1022
+ }
1023
+ function assertNonOverlappingTargets(targets) {
1024
+ for (let i = 0; i < targets.length; i++) {
1025
+ for (let j = i + 1; j < targets.length; j++) {
1026
+ const a = targets[i];
1027
+ const b = targets[j];
1028
+ if (a.insert || b.insert) continue;
1029
+ if (a.start <= b.end && b.start <= a.end) {
1030
+ throw new Error(
1031
+ `OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. Split into separate edit_file calls.`
1032
+ );
1033
+ }
1034
+ }
769
1035
  }
770
- return result;
1036
+ }
1037
+ function sortEditsForApply(edits) {
1038
+ return edits.map((edit) => ({ ...edit, _k: getEditStartLine(edit) })).sort((a, b) => b._k - a._k);
1039
+ }
1040
+ function targetRangeForReplaceBetween(startIdx, endIdx, boundaryMode) {
1041
+ if (boundaryMode === "exclusive") {
1042
+ return { start: startIdx + 2, end: Math.max(startIdx + 1, endIdx) };
1043
+ }
1044
+ return { start: startIdx + 1, end: endIdx + 1 };
1045
+ }
1046
+ function validateChecksumCoverage(rangeChecksum2, actualStart, actualEnd) {
1047
+ const { start, end } = parseChecksum(rangeChecksum2);
1048
+ if (start > actualStart || end < actualEnd) {
1049
+ return {
1050
+ ok: false,
1051
+ start,
1052
+ end,
1053
+ reason: `CHECKSUM_RANGE_GAP: checksum covers lines ${start}-${end} but edit spans ${actualStart}-${actualEnd} (inclusive). Checksum range must fully contain the anchor range.`
1054
+ };
1055
+ }
1056
+ return { ok: true, start, end, reason: null };
771
1057
  }
772
1058
 
773
1059
  // lib/edit.mjs
774
- import { statSync as statSync5, writeFileSync } from "node:fs";
775
- import { diffLines as diffLines2 } from "diff";
776
1060
  function restoreIndent(origLines, newLines) {
777
1061
  if (!origLines.length || !newLines.length) return newLines;
778
1062
  const origIndent = origLines[0].match(/^\s*/)[0];
@@ -794,6 +1078,26 @@ function buildErrorSnippet(lines, centerIdx, radius = 5) {
794
1078
  }).join("\n");
795
1079
  return { start: start + 1, end, text };
796
1080
  }
1081
+ function stripAnchorOrDiffPrefix(line) {
1082
+ let next = line;
1083
+ next = next.replace(/^\s*(?:>>| )?[a-z2-7]{2}\.\d+\t/, "");
1084
+ next = next.replace(/^.+:(?:>>| )[a-z2-7]{2}\.\d+\t/, "");
1085
+ next = next.replace(/^[ +-]\d+\|\s?/, "");
1086
+ return next;
1087
+ }
1088
+ function sanitizeEditText(text) {
1089
+ const original = String(text ?? "");
1090
+ const hadTrailingNewline = original.endsWith("\n");
1091
+ let lines = original.split("\n");
1092
+ const nonEmpty = lines.filter((line) => line.length > 0);
1093
+ if (nonEmpty.length > 0 && nonEmpty.every((line) => /^\+(?!\+)/.test(line))) {
1094
+ lines = lines.map((line) => line.startsWith("+") && !line.startsWith("++") ? line.slice(1) : line);
1095
+ }
1096
+ lines = lines.map(stripAnchorOrDiffPrefix);
1097
+ let cleaned = lines.join("\n");
1098
+ if (hadTrailingNewline && !cleaned.endsWith("\n")) cleaned += "\n";
1099
+ return cleaned;
1100
+ }
797
1101
  function findLine(lines, lineNum, expectedTag, hashIndex) {
798
1102
  const idx = lineNum - 1;
799
1103
  if (idx < 0 || idx >= lines.length) {
@@ -909,6 +1213,7 @@ function buildConflictMessage({
909
1213
  centerIdx,
910
1214
  changedRanges,
911
1215
  retryChecksum,
1216
+ remaps,
912
1217
  details
913
1218
  }) {
914
1219
  const safeCenter = Math.max(0, Math.min(lines.length - 1, centerIdx));
@@ -921,6 +1226,9 @@ file: ${fileChecksum}`;
921
1226
  changed_ranges: ${describeChangedRanges(changedRanges)}`;
922
1227
  if (retryChecksum) msg += `
923
1228
  retry_checksum: ${retryChecksum}`;
1229
+ if (remaps?.length) msg += `
1230
+ remapped_refs:
1231
+ ${remaps.map(({ from, to }) => `${from} -> ${to}`).join("\n")}`;
924
1232
  msg += `
925
1233
 
926
1234
  ${details}
@@ -934,11 +1242,137 @@ Tip: Retry from the fresh local snippet above.`;
934
1242
  path: ${filePath}`;
935
1243
  return msg;
936
1244
  }
937
- function targetRangeForReplaceBetween(startIdx, endIdx, boundaryMode) {
938
- if (boundaryMode === "exclusive") {
939
- return { start: startIdx + 2, end: Math.max(startIdx + 1, endIdx) };
1245
+ function applySetLineEdit(edit, ctx) {
1246
+ const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
1247
+ const { tag, line } = parseRef(edit.set_line.anchor);
1248
+ const idx = locateOrConflict({ tag, line });
1249
+ if (typeof idx === "string") return idx;
1250
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1251
+ if (conflict) return conflict;
1252
+ const txt = edit.set_line.new_text;
1253
+ if (!txt && txt !== 0) {
1254
+ lines.splice(idx, 1);
1255
+ return null;
940
1256
  }
941
- return { start: startIdx + 1, end: endIdx + 1 };
1257
+ const origLine = [lines[idx]];
1258
+ const raw = sanitizeEditText(txt).split("\n");
1259
+ const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
1260
+ lines.splice(idx, 1, ...newLines);
1261
+ return null;
1262
+ }
1263
+ function applyInsertAfterEdit(edit, ctx) {
1264
+ const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
1265
+ const { tag, line } = parseRef(edit.insert_after.anchor);
1266
+ const idx = locateOrConflict({ tag, line });
1267
+ if (typeof idx === "string") return idx;
1268
+ const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1269
+ if (conflict) return conflict;
1270
+ let insertLines = sanitizeEditText(edit.insert_after.text).split("\n");
1271
+ if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
1272
+ lines.splice(idx + 1, 0, ...insertLines);
1273
+ return null;
1274
+ }
1275
+ function applyReplaceLinesEdit(edit, ctx) {
1276
+ const {
1277
+ baseSnapshot,
1278
+ buildStrictChecksumMismatchError,
1279
+ conflictIfNeeded,
1280
+ currentSnapshot,
1281
+ ensureRevisionContext,
1282
+ hasBaseSnapshot,
1283
+ lines,
1284
+ locateOrConflict,
1285
+ opts,
1286
+ origLines,
1287
+ staleRevision
1288
+ } = ctx;
1289
+ const startRef = parseRef(edit.replace_lines.start_anchor);
1290
+ const endRef = parseRef(edit.replace_lines.end_anchor);
1291
+ const startIdx = locateOrConflict(startRef);
1292
+ if (typeof startIdx === "string") return startIdx;
1293
+ const endIdx = locateOrConflict(endRef);
1294
+ if (typeof endIdx === "string") return endIdx;
1295
+ const actualStart = startIdx + 1;
1296
+ const actualEnd = endIdx + 1;
1297
+ const rangeChecksum2 = edit.replace_lines.range_checksum;
1298
+ if (!rangeChecksum2) {
1299
+ throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum. The checksum range must cover start-to-end anchors (inclusive).");
1300
+ }
1301
+ if (staleRevision && opts.conflictPolicy === "conservative") {
1302
+ const conflict = ensureRevisionContext(actualStart, actualEnd, startIdx);
1303
+ if (conflict) return conflict;
1304
+ const baseCheck = hasBaseSnapshot ? verifyChecksumAgainstSnapshot(baseSnapshot, rangeChecksum2) : null;
1305
+ if (!baseCheck?.ok) {
1306
+ return conflictIfNeeded(
1307
+ "stale_checksum",
1308
+ startIdx,
1309
+ baseCheck?.actual || null,
1310
+ baseCheck?.actual ? `Provided checksum ${rangeChecksum2} does not match base revision ${opts.baseRevision}.` : `Checksum range from ${rangeChecksum2} is outside the available base revision.`
1311
+ );
1312
+ }
1313
+ } else {
1314
+ const coverage = validateChecksumCoverage(rangeChecksum2, actualStart, actualEnd);
1315
+ const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rangeChecksum2);
1316
+ if (!coverage.ok) {
1317
+ const snip = buildErrorSnippet(origLines, actualStart - 1);
1318
+ throw new Error(
1319
+ `${coverage.reason}
1320
+
1321
+ Current content (lines ${snip.start}-${snip.end}):
1322
+ ${snip.text}
1323
+
1324
+ Tip: Use updated hashes above for retry.`
1325
+ );
1326
+ }
1327
+ const actual = buildRangeChecksum(currentSnapshot, csStart, csEnd);
1328
+ const actualHex = actual?.split(":")[1];
1329
+ if (!actual || csHex !== actualHex) {
1330
+ const details = `CHECKSUM_MISMATCH: expected ${rangeChecksum2}, got ${actual}. Content at lines ${csStart}-${csEnd} differs from when you read it.
1331
+ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fresh checksum.`;
1332
+ if (opts.conflictPolicy === "conservative") {
1333
+ return conflictIfNeeded("stale_checksum", csStart - 1, actual, details);
1334
+ }
1335
+ throw buildStrictChecksumMismatchError(details, csStart, actual);
1336
+ }
1337
+ }
1338
+ const txt = edit.replace_lines.new_text;
1339
+ if (!txt && txt !== 0) {
1340
+ lines.splice(startIdx, endIdx - startIdx + 1);
1341
+ return null;
1342
+ }
1343
+ const origRange = lines.slice(startIdx, endIdx + 1);
1344
+ let newLines = sanitizeEditText(txt).split("\n");
1345
+ if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
1346
+ lines.splice(startIdx, endIdx - startIdx + 1, ...newLines);
1347
+ return null;
1348
+ }
1349
+ function applyReplaceBetweenEdit(edit, ctx) {
1350
+ const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
1351
+ const boundaryMode = edit.replace_between.boundary_mode || "inclusive";
1352
+ if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
1353
+ throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
1354
+ }
1355
+ const startRef = parseRef(edit.replace_between.start_anchor);
1356
+ const endRef = parseRef(edit.replace_between.end_anchor);
1357
+ const startIdx = locateOrConflict(startRef);
1358
+ if (typeof startIdx === "string") return startIdx;
1359
+ const endIdx = locateOrConflict(endRef);
1360
+ if (typeof endIdx === "string") return endIdx;
1361
+ if (startIdx > endIdx) {
1362
+ throw new Error(`BAD_INPUT: replace_between start anchor resolves after end anchor (${startIdx + 1} > ${endIdx + 1})`);
1363
+ }
1364
+ const targetRange = targetRangeForReplaceBetween(startIdx, endIdx, boundaryMode);
1365
+ const conflict = ensureRevisionContext(targetRange.start, targetRange.end, startIdx);
1366
+ if (conflict) return conflict;
1367
+ const txt = edit.replace_between.new_text;
1368
+ let newLines = sanitizeEditText(txt ?? "").split("\n");
1369
+ const sliceStart = boundaryMode === "exclusive" ? startIdx + 1 : startIdx;
1370
+ const removeCount = boundaryMode === "exclusive" ? Math.max(0, endIdx - startIdx - 1) : endIdx - startIdx + 1;
1371
+ const origRange = lines.slice(sliceStart, sliceStart + removeCount);
1372
+ if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
1373
+ if (txt === "" || txt === null) newLines = [];
1374
+ lines.splice(sliceStart, removeCount, ...newLines);
1375
+ return null;
942
1376
  }
943
1377
  function editFile(filePath, edits, opts = {}) {
944
1378
  filePath = normalizePath(filePath);
@@ -955,49 +1389,11 @@ function editFile(filePath, edits, opts = {}) {
955
1389
  const hadTrailingNewline = original.endsWith("\n");
956
1390
  const hashIndex = currentSnapshot.uniqueTagIndex;
957
1391
  let autoRebased = false;
958
- const anchored = [];
959
- for (const e of edits) {
960
- if (e.set_line || e.replace_lines || e.insert_after || e.replace_between) anchored.push(e);
961
- else if (e.replace) throw new Error("REPLACE_REMOVED: replace is no longer supported in edit_file. Use set_line/replace_lines for single edits, bulk_replace tool for rename/refactor.");
962
- else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
963
- }
964
- const editTargets = [];
965
- for (const e of anchored) {
966
- if (e.set_line) {
967
- const line = parseRef(e.set_line.anchor).line;
968
- editTargets.push({ start: line, end: line });
969
- } else if (e.replace_lines) {
970
- const s = parseRef(e.replace_lines.start_anchor).line;
971
- const en = parseRef(e.replace_lines.end_anchor).line;
972
- editTargets.push({ start: s, end: en });
973
- } else if (e.insert_after) {
974
- const line = parseRef(e.insert_after.anchor).line;
975
- editTargets.push({ start: line, end: line, insert: true });
976
- } else if (e.replace_between) {
977
- const s = parseRef(e.replace_between.start_anchor).line;
978
- const en = parseRef(e.replace_between.end_anchor).line;
979
- editTargets.push({ start: s, end: en });
980
- }
981
- }
982
- for (let i = 0; i < editTargets.length; i++) {
983
- for (let j = i + 1; j < editTargets.length; j++) {
984
- const a = editTargets[i], b = editTargets[j];
985
- if (a.insert || b.insert) continue;
986
- if (a.start <= b.end && b.start <= a.end) {
987
- throw new Error(
988
- `OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. Split into separate edit_file calls.`
989
- );
990
- }
991
- }
992
- }
993
- const sorted = anchored.map((e) => {
994
- let sortKey;
995
- if (e.set_line) sortKey = parseRef(e.set_line.anchor).line;
996
- else if (e.replace_lines) sortKey = parseRef(e.replace_lines.start_anchor).line;
997
- else if (e.insert_after) sortKey = parseRef(e.insert_after.anchor).line;
998
- else if (e.replace_between) sortKey = parseRef(e.replace_between.start_anchor).line;
999
- return { ...e, _k: sortKey };
1000
- }).sort((a, b) => b._k - a._k);
1392
+ const remaps = [];
1393
+ const remapKeys = /* @__PURE__ */ new Set();
1394
+ const anchored = normalizeAnchoredEdits(edits);
1395
+ assertNonOverlappingTargets(collectEditTargets(anchored));
1396
+ const sorted = sortEditsForApply(anchored);
1001
1397
  const conflictIfNeeded = (reason, centerIdx, retryChecksum, details) => {
1002
1398
  if (conflictPolicy !== "conservative") {
1003
1399
  throw new Error(details);
@@ -1011,12 +1407,24 @@ function editFile(filePath, edits, opts = {}) {
1011
1407
  centerIdx,
1012
1408
  changedRanges: staleRevision && hasBaseSnapshot ? changedRanges : null,
1013
1409
  retryChecksum,
1410
+ remaps,
1014
1411
  details
1015
1412
  });
1016
1413
  };
1414
+ const trackRemap = (ref, idx) => {
1415
+ const actualRef = `${lineTag(fnv1a(lines[idx]))}.${idx + 1}`;
1416
+ const expectedRef = `${ref.tag}.${ref.line}`;
1417
+ if (actualRef === expectedRef) return;
1418
+ const key = `${expectedRef}->${actualRef}`;
1419
+ if (remapKeys.has(key)) return;
1420
+ remapKeys.add(key);
1421
+ remaps.push({ from: expectedRef, to: actualRef });
1422
+ };
1017
1423
  const locateOrConflict = (ref, reason = "stale_anchor") => {
1018
1424
  try {
1019
- return findLine(lines, ref.line, ref.tag, hashIndex);
1425
+ const idx = findLine(lines, ref.line, ref.tag, hashIndex);
1426
+ trackRemap(ref, idx);
1427
+ return idx;
1020
1428
  } catch (e) {
1021
1429
  if (conflictPolicy !== "conservative" || !staleRevision) throw e;
1022
1430
  const centerIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1));
@@ -1030,7 +1438,7 @@ function editFile(filePath, edits, opts = {}) {
1030
1438
  "base_revision_evicted",
1031
1439
  centerIdx,
1032
1440
  null,
1033
- `Base revision ${opts.baseRevision} is not available in the local revision cache.`
1441
+ `Base revision ${opts.baseRevision} is not available in the local revision cache. Recovery: re-read the file with read_file to get a fresh revision, then retry.`
1034
1442
  );
1035
1443
  }
1036
1444
  if (overlapsChangedRanges(changedRanges, actualStart, actualEnd)) {
@@ -1038,134 +1446,45 @@ function editFile(filePath, edits, opts = {}) {
1038
1446
  "overlap",
1039
1447
  centerIdx,
1040
1448
  null,
1041
- `Changes since ${opts.baseRevision} overlap edit range ${actualStart}-${actualEnd}.`
1449
+ `Changes since ${opts.baseRevision} overlap edit range ${actualStart}-${actualEnd}. Recovery: re-read lines ${actualStart}-${actualEnd} with read_file, then retry with fresh anchors and checksum.`
1042
1450
  );
1043
1451
  }
1044
1452
  autoRebased = true;
1045
1453
  return null;
1046
1454
  };
1047
- for (let _ei = 0; _ei < sorted.length; _ei++) {
1048
- const e = sorted[_ei];
1049
- try {
1050
- if (e.set_line) {
1051
- const { tag, line } = parseRef(e.set_line.anchor);
1052
- const idx = locateOrConflict({ tag, line });
1053
- if (typeof idx === "string") return idx;
1054
- const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1055
- if (conflict) return conflict;
1056
- const txt = e.set_line.new_text;
1057
- if (!txt && txt !== 0) {
1058
- lines.splice(idx, 1);
1059
- } else {
1060
- const origLine = [lines[idx]];
1061
- const raw = String(txt).split("\n");
1062
- const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
1063
- lines.splice(idx, 1, ...newLines);
1064
- }
1065
- continue;
1066
- }
1067
- if (e.insert_after) {
1068
- const { tag, line } = parseRef(e.insert_after.anchor);
1069
- const idx = locateOrConflict({ tag, line });
1070
- if (typeof idx === "string") return idx;
1071
- const conflict = ensureRevisionContext(idx + 1, idx + 1, idx);
1072
- if (conflict) return conflict;
1073
- let insertLines = e.insert_after.text.split("\n");
1074
- if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
1075
- lines.splice(idx + 1, 0, ...insertLines);
1076
- continue;
1077
- }
1078
- if (e.replace_lines) {
1079
- const s = parseRef(e.replace_lines.start_anchor);
1080
- const en = parseRef(e.replace_lines.end_anchor);
1081
- const si = locateOrConflict(s);
1082
- if (typeof si === "string") return si;
1083
- const ei = locateOrConflict(en);
1084
- if (typeof ei === "string") return ei;
1085
- const actualStart = si + 1;
1086
- const actualEnd = ei + 1;
1087
- const rc = e.replace_lines.range_checksum;
1088
- if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum. The checksum range must cover start-to-end anchors (inclusive).");
1089
- if (staleRevision && conflictPolicy === "conservative") {
1090
- const conflict = ensureRevisionContext(actualStart, actualEnd, si);
1091
- if (conflict) return conflict;
1092
- const baseCheck = hasBaseSnapshot ? verifyChecksumAgainstSnapshot(baseSnapshot, rc) : null;
1093
- if (!baseCheck?.ok) {
1094
- return conflictIfNeeded(
1095
- "stale_checksum",
1096
- si,
1097
- baseCheck?.actual || null,
1098
- baseCheck?.actual ? `Provided checksum ${rc} does not match base revision ${opts.baseRevision}.` : `Checksum range from ${rc} is outside the available base revision.`
1099
- );
1100
- }
1101
- } else {
1102
- const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
1103
- if (csStart > actualStart || csEnd < actualEnd) {
1104
- const snip = buildErrorSnippet(origLines, actualStart - 1);
1105
- throw new Error(
1106
- `CHECKSUM_RANGE_GAP: checksum covers lines ${csStart}-${csEnd} but edit spans ${actualStart}-${actualEnd} (inclusive). Checksum range must fully contain the anchor range.
1107
-
1108
- Current content (lines ${snip.start}-${snip.end}):
1109
- ${snip.text}
1110
-
1111
- Tip: Use updated hashes above for retry.`
1112
- );
1113
- }
1114
- const actual = buildRangeChecksum(currentSnapshot, csStart, csEnd);
1115
- const actualHex = actual?.split(":")[1];
1116
- if (!actual || csHex !== actualHex) {
1117
- const details = `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. Content at lines ${csStart}-${csEnd} differs from when you read it \u2014 re-read before editing.`;
1118
- if (conflictPolicy === "conservative") {
1119
- return conflictIfNeeded("stale_checksum", csStart - 1, actual, details);
1120
- }
1121
- const snip = buildErrorSnippet(origLines, csStart - 1);
1122
- throw new Error(
1123
- `${details}
1455
+ const buildStrictChecksumMismatchError = (details, checksumStart, actual) => {
1456
+ const snip = buildErrorSnippet(origLines, checksumStart - 1);
1457
+ return new Error(
1458
+ `${details}
1124
1459
 
1125
1460
  Current content (lines ${snip.start}-${snip.end}):
1126
1461
  ${snip.text}
1127
1462
 
1128
1463
  Retry with fresh checksum ${actual}, or use set_line with hashes above.`
1129
- );
1130
- }
1131
- }
1132
- const txt = e.replace_lines.new_text;
1133
- if (!txt && txt !== 0) {
1134
- lines.splice(si, ei - si + 1);
1135
- } else {
1136
- const origRange = lines.slice(si, ei + 1);
1137
- let newLines = String(txt).split("\n");
1138
- if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
1139
- lines.splice(si, ei - si + 1, ...newLines);
1140
- }
1141
- continue;
1142
- }
1143
- if (e.replace_between) {
1144
- const boundaryMode = e.replace_between.boundary_mode || "inclusive";
1145
- if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
1146
- throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
1147
- }
1148
- const s = parseRef(e.replace_between.start_anchor);
1149
- const en = parseRef(e.replace_between.end_anchor);
1150
- const si = locateOrConflict(s);
1151
- if (typeof si === "string") return si;
1152
- const ei = locateOrConflict(en);
1153
- if (typeof ei === "string") return ei;
1154
- if (si > ei) {
1155
- throw new Error(`BAD_INPUT: replace_between start anchor resolves after end anchor (${si + 1} > ${ei + 1})`);
1156
- }
1157
- const targetRange = targetRangeForReplaceBetween(si, ei, boundaryMode);
1158
- const conflict = ensureRevisionContext(targetRange.start, targetRange.end, si);
1159
- if (conflict) return conflict;
1160
- const txt = e.replace_between.new_text;
1161
- let newLines = String(txt ?? "").split("\n");
1162
- const sliceStart = boundaryMode === "exclusive" ? si + 1 : si;
1163
- const removeCount = boundaryMode === "exclusive" ? Math.max(0, ei - si - 1) : ei - si + 1;
1164
- const origRange = lines.slice(sliceStart, sliceStart + removeCount);
1165
- if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
1166
- if (txt === "" || txt === null) newLines = [];
1167
- lines.splice(sliceStart, removeCount, ...newLines);
1168
- }
1464
+ );
1465
+ };
1466
+ const editContext = {
1467
+ baseSnapshot,
1468
+ buildStrictChecksumMismatchError,
1469
+ conflictIfNeeded,
1470
+ currentSnapshot,
1471
+ ensureRevisionContext,
1472
+ hasBaseSnapshot,
1473
+ lines,
1474
+ locateOrConflict,
1475
+ opts,
1476
+ origLines,
1477
+ staleRevision
1478
+ };
1479
+ for (let _ei = 0; _ei < sorted.length; _ei++) {
1480
+ const e = sorted[_ei];
1481
+ try {
1482
+ let conflictResult = null;
1483
+ if (e.set_line) conflictResult = applySetLineEdit(e, editContext);
1484
+ else if (e.insert_after) conflictResult = applyInsertAfterEdit(e, editContext);
1485
+ else if (e.replace_lines) conflictResult = applyReplaceLinesEdit(e, editContext);
1486
+ else if (e.replace_between) conflictResult = applyReplaceBetweenEdit(e, editContext);
1487
+ if (typeof conflictResult === "string") return conflictResult;
1169
1488
  } catch (editErr) {
1170
1489
  if (sorted.length > 1) editErr.message = `Edit ${_ei + 1}/${sorted.length}: ${editErr.message}`;
1171
1490
  throw editErr;
@@ -1219,25 +1538,24 @@ file: ${nextSnapshot.fileChecksum}`;
1219
1538
  if (autoRebased && staleRevision && hasBaseSnapshot) {
1220
1539
  msg += `
1221
1540
  changed_ranges: ${describeChangedRanges(changedRanges)}`;
1541
+ }
1542
+ if (remaps.length > 0) {
1543
+ msg += `
1544
+ remapped_refs:
1545
+ ${remaps.map(({ from, to }) => `${from} -> ${to}`).join("\n")}`;
1222
1546
  }
1223
1547
  msg += `
1224
1548
  Updated ${filePath} (${content.split("\n").length} lines)`;
1225
1549
  if (fullDiff && minLine <= maxLine) {
1226
- const ctxStart = Math.max(0, minLine - 6);
1550
+ const ctxStart = Math.max(0, minLine - 6) + 1;
1227
1551
  const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
1228
- const ctxLines = [];
1229
- const ctxHashes = [];
1230
- for (let i = ctxStart; i < ctxEnd; i++) {
1231
- const h = fnv1a(newLinesAll[i]);
1232
- ctxHashes.push(h);
1233
- ctxLines.push(`${lineTag(h)}.${i + 1} ${newLinesAll[i]}`);
1234
- }
1235
- const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
1236
- msg += `
1552
+ const entries = createSnapshotEntries(nextSnapshot, ctxStart, ctxEnd);
1553
+ if (entries.length > 0) {
1554
+ const block = buildEditReadyBlock({ path: real, kind: "post_edit", entries });
1555
+ msg += `
1237
1556
 
1238
- Post-edit (lines ${ctxStart + 1}-${ctxEnd}):
1239
- ${ctxLines.join("\n")}
1240
- checksum: ${ctxCs}`;
1557
+ ${serializeReadBlock(block)}`;
1558
+ }
1241
1559
  }
1242
1560
  try {
1243
1561
  const db = getGraphDB(real);
@@ -1334,9 +1652,7 @@ async function filesMode(pattern, target, opts) {
1334
1652
  if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
1335
1653
  const lines = stdout.trimEnd().split("\n").filter(Boolean);
1336
1654
  const normalized = lines.map((l) => l.replace(/\\/g, "/"));
1337
- return `\`\`\`
1338
- ${normalized.join("\n")}
1339
- \`\`\``;
1655
+ return normalized.join("\n");
1340
1656
  }
1341
1657
  async function countMode(pattern, target, opts) {
1342
1658
  const realArgs = ["-c"];
@@ -1353,9 +1669,7 @@ async function countMode(pattern, target, opts) {
1353
1669
  if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
1354
1670
  const lines = stdout.trimEnd().split("\n").filter(Boolean);
1355
1671
  const normalized = lines.map((l) => l.replace(/\\/g, "/"));
1356
- return `\`\`\`
1357
- ${normalized.join("\n")}
1358
- \`\`\``;
1672
+ return normalized.join("\n");
1359
1673
  }
1360
1674
  async function contentMode(pattern, target, opts, plain, totalLimit) {
1361
1675
  const realArgs = ["--json"];
@@ -1376,21 +1690,33 @@ async function contentMode(pattern, target, opts, plain, totalLimit) {
1376
1690
  if (code === 1) return "No matches found.";
1377
1691
  if (code !== 0 && code !== null) throw new Error(`GREP_ERROR: rg exit ${code} \u2014 ${stderr.trim() || "unknown error"}`);
1378
1692
  const jsonLines = stdout.trimEnd().split("\n").filter(Boolean);
1379
- const formatted = [];
1693
+ const blocks = [];
1380
1694
  const db = getGraphDB(target);
1381
1695
  const relCache = /* @__PURE__ */ new Map();
1382
1696
  let groupFile = null;
1383
- let groupLines = [];
1697
+ let groupEntries = [];
1384
1698
  let matchCount = 0;
1385
1699
  function flushGroup() {
1386
- if (groupLines.length === 0) return;
1387
- const sorted = [...groupLines].sort((a, b) => a.lineNum - b.lineNum);
1388
- const start = sorted[0].lineNum;
1389
- const end = sorted[sorted.length - 1].lineNum;
1390
- const hashes = sorted.map((l) => l.hash32);
1391
- const cs = rangeChecksum(hashes, start, end);
1392
- formatted.push(`checksum: ${cs}`);
1393
- groupLines = [];
1700
+ if (groupEntries.length === 0) return;
1701
+ const matchLines = groupEntries.filter((entry) => entry.role === "match").map((entry) => entry.lineNumber);
1702
+ let graphScore = 0;
1703
+ let defs = 0;
1704
+ let usages = 0;
1705
+ for (const entry of groupEntries) {
1706
+ if (!entry.annotation) continue;
1707
+ const callerMatch = entry.annotation.match(/(\d+)\u2191/);
1708
+ if (callerMatch) graphScore += parseInt(callerMatch[1], 10);
1709
+ if (/\[fn|\[cls|\[mtd/.test(entry.annotation)) defs++;
1710
+ else usages++;
1711
+ }
1712
+ const summary = defs || usages ? `${defs} definition(s), ${usages} usage(s)` : null;
1713
+ blocks.push(buildEditReadyBlock({
1714
+ path: groupFile,
1715
+ kind: "search_hunk",
1716
+ entries: groupEntries,
1717
+ meta: { matchLines, graphScore, ...summary ? { summary } : {} }
1718
+ }));
1719
+ groupEntries = [];
1394
1720
  }
1395
1721
  for (const jl of jsonLines) {
1396
1722
  let msg;
@@ -1404,11 +1730,6 @@ async function contentMode(pattern, target, opts, plain, totalLimit) {
1404
1730
  flushGroup();
1405
1731
  groupFile = null;
1406
1732
  }
1407
- if (msg.type === "begin") {
1408
- if (formatted.length > 0 && formatted[formatted.length - 1] !== "") {
1409
- formatted.push("");
1410
- }
1411
- }
1412
1733
  continue;
1413
1734
  }
1414
1735
  if (msg.type !== "match" && msg.type !== "context") continue;
@@ -1430,48 +1751,46 @@ async function contentMode(pattern, target, opts, plain, totalLimit) {
1430
1751
  for (let i = 0; i < subLines.length; i++) {
1431
1752
  const ln = lineNum + i;
1432
1753
  const lineContent = subLines[i];
1433
- const hash32 = fnv1a(lineContent);
1434
- const tag = lineTag(hash32);
1435
- if (groupLines.length > 0) {
1436
- const lastLn = groupLines[groupLines.length - 1].lineNum;
1754
+ if (groupEntries.length > 0) {
1755
+ const lastLn = groupEntries[groupEntries.length - 1].lineNumber;
1437
1756
  if (ln > lastLn + 1) flushGroup();
1438
1757
  }
1439
- groupLines.push({ lineNum: ln, hash32 });
1440
1758
  const isMatch = msg.type === "match";
1441
- if (plain) {
1442
- formatted.push(`${filePath}:${ln}:${lineContent}`);
1443
- } else {
1444
- let anno = "";
1445
- if (db && isMatch) {
1446
- let rel = relCache.get(filePath);
1447
- if (rel === void 0) {
1448
- rel = getRelativePath(resolve2(filePath)) || "";
1449
- relCache.set(filePath, rel);
1450
- }
1451
- if (rel) {
1452
- const a = matchAnnotation(db, rel, ln);
1453
- if (a) anno = ` ${a}`;
1454
- }
1759
+ let annotation = "";
1760
+ if (db && isMatch) {
1761
+ let rel = relCache.get(filePath);
1762
+ if (rel === void 0) {
1763
+ rel = getRelativePath(resolve2(filePath)) || "";
1764
+ relCache.set(filePath, rel);
1765
+ }
1766
+ if (rel) {
1767
+ const a = matchAnnotation(db, rel, ln);
1768
+ if (a) annotation = a;
1455
1769
  }
1456
- const prefix = isMatch ? ">>" : " ";
1457
- formatted.push(`${filePath}:${prefix}${tag}.${ln} ${lineContent}${anno}`);
1458
1770
  }
1771
+ groupEntries.push({
1772
+ lineNumber: ln,
1773
+ text: lineContent,
1774
+ role: isMatch ? "match" : "context",
1775
+ annotation
1776
+ });
1459
1777
  }
1460
1778
  if (msg.type === "match") {
1461
1779
  matchCount++;
1462
1780
  if (totalLimit > 0 && matchCount >= totalLimit) {
1463
1781
  flushGroup();
1464
- formatted.push(`--- total_limit reached (${totalLimit}) ---`);
1465
- return `\`\`\`
1466
- ${formatted.join("\n")}
1467
- \`\`\``;
1782
+ blocks.push(buildDiagnosticBlock({
1783
+ kind: "total_limit",
1784
+ message: `Search stopped after ${totalLimit} match event(s). Narrow the query to continue.`,
1785
+ path: String(target).replace(/\\/g, "/")
1786
+ }));
1787
+ return blocks.map((block) => block.type === "edit_ready_block" ? serializeSearchBlock(block, { plain }) : serializeDiagnosticBlock(block)).join("\n\n");
1468
1788
  }
1469
1789
  }
1470
1790
  }
1471
1791
  flushGroup();
1472
- return `\`\`\`
1473
- ${formatted.join("\n")}
1474
- \`\`\``;
1792
+ if (db) blocks.sort((a, b) => (b.meta.graphScore || 0) - (a.meta.graphScore || 0));
1793
+ return blocks.map((block) => block.type === "edit_ready_block" ? serializeSearchBlock(block, { plain }) : serializeDiagnosticBlock(block)).join("\n\n");
1475
1794
  }
1476
1795
 
1477
1796
  // lib/outline.mjs
@@ -1535,15 +1854,6 @@ function supportedExtensions() {
1535
1854
  return Object.keys(EXTENSION_GRAMMARS);
1536
1855
  }
1537
1856
 
1538
- // ../hex-common/src/text/file-text.mjs
1539
- import { readFileSync as readFileSync2 } from "node:fs";
1540
- function normalizeSourceText(text) {
1541
- return text.replace(/\r\n/g, "\n");
1542
- }
1543
- function readUtf8Normalized(filePath) {
1544
- return normalizeSourceText(readFileSync2(filePath, "utf-8"));
1545
- }
1546
-
1547
1857
  // lib/outline.mjs
1548
1858
  var LANG_CONFIGS = {
1549
1859
  ".js": { outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
@@ -1564,7 +1874,9 @@ var LANG_CONFIGS = {
1564
1874
  ".kt": { outline: ["function_declaration", "class_declaration", "object_declaration"], skip: ["import_header"], recurse: ["class_body"] },
1565
1875
  ".swift": { outline: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
1566
1876
  ".sh": { outline: ["function_definition"], skip: [], recurse: [] },
1567
- ".bash": { outline: ["function_definition"], skip: [], recurse: [] }
1877
+ ".bash": { outline: ["function_definition"], skip: [], recurse: [] },
1878
+ ".md": { outline: [], skip: [], recurse: [] },
1879
+ ".mdx": { outline: [], skip: [], recurse: [] }
1568
1880
  };
1569
1881
  function extractOutline(rootNode, config, sourceLines) {
1570
1882
  const entries = [];
@@ -1609,6 +1921,59 @@ function extractOutline(rootNode, config, sourceLines) {
1609
1921
  walk(rootNode, 0);
1610
1922
  return { entries, skippedRanges };
1611
1923
  }
1924
+ function fallbackOutline(sourceLines) {
1925
+ const entries = [];
1926
+ for (let index = 0; index < sourceLines.length; index++) {
1927
+ const line = sourceLines[index];
1928
+ const trimmed = line.trim();
1929
+ if (!trimmed) continue;
1930
+ const match = trimmed.match(
1931
+ /^(?:export\s+)?(?:async\s+)?function\s+[\w$]+|^(?:export\s+)?(?:const|let|var)\s+[\w$]+\s*=|^(?:export\s+)?class\s+[\w$]+|^(?:export\s+)?interface\s+[\w$]+|^(?:export\s+)?type\s+[\w$]+\s*=|^(?:export\s+)?enum\s+[\w$]+|^(?:export\s+default\s+)?[\w$]+\s*=>/
1932
+ );
1933
+ if (!match) continue;
1934
+ entries.push({
1935
+ start: index + 1,
1936
+ end: index + 1,
1937
+ depth: 0,
1938
+ text: trimmed.slice(0, 120),
1939
+ name: trimmed.match(/([\w$]+)/)?.[1] || null
1940
+ });
1941
+ }
1942
+ return entries;
1943
+ }
1944
+ function markdownOutline(sourceLines) {
1945
+ const entries = [];
1946
+ let activeFence = null;
1947
+ for (let index = 0; index < sourceLines.length; index++) {
1948
+ const line = sourceLines[index];
1949
+ const fenceMatch = line.match(/^\s{0,3}(```+|~~~+).*$/);
1950
+ if (fenceMatch) {
1951
+ const marker = fenceMatch[1][0];
1952
+ const length = fenceMatch[1].length;
1953
+ if (!activeFence) {
1954
+ activeFence = { marker, length };
1955
+ continue;
1956
+ }
1957
+ if (activeFence.marker === marker && length >= activeFence.length) {
1958
+ activeFence = null;
1959
+ continue;
1960
+ }
1961
+ }
1962
+ if (activeFence) continue;
1963
+ const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
1964
+ if (!match) continue;
1965
+ const level = match[1].length;
1966
+ const title = match[2].trim();
1967
+ entries.push({
1968
+ start: index + 1,
1969
+ end: index + 1,
1970
+ depth: level - 1,
1971
+ text: title.slice(0, 120),
1972
+ name: title.split(/\s+/)[0] || null
1973
+ });
1974
+ }
1975
+ return entries;
1976
+ }
1612
1977
  async function outlineFromContent(content, ext) {
1613
1978
  const config = LANG_CONFIGS[ext];
1614
1979
  const grammar = grammarForExtension(ext);
@@ -1625,8 +1990,9 @@ async function outlineFromContent(content, ext) {
1625
1990
  const tree = parser.parse(content);
1626
1991
  return extractOutline(tree.rootNode, config, sourceLines);
1627
1992
  }
1628
- function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
1993
+ function formatOutline(entries, skippedRanges, sourceLineCount, snapshot, db, relFile, note = "") {
1629
1994
  const lines = [];
1995
+ if (note) lines.push(note, "");
1630
1996
  if (skippedRanges.length > 0) {
1631
1997
  const first = skippedRanges[0].start;
1632
1998
  const last = skippedRanges[skippedRanges.length - 1].end;
@@ -1637,7 +2003,9 @@ function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
1637
2003
  const indent = " ".repeat(e.depth);
1638
2004
  const anno = db ? symbolAnnotation(db, relFile, e.name) : null;
1639
2005
  const suffix = anno ? ` ${anno}` : "";
1640
- lines.push(`${indent}${e.start}-${e.end}: ${e.text}${suffix}`);
2006
+ const tag = snapshot && snapshot.lineHashes[e.start - 1] ? lineTag(snapshot.lineHashes[e.start - 1]) : null;
2007
+ const prefix = tag ? `${tag}.` : "";
2008
+ lines.push(`${indent}${prefix}${e.start}-${e.end}: ${e.text}${suffix}`);
1641
2009
  }
1642
2010
  lines.push("");
1643
2011
  lines.push(`(${entries.length} symbols, ${sourceLineCount} source lines)`);
@@ -1650,61 +2018,136 @@ async function fileOutline(filePath) {
1650
2018
  if (!LANG_CONFIGS[ext]) {
1651
2019
  return `Outline unavailable for ${ext} files. Use read_file directly for non-code files (markdown, config, text). Supported code extensions: ${supportedExtensions().join(", ")}`;
1652
2020
  }
1653
- const content = readUtf8Normalized(real);
1654
- const result = await outlineFromContent(content, ext);
2021
+ const snapshot = readSnapshot(real);
2022
+ const isMarkdown = ext === ".md" || ext === ".mdx";
2023
+ const result = isMarkdown ? null : await outlineFromContent(snapshot.content, ext);
2024
+ let entries;
2025
+ let skippedRanges = [];
2026
+ let note = "";
2027
+ if (result && result.entries.length > 0) {
2028
+ entries = result.entries;
2029
+ skippedRanges = result.skippedRanges;
2030
+ } else if (isMarkdown) {
2031
+ entries = markdownOutline(snapshot.lines);
2032
+ } else {
2033
+ entries = fallbackOutline(snapshot.lines);
2034
+ if (entries.length > 0) note = "Fallback outline: heuristic symbols shown because parser returned no structural entries.";
2035
+ }
1655
2036
  const db = getGraphDB(real);
1656
2037
  const relFile = db ? getRelativePath(real) : null;
1657
2038
  return `File: ${filePath}
1658
2039
 
1659
- ${formatOutline(result.entries, result.skippedRanges, content.split("\n").length, db, relFile)}`;
2040
+ ${formatOutline(entries, skippedRanges, snapshot.lines.length, snapshot, db, relFile, note)}`;
1660
2041
  }
1661
2042
 
1662
2043
  // lib/verify.mjs
2044
+ function parseChecksumEntry(raw) {
2045
+ try {
2046
+ return { ok: true, raw, parsed: parseChecksum(raw) };
2047
+ } catch (error) {
2048
+ return {
2049
+ ok: false,
2050
+ raw,
2051
+ error: error instanceof Error ? error.message : String(error)
2052
+ };
2053
+ }
2054
+ }
2055
+ function classifyChecksum(currentSnapshot, entry) {
2056
+ if (!entry.ok) {
2057
+ return {
2058
+ status: "INVALID",
2059
+ checksum: entry.raw,
2060
+ span: null,
2061
+ currentChecksum: null,
2062
+ reason: `invalid checksum format: ${entry.error}`
2063
+ };
2064
+ }
2065
+ const { start, end } = entry.parsed;
2066
+ if (start < 1 || end < start) {
2067
+ return {
2068
+ status: "INVALID",
2069
+ checksum: entry.raw,
2070
+ span: `${start}-${end}`,
2071
+ currentChecksum: null,
2072
+ reason: `invalid range ${start}-${end}`
2073
+ };
2074
+ }
2075
+ if (end > currentSnapshot.lines.length) {
2076
+ return {
2077
+ status: "INVALID",
2078
+ checksum: entry.raw,
2079
+ span: `${start}-${end}`,
2080
+ currentChecksum: null,
2081
+ reason: `range ${start}-${end} exceeds file length ${currentSnapshot.lines.length}`
2082
+ };
2083
+ }
2084
+ const currentChecksum = buildRangeChecksum(currentSnapshot, start, end);
2085
+ if (currentChecksum === entry.raw) {
2086
+ return {
2087
+ status: "VALID",
2088
+ checksum: entry.raw,
2089
+ span: `${start}-${end}`,
2090
+ currentChecksum,
2091
+ reason: null
2092
+ };
2093
+ }
2094
+ return {
2095
+ status: "STALE",
2096
+ checksum: entry.raw,
2097
+ span: `${start}-${end}`,
2098
+ currentChecksum,
2099
+ reason: `content changed since the checksum was captured. Recovery: read_file ranges=["${start}-${end}"] to get fresh content.`
2100
+ };
2101
+ }
2102
+ function summarizeStatuses(entries) {
2103
+ return entries.reduce((acc, entry) => {
2104
+ const key = entry.status.toLowerCase();
2105
+ acc[key] += 1;
2106
+ return acc;
2107
+ }, { valid: 0, stale: 0, invalid: 0 });
2108
+ }
2109
+ function renderEntry(entry) {
2110
+ const base = `checksum: ${entry.checksum}`;
2111
+ if (entry.status === "VALID") {
2112
+ return `${entry.status} ${entry.span} ${base}`;
2113
+ }
2114
+ if (entry.status === "STALE") {
2115
+ return `${entry.status} ${entry.span} ${base} current=${entry.currentChecksum} | re-read to refresh`;
2116
+ }
2117
+ return `${entry.status}${entry.span ? ` ${entry.span}` : ""} ${base} reason=${entry.reason}`;
2118
+ }
1663
2119
  function verifyChecksums(filePath, checksums, opts = {}) {
1664
2120
  filePath = normalizePath(filePath);
1665
2121
  const real = validatePath(filePath);
1666
- const current = readSnapshot(real);
2122
+ const currentSnapshot = readSnapshot(real);
1667
2123
  const baseSnapshot = opts.baseRevision ? getSnapshotByRevision(opts.baseRevision) : null;
1668
- const results = [];
1669
- let allValid = true;
1670
- for (const cs of checksums) {
1671
- const parsed = parseChecksum(cs);
1672
- if (parsed.start < 1 || parsed.end > current.lines.length) {
1673
- results.push(`${cs}: INVALID (range ${parsed.start}-${parsed.end} exceeds file length ${current.lines.length})`);
1674
- allValid = false;
1675
- continue;
1676
- }
1677
- const actual = buildRangeChecksum(current, parsed.start, parsed.end);
1678
- const currentHex = actual.split(":")[1];
1679
- if (currentHex === parsed.hex) {
1680
- results.push(`${cs}: valid`);
2124
+ const hasBaseSnapshot = !!(baseSnapshot && baseSnapshot.path === real);
2125
+ const parsedEntries = (checksums || []).map(parseChecksumEntry);
2126
+ const results = parsedEntries.map((entry) => classifyChecksum(currentSnapshot, entry));
2127
+ const summary = summarizeStatuses(results);
2128
+ const status = summary.invalid > 0 ? "INVALID" : summary.stale > 0 ? "STALE" : "OK";
2129
+ const lines = [
2130
+ `status: ${status}`,
2131
+ `revision: ${currentSnapshot.revision}`,
2132
+ `file: ${currentSnapshot.fileChecksum}`,
2133
+ `summary: valid=${summary.valid} stale=${summary.stale} invalid=${summary.invalid}`
2134
+ ];
2135
+ if (opts.baseRevision) {
2136
+ lines.push(`base_revision: ${opts.baseRevision}`);
2137
+ if (hasBaseSnapshot) {
2138
+ lines.push(`changed_ranges: ${describeChangedRanges(computeChangedRanges(baseSnapshot.lines, currentSnapshot.lines))}`);
1681
2139
  } else {
1682
- const staleBits = [`${cs}: STALE \u2192 current: ${actual}`];
1683
- if (baseSnapshot?.path === real) {
1684
- const changedRanges = computeChangedRanges(baseSnapshot.lines, current.lines);
1685
- staleBits.push(`revision: ${current.revision}`);
1686
- staleBits.push(`changed_ranges: ${describeChangedRanges(changedRanges)}`);
1687
- } else if (opts.baseRevision) {
1688
- staleBits.push(`revision: ${current.revision}`);
1689
- staleBits.push(`changed_ranges: unavailable (base revision evicted)`);
1690
- }
1691
- results.push(staleBits.join("\n"));
1692
- allValid = false;
2140
+ lines.push("base_revision_status: evicted");
1693
2141
  }
1694
2142
  }
1695
- if (allValid && checksums.length > 0) {
1696
- let msg = `All ${checksums.length} checksum(s) valid for ${filePath}`;
1697
- msg += `
1698
- revision: ${current.revision}`;
1699
- msg += `
1700
- file: ${current.fileChecksum}`;
1701
- return msg;
2143
+ if (results.length > 0) {
2144
+ lines.push("", ...results.map(renderEntry));
1702
2145
  }
1703
- return results.join("\n");
2146
+ return lines.join("\n");
1704
2147
  }
1705
2148
 
1706
2149
  // lib/tree.mjs
1707
- import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync6, existsSync as existsSync4 } from "node:fs";
2150
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync6, existsSync as existsSync4 } from "node:fs";
1708
2151
  import { resolve as resolve4, basename, join as join4, relative as relative2 } from "node:path";
1709
2152
  import ignore from "ignore";
1710
2153
  var SKIP_DIRS = /* @__PURE__ */ new Set([
@@ -1725,7 +2168,7 @@ function loadGitignore(rootDir) {
1725
2168
  const gi = join4(rootDir, ".gitignore");
1726
2169
  if (!existsSync4(gi)) return null;
1727
2170
  try {
1728
- const content = readFileSync3(gi, "utf-8");
2171
+ const content = readFileSync2(gi, "utf-8");
1729
2172
  return ignore().add(content);
1730
2173
  } catch {
1731
2174
  return null;
@@ -1971,7 +2414,7 @@ function fileInfo(filePath) {
1971
2414
  }
1972
2415
 
1973
2416
  // lib/setup.mjs
1974
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync5, mkdirSync, copyFileSync } from "node:fs";
2417
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync5, mkdirSync, copyFileSync } from "node:fs";
1975
2418
  import { resolve as resolve6, dirname as dirname3, join as join5 } from "node:path";
1976
2419
  import { fileURLToPath } from "node:url";
1977
2420
  import { homedir } from "node:os";
@@ -1999,7 +2442,7 @@ var CLAUDE_HOOKS = {
1999
2442
  };
2000
2443
  function readJson(filePath) {
2001
2444
  if (!existsSync5(filePath)) return null;
2002
- return JSON.parse(readFileSync4(filePath, "utf-8"));
2445
+ return JSON.parse(readFileSync3(filePath, "utf-8"));
2003
2446
  }
2004
2447
  function writeJson(filePath, data) {
2005
2448
  mkdirSync(dirname3(filePath), { recursive: true });
@@ -2078,7 +2521,7 @@ function installOutputStyle() {
2078
2521
  const source = resolve6(dirname3(fileURLToPath(import.meta.url)), "..", "output-style.md");
2079
2522
  const target = resolve6(homedir(), ".claude", "output-styles", "hex-line.md");
2080
2523
  mkdirSync(dirname3(target), { recursive: true });
2081
- writeFileSync2(target, readFileSync4(source, "utf-8"), "utf-8");
2524
+ writeFileSync2(target, readFileSync3(source, "utf-8"), "utf-8");
2082
2525
  const userSettings = resolve6(homedir(), ".claude/settings.json");
2083
2526
  const config = readJson(userSettings) || {};
2084
2527
  const prev = config.outputStyle;
@@ -2275,7 +2718,7 @@ Summary: ${summary}`);
2275
2718
  }
2276
2719
 
2277
2720
  // lib/bulk-replace.mjs
2278
- import { writeFileSync as writeFileSync3, readdirSync as readdirSync3 } from "node:fs";
2721
+ import { writeFileSync as writeFileSync3, readdirSync as readdirSync3, renameSync, unlinkSync } from "node:fs";
2279
2722
  import { resolve as resolve7, relative as relative3, join as join6 } from "node:path";
2280
2723
  var ignoreMod;
2281
2724
  try {
@@ -2348,7 +2791,17 @@ function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
2348
2791
  continue;
2349
2792
  }
2350
2793
  if (!dryRun) {
2351
- writeFileSync3(file, content, "utf-8");
2794
+ const tempPath = `${file}.hexline-tmp-${process.pid}`;
2795
+ try {
2796
+ writeFileSync3(tempPath, content, "utf-8");
2797
+ renameSync(tempPath, file);
2798
+ } catch (error) {
2799
+ try {
2800
+ unlinkSync(tempPath);
2801
+ } catch {
2802
+ }
2803
+ throw error;
2804
+ }
2352
2805
  }
2353
2806
  const relPath = relative3(abs, file).replace(/\\/g, "/");
2354
2807
  totalReplacements += replacementCount;
@@ -2384,7 +2837,7 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
2384
2837
  }
2385
2838
 
2386
2839
  // server.mjs
2387
- var version = true ? "1.4.0" : (await null).createRequire(import.meta.url)("./package.json").version;
2840
+ var version = true ? "1.6.0" : (await null).createRequire(import.meta.url)("./package.json").version;
2388
2841
  var { server, StdioServerTransport } = await createServerRuntime({
2389
2842
  name: "hex-line-mcp",
2390
2843
  version
@@ -2392,43 +2845,43 @@ var { server, StdioServerTransport } = await createServerRuntime({
2392
2845
  var replacementPairsSchema = z2.array(
2393
2846
  z2.object({ old: z2.string().min(1), new: z2.string() })
2394
2847
  ).min(1);
2395
- function coerceEdit(e) {
2396
- if (!e || typeof e !== "object" || Array.isArray(e)) return e;
2397
- if (e.set_line || e.replace_lines || e.insert_after || e.replace_between || e.replace) return e;
2398
- if (e.anchor && !e.start_anchor && !e.end_anchor && !e.boundary_mode && !e.range_checksum) {
2399
- const raw = e.new_text ?? e.updated_lines ?? e.content ?? e.line;
2400
- if (raw !== void 0) {
2401
- const text = Array.isArray(raw) ? raw.join("\n") : raw;
2402
- return { set_line: { anchor: e.anchor, new_text: text } };
2403
- }
2404
- }
2405
- if (e.start_anchor && e.end_anchor && e.boundary_mode && e.new_text !== void 0) {
2406
- return { replace_between: { start_anchor: e.start_anchor, end_anchor: e.end_anchor, new_text: e.new_text, boundary_mode: e.boundary_mode } };
2407
- }
2408
- if (e.start_anchor && e.end_anchor && e.new_text !== void 0) {
2409
- return { replace_lines: { start_anchor: e.start_anchor, end_anchor: e.end_anchor, new_text: e.new_text, ...e.range_checksum ? { range_checksum: e.range_checksum } : {} } };
2848
+ var readRangeSchema = z2.union([
2849
+ z2.string(),
2850
+ z2.object({
2851
+ start: flexNum().optional(),
2852
+ end: flexNum().optional()
2853
+ })
2854
+ ]);
2855
+ function parseReadRanges(rawRanges) {
2856
+ if (!rawRanges) return void 0;
2857
+ const parsed = Array.isArray(rawRanges) ? rawRanges : JSON.parse(rawRanges);
2858
+ if (!Array.isArray(parsed) || parsed.length === 0) {
2859
+ throw new Error("ranges must be a non-empty array");
2410
2860
  }
2411
- return e;
2861
+ return parsed;
2412
2862
  }
2413
2863
  server.registerTool("read_file", {
2414
2864
  title: "Read File",
2415
- description: "Read a file with hash-annotated lines, range checksums, and current revision. Use offset/limit for targeted reads; use outline first for large code files.",
2865
+ description: "Read file with hash-annotated lines, checksums, and revision metadata. Default: edit-ready output. Use plain:true for non-edit workflows.",
2416
2866
  inputSchema: z2.object({
2417
2867
  path: z2.string().optional().describe("File or directory path"),
2418
2868
  paths: z2.array(z2.string()).optional().describe("Array of file paths to read (batch mode)"),
2419
2869
  offset: flexNum().describe("Start line (1-indexed, default: 1)"),
2420
2870
  limit: flexNum().describe("Max lines (default: 2000, 0 = all)"),
2871
+ ranges: z2.union([z2.string(), z2.array(readRangeSchema)]).optional().describe('Line ranges, e.g. ["10-25", {"start":40,"end":55}]'),
2872
+ include_graph: flexBool().describe("Include graph annotations"),
2421
2873
  plain: flexBool().describe("Omit hashes (lineNum|content)")
2422
2874
  }),
2423
2875
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2424
2876
  }, async (rawParams) => {
2425
- const { path: p, paths: multi, offset, limit, plain } = coerceParams(rawParams);
2877
+ const { path: p, paths: multi, offset, limit, ranges: rawRanges, include_graph, plain } = coerceParams(rawParams);
2426
2878
  try {
2879
+ const ranges = parseReadRanges(rawRanges);
2427
2880
  if (multi && multi.length > 0 && !p) {
2428
2881
  const results = [];
2429
2882
  for (const fp of multi) {
2430
2883
  try {
2431
- results.push(readFile2(fp, { offset, limit, plain }));
2884
+ results.push(readFile2(fp, { offset, limit, ranges, includeGraph: include_graph, plain }));
2432
2885
  } catch (e) {
2433
2886
  results.push(`File: ${fp}
2434
2887
 
@@ -2438,18 +2891,18 @@ ERROR: ${e.message}`);
2438
2891
  return { content: [{ type: "text", text: results.join("\n\n---\n\n") }] };
2439
2892
  }
2440
2893
  if (!p) throw new Error("Either 'path' or 'paths' is required");
2441
- return { content: [{ type: "text", text: readFile2(p, { offset, limit, plain }) }] };
2894
+ return { content: [{ type: "text", text: readFile2(p, { offset, limit, ranges, includeGraph: include_graph, plain }) }] };
2442
2895
  } catch (e) {
2443
2896
  return { content: [{ type: "text", text: e.message }], isError: true };
2444
2897
  }
2445
2898
  });
2446
2899
  server.registerTool("edit_file", {
2447
2900
  title: "Edit File",
2448
- description: "Apply revision-aware partial edits to one file. Prefer one batched call per file. Supports set_line, replace_lines, insert_after, and replace_between. For text rename/refactor use bulk_replace.",
2901
+ description: "Apply verified partial edits to one file.",
2449
2902
  inputSchema: z2.object({
2450
2903
  path: z2.string().describe("File to edit"),
2451
2904
  edits: z2.union([z2.string(), z2.array(z2.any())]).describe(
2452
- 'JSON array. Types: set_line, replace_lines, insert_after, replace_between.\n[{"set_line":{"anchor":"ab.12","new_text":"x"}}]\n[{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"x","range_checksum":"10-15:a1b2"}}]\n[{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"x","boundary_mode":"inclusive"}}]\n[{"insert_after":{"anchor":"ab.20","text":"x"}}]'
2905
+ 'JSON array of canonical edits.\n[{"set_line":{"anchor":"ab.12","new_text":"x"}}]\n[{"replace_lines":{"start_anchor":"ab.10","end_anchor":"cd.15","new_text":"x","range_checksum":"10-15:a1b2"}}]\n[{"insert_after":{"anchor":"ab.20","text":"x"}}]\n[{"replace_between":{"start_anchor":"ab.10","end_anchor":"cd.40","new_text":"x","boundary_mode":"inclusive"}}]'
2453
2906
  ),
2454
2907
  dry_run: flexBool().describe("Preview changes without writing"),
2455
2908
  restore_indent: flexBool().describe("Auto-fix indentation to match anchor (default: false)"),
@@ -2467,11 +2920,10 @@ server.registerTool("edit_file", {
2467
2920
  throw new Error('edits: invalid JSON. Expected: [{"set_line":{"anchor":"xx.N","new_text":"..."}}]');
2468
2921
  }
2469
2922
  if (!Array.isArray(parsed) || !parsed.length) throw new Error("Edits: non-empty JSON array required");
2470
- const normalized = parsed.map(coerceEdit);
2471
2923
  return {
2472
2924
  content: [{
2473
2925
  type: "text",
2474
- text: editFile(p, normalized, {
2926
+ text: editFile(p, parsed, {
2475
2927
  dryRun: dry_run,
2476
2928
  restoreIndent: restore_indent,
2477
2929
  baseRevision: base_revision,
@@ -2504,7 +2956,7 @@ server.registerTool("write_file", {
2504
2956
  });
2505
2957
  server.registerTool("grep_search", {
2506
2958
  title: "Search Files",
2507
- description: "Search file contents with ripgrep. Returns hash-annotated matches with checksums. Modes: content (default), files, count. Use checksums with set_line/replace_lines. Prefer over shell grep/rg.",
2959
+ description: "Search file contents with ripgrep. Default: edit-ready blocks with hashes and checksums. Use output:'files' or 'count' for non-edit workflows. With graph DB: results ranked by importance.",
2508
2960
  inputSchema: z2.object({
2509
2961
  pattern: z2.string().describe("Search pattern (regex by default, literal if literal:true)"),
2510
2962
  path: z2.string().optional().describe("Search dir/file (default: cwd)"),
@@ -2565,7 +3017,7 @@ server.registerTool("grep_search", {
2565
3017
  });
2566
3018
  server.registerTool("outline", {
2567
3019
  title: "File Outline",
2568
- description: "AST-based structural outline: functions, classes, interfaces with line ranges. Use before reading large code files. Not for .md/.json/.yaml.",
3020
+ description: "AST-based structural outline with hash anchors for direct edit_file usage. Supports code files (JS/TS/Python/Go/Rust/Java/C/C++/C#/Ruby/PHP/Kotlin/Swift/Bash) and markdown headings (.md/.mdx, fence-aware).",
2569
3021
  inputSchema: z2.object({
2570
3022
  path: z2.string().describe("Source file path")
2571
3023
  }),
@@ -2581,19 +3033,20 @@ server.registerTool("outline", {
2581
3033
  });
2582
3034
  server.registerTool("verify", {
2583
3035
  title: "Verify Checksums",
2584
- description: "Check whether held checksums and optional base_revision are still current, without rereading the file.",
3036
+ description: "Verify held checksums without rereading the file.",
2585
3037
  inputSchema: z2.object({
2586
3038
  path: z2.string().describe("File path"),
2587
- checksums: z2.string().describe('JSON array of checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
3039
+ checksums: z2.array(z2.string()).describe('Checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
2588
3040
  base_revision: z2.string().optional().describe("Optional prior revision to compare against latest state.")
2589
3041
  }),
2590
3042
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }
2591
3043
  }, async (rawParams) => {
2592
3044
  const { path: p, checksums, base_revision } = coerceParams(rawParams);
2593
3045
  try {
2594
- const parsed = JSON.parse(checksums);
2595
- if (!Array.isArray(parsed)) throw new Error("checksums must be a JSON array of strings");
2596
- return { content: [{ type: "text", text: verifyChecksums(p, parsed, { baseRevision: base_revision }) }] };
3046
+ if (!Array.isArray(checksums) || checksums.length === 0) {
3047
+ throw new Error("checksums must be a non-empty array of strings");
3048
+ }
3049
+ return { content: [{ type: "text", text: verifyChecksums(p, checksums, { baseRevision: base_revision }) }] };
2597
3050
  } catch (e) {
2598
3051
  return { content: [{ type: "text", text: e.message }], isError: true };
2599
3052
  }
@@ -2667,7 +3120,7 @@ server.registerTool("changes", {
2667
3120
  });
2668
3121
  server.registerTool("bulk_replace", {
2669
3122
  title: "Bulk Replace",
2670
- description: "Search-and-replace across multiple files. Finds files by glob, applies ordered text replacements. Default format is compact (summary only); use format:'full' for capped diffs. Use dry_run:true to preview. For single-file rename, set glob to the filename.",
3123
+ description: "Search-and-replace across multiple files with compact or full diff output.",
2671
3124
  inputSchema: z2.object({
2672
3125
  replacements: z2.union([z2.string(), replacementPairsSchema]).describe('JSON array of {old, new} pairs: [{"old":"foo","new":"bar"}]'),
2673
3126
  glob: z2.string().optional().describe('File glob (default: "**/*.{md,mjs,json,yml,ts,js}")'),