@levnikolaevich/hex-line-mcp 1.12.0 → 1.13.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/README.md CHANGED
@@ -7,7 +7,7 @@ Hash-verified file editing MCP + token efficiency hook for AI coding agents.
7
7
  [![license](https://img.shields.io/npm/l/@levnikolaevich/hex-line-mcp)](./LICENSE)
8
8
  ![node](https://img.shields.io/node/v/@levnikolaevich/hex-line-mcp)
9
9
 
10
- Every line carries an FNV-1a content hash. Every edit must present those hashes back -- proving the agent is editing what it thinks it's editing. No stale context, no silent corruption.
10
+ Every line carries an FNV-1a content hash. Every edit must present those hashes back -- proving the agent is editing what it thinks it's editing. No stale context, no silent corruption. Hashing works on normalized logical text; writes preserve the file's existing line endings and trailing-newline shape.
11
11
 
12
12
  ## Features
13
13
 
@@ -64,6 +64,8 @@ claude mcp add -s user hex-line -- hex-line-mcp
64
64
 
65
65
  ripgrep is bundled via `@vscode/ripgrep` — no manual install needed for `grep_search`.
66
66
 
67
+ Requires Node.js >= 20.19.0.
68
+
67
69
  ### Hooks
68
70
 
69
71
  Hooks and output style are auto-synced on every MCP server startup. The server compares installed files with bundled versions and updates only when content differs. First run after `npm i -g` triggers full install automatically.
@@ -124,7 +126,8 @@ If a project already has `.hex-skills/codegraph/index.db`, `hex-line` automatica
124
126
 
125
127
  1. Carry `revision` from the earlier `read_file` or `edit_file`
126
128
  2. Pass it back as `base_revision`
127
- 3. Use `verify` before rereading the file
129
+ 3. Use `verify` before delayed or mixed-tool follow-up edits
130
+ 4. If the server returns `retry_edit`, `retry_edits`, `retry_checksum`, or `retry_plan`, reuse those directly
128
131
 
129
132
  ### Rewrite a long block
130
133
 
@@ -154,9 +157,13 @@ File: lib/search.mjs
154
157
  meta: 282 lines, 10.2KB, 2 hours ago
155
158
  revision: rev-12-a1b2c3d4
156
159
  file: 1-282:beefcafe
160
+ eol: lf
161
+ trailing_newline: true
157
162
 
158
163
  block: read_range
159
164
  span: 1-3
165
+ eol: lf
166
+ trailing_newline: true
160
167
  ab.1 import { resolve } from "node:path";
161
168
  cd.2 import { readFileSync } from "node:fs";
162
169
  ef.3 ...
@@ -191,7 +198,8 @@ Discipline:
191
198
 
192
199
  - Never invent `range_checksum`. Copy it from `read_file` or `grep_search(output:"content")`.
193
200
  - First mutation in a file: prefer `grep_search` for narrow targets, or `outline -> read_file(ranges)` for structural edits.
194
- - Prefer 1-2 hunks on the first pass. Once `edit_file` returns a fresh `revision`, continue from that state.
201
+ - Prefer 1-2 hunks on the first pass. Once `edit_file` returns a fresh `revision`, continue from that state as `base_revision`.
202
+ - `hex-line` preserves existing file line endings on write; repo-level line-ending cleanup should be a separate deliberate operation, not a side effect of `edit_file`.
195
203
 
196
204
  Result footer includes:
197
205
 
package/dist/hook.mjs CHANGED
@@ -111,7 +111,7 @@ var REVERSE_TOOL_HINTS = {
111
111
  "mcp__hex-line__grep_search": "Grep (pattern, path)",
112
112
  "mcp__hex-line__inspect_path": "Path info / tree / Bash(ls,stat)",
113
113
  "mcp__hex-line__outline": "Read with offset/limit",
114
- "mcp__hex-line__verify": "Read (re-read file to check freshness)",
114
+ "mcp__hex-line__verify": "Read (check checksum/revision freshness before follow-up edits)",
115
115
  "mcp__hex-line__changes": "Bash(git diff)",
116
116
  "mcp__hex-line__bulk_replace": "Edit (text rename/refactor across files inside an explicit root path)"
117
117
  };
@@ -129,7 +129,7 @@ var TOOL_HINTS = {
129
129
  sed: "mcp__hex-line__edit_file for hash edits, or mcp__hex-line__bulk_replace with path=<project root> for text rename (not sed -i)",
130
130
  diff: "mcp__hex-line__changes (not diff). Git diff with change symbols",
131
131
  outline: "mcp__hex-line__outline (before reading large code files)",
132
- verify: "mcp__hex-line__verify (staleness / revision check without re-read)",
132
+ verify: "mcp__hex-line__verify (staleness / revision check without re-read; use before delayed same-file follow-ups)",
133
133
  changes: "mcp__hex-line__changes (git diff with change symbols)",
134
134
  bulk: "mcp__hex-line__bulk_replace with path=<project root> (multi-file search-replace)"
135
135
  };
package/dist/server.mjs CHANGED
@@ -48,6 +48,7 @@ import { tmpdir } from "node:os";
48
48
  var CACHE_FILE = join(tmpdir(), "hex-common-update.json");
49
49
  var CHECK_INTERVAL = 24 * 60 * 60 * 1e3;
50
50
  var TIMEOUT = 3e3;
51
+ var SEMVER_PART_COUNT = 3;
51
52
  async function readCache() {
52
53
  try {
53
54
  return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
@@ -75,7 +76,7 @@ async function fetchLatest(packageName) {
75
76
  function compareVersions(a, b) {
76
77
  const pa = a.split(".").map(Number);
77
78
  const pb = b.split(".").map(Number);
78
- for (let i = 0; i < 3; i++) {
79
+ for (let i = 0; i < SEMVER_PART_COUNT; i++) {
79
80
  if ((pa[i] || 0) < (pb[i] || 0)) return -1;
80
81
  if ((pa[i] || 0) > (pb[i] || 0)) return 1;
81
82
  }
@@ -297,6 +298,7 @@ var FACT_PRIORITY = /* @__PURE__ */ new Map([
297
298
  ]);
298
299
  var _dbs = /* @__PURE__ */ new Map();
299
300
  var _driverUnavailable = false;
301
+ var MAX_PROJECT_ROOT_ASCENT = 25;
300
302
  var PROJECT_BOUNDARY_MARKERS = [
301
303
  "package.json",
302
304
  "pyproject.toml",
@@ -595,7 +597,7 @@ function getRelativePath(filePath) {
595
597
  }
596
598
  function findProjectRoot(filePath) {
597
599
  let dir = dirname2(filePath);
598
- for (let i = 0; i < 25; i++) {
600
+ for (let i = 0; i < MAX_PROJECT_ROOT_ASCENT; i++) {
599
601
  if (existsSync2(join3(dir, ".hex-skills/codegraph", "index.db"))) return dir;
600
602
  if (PROJECT_BOUNDARY_MARKERS.some((marker) => existsSync2(join3(dir, marker)))) return dir;
601
603
  const parent = dirname2(dir);
@@ -667,6 +669,70 @@ function parseChecksum(cs) {
667
669
  return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
668
670
  }
669
671
 
672
+ // ../hex-common/src/text/file-text.mjs
673
+ import { readFileSync as readFileSync2 } from "node:fs";
674
+ function classifyEol(counts) {
675
+ const active = Object.entries(counts).filter(([, count]) => count > 0);
676
+ if (active.length === 0) return "none";
677
+ if (active.length === 1) return active[0][0];
678
+ return "mixed";
679
+ }
680
+ function chooseDefaultEol(lineEndings, counts) {
681
+ const active = Object.entries(counts).filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1]);
682
+ if (active.length === 0) return "\n";
683
+ if (active.length === 1 || active[0][1] > active[1][1]) {
684
+ return active[0][0] === "crlf" ? "\r\n" : active[0][0] === "cr" ? "\r" : "\n";
685
+ }
686
+ const firstSeen = lineEndings.find((ending) => ending);
687
+ return firstSeen || "\n";
688
+ }
689
+ function normalizeSourceText(text) {
690
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
691
+ }
692
+ function parseUtf8TextWithMetadata(text) {
693
+ const lines = [];
694
+ const lineEndings = [];
695
+ const eolCounts = { lf: 0, crlf: 0, cr: 0 };
696
+ let start = 0;
697
+ for (let i = 0; i < text.length; i++) {
698
+ const ch = text[i];
699
+ if (ch === "\r") {
700
+ const isCrlf = text[i + 1] === "\n";
701
+ lines.push(text.slice(start, i));
702
+ lineEndings.push(isCrlf ? "\r\n" : "\r");
703
+ if (isCrlf) {
704
+ eolCounts.crlf++;
705
+ i++;
706
+ } else {
707
+ eolCounts.cr++;
708
+ }
709
+ start = i + 1;
710
+ continue;
711
+ }
712
+ if (ch === "\n") {
713
+ lines.push(text.slice(start, i));
714
+ lineEndings.push("\n");
715
+ eolCounts.lf++;
716
+ start = i + 1;
717
+ }
718
+ }
719
+ lines.push(text.slice(start));
720
+ lineEndings.push("");
721
+ const trailingNewline = text.endsWith("\n") || text.endsWith("\r");
722
+ return {
723
+ rawText: text,
724
+ content: normalizeSourceText(text),
725
+ lines,
726
+ lineEndings,
727
+ trailingNewline,
728
+ eol: classifyEol(eolCounts),
729
+ defaultEol: chooseDefaultEol(lineEndings, eolCounts)
730
+ };
731
+ }
732
+ function readUtf8WithMetadata(filePath) {
733
+ return parseUtf8TextWithMetadata(readFileSync2(filePath, "utf-8"));
734
+ }
735
+
670
736
  // lib/snapshot.mjs
671
737
  var MAX_FILES = 200;
672
738
  var MAX_REVISIONS_PER_FILE = 5;
@@ -703,6 +769,10 @@ function pruneExpired(now = Date.now()) {
703
769
  }
704
770
  function rememberRevisionId(filePath, revision) {
705
771
  const ids = fileRevisionIds.get(filePath) || [];
772
+ if (ids.includes(revision)) {
773
+ fileRevisionIds.set(filePath, ids);
774
+ return;
775
+ }
706
776
  ids.push(revision);
707
777
  while (ids.length > MAX_REVISIONS_PER_FILE) {
708
778
  const removed = ids.shift();
@@ -794,36 +864,42 @@ function describeChangedRanges(ranges) {
794
864
  if (!ranges?.length) return "none";
795
865
  return ranges.map((r) => `${r.start}-${r.end}${r.kind ? `(${r.kind})` : ""}`).join(", ");
796
866
  }
797
- function createSnapshot(filePath, content, mtimeMs, size, prevSnapshot = null) {
798
- const lines = content.split("\n");
867
+ function createSnapshot(filePath, parsed, mtimeMs, size, prevSnapshot = null, revisionOverride = null) {
868
+ const { content, lines, lineEndings, rawText, eol, defaultEol, trailingNewline } = parsed;
799
869
  const lineHashes = lines.map((line) => fnv1a(line));
800
870
  const fileChecksum = computeFileChecksum(lineHashes);
801
- const revision = `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
871
+ const revision = revisionOverride || `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
802
872
  return {
803
873
  revision,
804
874
  path: filePath,
805
875
  content,
876
+ rawText,
806
877
  lines,
878
+ lineEndings,
807
879
  lineHashes,
808
880
  fileChecksum,
809
881
  uniqueTagIndex: buildUniqueTagIndex(lineHashes),
810
882
  changedRangesFromPrev: prevSnapshot ? computeChangedRanges(prevSnapshot.lines, lines) : [],
811
883
  prevRevision: prevSnapshot?.revision || null,
884
+ eol,
885
+ defaultEol,
886
+ trailingNewline,
812
887
  mtimeMs,
813
888
  size,
814
889
  createdAt: Date.now()
815
890
  };
816
891
  }
817
- function rememberSnapshot(filePath, content, meta = {}) {
892
+ function rememberSnapshot(filePath, input, meta = {}) {
818
893
  pruneExpired();
819
894
  const latest = latestByFile.get(filePath);
895
+ const parsed = typeof input === "string" ? parseUtf8TextWithMetadata(input) : input;
820
896
  const mtimeMs = meta.mtimeMs ?? latest?.mtimeMs ?? Date.now();
821
- const size = meta.size ?? Buffer.byteLength(content, "utf8");
822
- if (latest && latest.content === content && latest.mtimeMs === mtimeMs && latest.size === size) {
897
+ const size = meta.size ?? Buffer.byteLength(parsed.rawText, "utf8");
898
+ if (latest && latest.content === parsed.content && latest.rawText === parsed.rawText && latest.mtimeMs === mtimeMs && latest.size === size) {
823
899
  touchFile(filePath);
824
900
  return latest;
825
901
  }
826
- const snapshot = createSnapshot(filePath, content, mtimeMs, size, latest || null);
902
+ const snapshot = latest && latest.content === parsed.content ? createSnapshot(filePath, parsed, mtimeMs, size, latest || null, latest.revision) : createSnapshot(filePath, parsed, mtimeMs, size, latest || null);
827
903
  latestByFile.set(filePath, snapshot);
828
904
  revisionsById.set(snapshot.revision, snapshot);
829
905
  rememberRevisionId(filePath, snapshot.revision);
@@ -839,8 +915,8 @@ function readSnapshot(filePath) {
839
915
  touchFile(filePath);
840
916
  return latest;
841
917
  }
842
- const content = readText(filePath);
843
- return rememberSnapshot(filePath, content, { mtimeMs: stat.mtimeMs, size: stat.size });
918
+ const parsed = readUtf8WithMetadata(filePath);
919
+ return rememberSnapshot(filePath, parsed, { mtimeMs: stat.mtimeMs, size: stat.size });
844
920
  }
845
921
  function getSnapshotByRevision(revision) {
846
922
  pruneExpired();
@@ -872,6 +948,9 @@ function renderRequestedSpan(block) {
872
948
  if (block.requestedStartLine === block.startLine && block.requestedEndLine === block.endLine) return null;
873
949
  return `requested_span: ${block.requestedStartLine}-${block.requestedEndLine}`;
874
950
  }
951
+ function renderMetaLines(meta = {}) {
952
+ return Object.entries(meta).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => `${key}: ${value}`);
953
+ }
875
954
  function renderBaseEntry(entry, plain = false) {
876
955
  return plain ? `${entry.lineNumber}|${entry.text}` : `${entry.tag}.${entry.lineNumber} ${entry.text}`;
877
956
  }
@@ -948,6 +1027,7 @@ function serializeReadBlock(block, opts = {}) {
948
1027
  ];
949
1028
  const requestedSpan = renderRequestedSpan(block);
950
1029
  if (requestedSpan) lines.push(requestedSpan);
1030
+ lines.push(...renderMetaLines(block.meta));
951
1031
  lines.push(...block.entries.map((entry) => serializeReadEntry(entry, opts)));
952
1032
  lines.push(`checksum: ${block.checksum}`);
953
1033
  return lines.join("\n");
@@ -965,6 +1045,9 @@ function serializeSearchBlock(block, opts = {}) {
965
1045
  lines.push(`match_lines: ${block.meta.matchLines.join(",")}`);
966
1046
  }
967
1047
  if (block.meta.summary) lines.push(`summary: ${block.meta.summary}`);
1048
+ lines.push(...renderMetaLines(Object.fromEntries(
1049
+ Object.entries(block.meta).filter(([key]) => key !== "matchLines" && key !== "summary")
1050
+ )));
968
1051
  lines.push(...block.entries.map((entry) => serializeSearchEntry(entry, opts)));
969
1052
  lines.push(`checksum: ${block.checksum}`);
970
1053
  return lines.join("\n");
@@ -1058,7 +1141,11 @@ function buildReadBlock(snapshot, range, plain, remainingChars) {
1058
1141
  kind: "read_range",
1059
1142
  entries,
1060
1143
  requestedStartLine: range.requestedStartLine,
1061
- requestedEndLine: range.requestedEndLine
1144
+ requestedEndLine: range.requestedEndLine,
1145
+ meta: {
1146
+ eol: snapshot.eol,
1147
+ trailing_newline: snapshot.trailingNewline
1148
+ }
1062
1149
  }),
1063
1150
  remainingChars: nextBudget,
1064
1151
  cappedAtLine
@@ -1164,6 +1251,8 @@ Graph: ${items.join(" | ")}`;
1164
1251
  meta: ${meta}
1165
1252
  revision: ${snapshot.revision}
1166
1253
  file: ${snapshot.fileChecksum}
1254
+ eol: ${snapshot.eol}
1255
+ trailing_newline: ${snapshot.trailingNewline}
1167
1256
 
1168
1257
  ${serializedBlocks.join("\n\n")}`.trim();
1169
1258
  }
@@ -1335,6 +1424,41 @@ function sanitizeEditText(text) {
1335
1424
  if (hadTrailingNewline && !cleaned.endsWith("\n")) cleaned += "\n";
1336
1425
  return cleaned;
1337
1426
  }
1427
+ function replaceLogicalRange(lines, lineEndings, startIdx, endIdx, newLines, defaultEol) {
1428
+ const removeCount = endIdx - startIdx + 1;
1429
+ const tailEnding = lineEndings[endIdx] ?? "";
1430
+ const lastIdx = lines.length - 1;
1431
+ if (newLines.length === 0) {
1432
+ lines.splice(startIdx, removeCount);
1433
+ lineEndings.splice(startIdx, removeCount);
1434
+ if (lines.length === 0) {
1435
+ lines.push("");
1436
+ lineEndings.push("");
1437
+ return;
1438
+ }
1439
+ if (endIdx === lastIdx && startIdx > 0) {
1440
+ lineEndings[startIdx - 1] = tailEnding;
1441
+ }
1442
+ return;
1443
+ }
1444
+ const newEndings = newLines.map((_, idx) => idx === newLines.length - 1 ? tailEnding : defaultEol);
1445
+ lines.splice(startIdx, removeCount, ...newLines);
1446
+ lineEndings.splice(startIdx, removeCount, ...newEndings);
1447
+ }
1448
+ function insertLogicalLinesAfter(lines, lineEndings, idx, newLines, defaultEol) {
1449
+ if (newLines.length === 0) return;
1450
+ let lastInsertedEnding = defaultEol;
1451
+ if ((lineEndings[idx] ?? "") === "") {
1452
+ lineEndings[idx] = defaultEol;
1453
+ lastInsertedEnding = "";
1454
+ }
1455
+ const insertedEndings = newLines.map((_, index) => index === newLines.length - 1 ? lastInsertedEnding : defaultEol);
1456
+ lines.splice(idx + 1, 0, ...newLines);
1457
+ lineEndings.splice(idx + 1, 0, ...insertedEndings);
1458
+ }
1459
+ function composeRawText(lines, lineEndings) {
1460
+ return lines.map((line, idx) => `${line}${lineEndings[idx] ?? ""}`).join("");
1461
+ }
1338
1462
  function findLine(lines, lineNum, expectedTag, hashIndex) {
1339
1463
  const idx = lineNum - 1;
1340
1464
  if (idx < 0 || idx >= lines.length) {
@@ -1992,7 +2116,7 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
1992
2116
  return conflicts;
1993
2117
  }
1994
2118
  function applySetLineEdit(edit, ctx) {
1995
- const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
2119
+ const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
1996
2120
  const { tag, line } = parseRef(edit.set_line.anchor);
1997
2121
  const idx = locateOrConflict({ tag, line }, "stale_anchor", () => buildRetryEdit(edit, lines));
1998
2122
  if (typeof idx === "string") return idx;
@@ -2002,17 +2126,17 @@ function applySetLineEdit(edit, ctx) {
2002
2126
  if (conflict) return conflict;
2003
2127
  const txt = edit.set_line.new_text;
2004
2128
  if (!txt && txt !== 0) {
2005
- lines.splice(idx, 1);
2129
+ replaceLogicalRange(lines, lineEndings, idx, idx, [], defaultEol);
2006
2130
  return null;
2007
2131
  }
2008
2132
  const origLine = [lines[idx]];
2009
2133
  const raw = sanitizeEditText(txt).split("\n");
2010
2134
  const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
2011
- lines.splice(idx, 1, ...newLines);
2135
+ replaceLogicalRange(lines, lineEndings, idx, idx, newLines, defaultEol);
2012
2136
  return null;
2013
2137
  }
2014
2138
  function applyInsertAfterEdit(edit, ctx) {
2015
- const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
2139
+ const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
2016
2140
  const { tag, line } = parseRef(edit.insert_after.anchor);
2017
2141
  const idx = locateOrConflict({ tag, line }, "stale_anchor", () => buildRetryEdit(edit, lines));
2018
2142
  if (typeof idx === "string") return idx;
@@ -2022,7 +2146,7 @@ function applyInsertAfterEdit(edit, ctx) {
2022
2146
  if (conflict) return conflict;
2023
2147
  let insertLines = sanitizeEditText(edit.insert_after.text).split("\n");
2024
2148
  if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
2025
- lines.splice(idx + 1, 0, ...insertLines);
2149
+ insertLogicalLinesAfter(lines, lineEndings, idx, insertLines, defaultEol);
2026
2150
  return null;
2027
2151
  }
2028
2152
  function applyReplaceLinesEdit(edit, ctx) {
@@ -2033,6 +2157,8 @@ function applyReplaceLinesEdit(edit, ctx) {
2033
2157
  currentSnapshot,
2034
2158
  ensureRevisionContext,
2035
2159
  hasBaseSnapshot,
2160
+ lineEndings,
2161
+ defaultEol,
2036
2162
  lines,
2037
2163
  locateOrConflict,
2038
2164
  opts,
@@ -2122,17 +2248,17 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
2122
2248
  }
2123
2249
  const txt = edit.replace_lines.new_text;
2124
2250
  if (!txt && txt !== 0) {
2125
- lines.splice(startIdx, endIdx - startIdx + 1);
2251
+ replaceLogicalRange(lines, lineEndings, startIdx, endIdx, [], defaultEol);
2126
2252
  return null;
2127
2253
  }
2128
2254
  const origRange = lines.slice(startIdx, endIdx + 1);
2129
2255
  let newLines = sanitizeEditText(txt).split("\n");
2130
2256
  if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
2131
- lines.splice(startIdx, endIdx - startIdx + 1, ...newLines);
2257
+ replaceLogicalRange(lines, lineEndings, startIdx, endIdx, newLines, defaultEol);
2132
2258
  return null;
2133
2259
  }
2134
2260
  function applyReplaceBetweenEdit(edit, ctx) {
2135
- const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
2261
+ const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
2136
2262
  const boundaryMode = edit.replace_between.boundary_mode || "inclusive";
2137
2263
  if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
2138
2264
  throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
@@ -2161,7 +2287,11 @@ function applyReplaceBetweenEdit(edit, ctx) {
2161
2287
  const origRange = lines.slice(sliceStart, sliceStart + removeCount);
2162
2288
  if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
2163
2289
  if (txt === "" || txt === null) newLines = [];
2164
- lines.splice(sliceStart, removeCount, ...newLines);
2290
+ if (removeCount === 0) {
2291
+ insertLogicalLinesAfter(lines, lineEndings, sliceStart - 1, newLines, defaultEol);
2292
+ return null;
2293
+ }
2294
+ replaceLogicalRange(lines, lineEndings, sliceStart, sliceStart + removeCount - 1, newLines, defaultEol);
2165
2295
  return null;
2166
2296
  }
2167
2297
  function editFile(filePath, edits, opts = {}) {
@@ -2173,11 +2303,12 @@ function editFile(filePath, edits, opts = {}) {
2173
2303
  const staleRevision = !!opts.baseRevision && opts.baseRevision !== currentSnapshot.revision && hasBaseSnapshot;
2174
2304
  const changedRanges = staleRevision && hasBaseSnapshot ? computeChangedRanges(baseSnapshot.lines, currentSnapshot.lines) : [];
2175
2305
  const conflictPolicy = opts.conflictPolicy || "conservative";
2176
- const original = currentSnapshot.content;
2306
+ const originalRaw = currentSnapshot.rawText;
2177
2307
  const lines = [...currentSnapshot.lines];
2308
+ const lineEndings = [...currentSnapshot.lineEndings];
2178
2309
  const origLines = [...currentSnapshot.lines];
2179
- const hadTrailingNewline = original.endsWith("\n");
2180
2310
  const hashIndex = currentSnapshot.uniqueTagIndex;
2311
+ const defaultEol = currentSnapshot.defaultEol || "\n";
2181
2312
  let autoRebased = false;
2182
2313
  const remaps = [];
2183
2314
  const remapKeys = /* @__PURE__ */ new Set();
@@ -2298,6 +2429,8 @@ ${snip.text}`;
2298
2429
  ensureRevisionContext,
2299
2430
  hasBaseSnapshot,
2300
2431
  lines,
2432
+ lineEndings,
2433
+ defaultEol,
2301
2434
  locateOrConflict,
2302
2435
  opts,
2303
2436
  origLines,
@@ -2338,19 +2471,17 @@ ${snip.text}`;
2338
2471
  throw editErr;
2339
2472
  }
2340
2473
  }
2341
- let content = lines.join("\n");
2342
- if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
2343
- if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
2344
- if (original === content) {
2474
+ const content = composeRawText(lines, lineEndings);
2475
+ if (originalRaw === content) {
2345
2476
  throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
2346
2477
  }
2347
- const fullDiff = simpleDiff(origLines, content.split("\n"));
2478
+ const fullDiff = simpleDiff(origLines, lines);
2348
2479
  let displayDiff = fullDiff;
2349
2480
  if (displayDiff && displayDiff.length > MAX_DIFF_CHARS) {
2350
2481
  displayDiff = displayDiff.slice(0, MAX_DIFF_CHARS) + `
2351
2482
  ... (diff truncated, ${displayDiff.length} chars total)`;
2352
2483
  }
2353
- const newLinesAll = content.split("\n");
2484
+ const newLinesAll = lines;
2354
2485
  let minLine = Infinity, maxLine = 0;
2355
2486
  if (fullDiff) {
2356
2487
  for (const dl of fullDiff.split("\n")) {
@@ -2367,7 +2498,7 @@ ${snip.text}`;
2367
2498
  reason: ${REASON.DRY_RUN_PREVIEW}
2368
2499
  revision: ${currentSnapshot.revision}
2369
2500
  file: ${currentSnapshot.fileChecksum}
2370
- Dry run: ${filePath} would change (${content.split("\n").length} lines)`;
2501
+ Dry run: ${filePath} would change (${lines.length} lines)`;
2371
2502
  if (staleRevision && hasBaseSnapshot) msg2 += `
2372
2503
  changed_ranges: ${describeChangedRanges(changedRanges)}`;
2373
2504
  if (displayDiff) msg2 += `
@@ -2395,13 +2526,21 @@ remapped_refs:
2395
2526
  ${remaps.map(({ from, to }) => `${from} -> ${to}`).join("\n")}`;
2396
2527
  }
2397
2528
  msg += `
2398
- Updated ${filePath} (${content.split("\n").length} lines)`;
2529
+ Updated ${filePath} (${lines.length} lines)`;
2399
2530
  if (fullDiff && minLine <= maxLine) {
2400
2531
  const ctxStart = Math.max(0, minLine - 6) + 1;
2401
2532
  const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
2402
2533
  const entries = createSnapshotEntries(nextSnapshot, ctxStart, ctxEnd);
2403
2534
  if (entries.length > 0) {
2404
- const block = buildEditReadyBlock({ path: real, kind: "post_edit", entries });
2535
+ const block = buildEditReadyBlock({
2536
+ path: real,
2537
+ kind: "post_edit",
2538
+ entries,
2539
+ meta: {
2540
+ eol: nextSnapshot.eol,
2541
+ trailing_newline: nextSnapshot.trailingNewline
2542
+ }
2543
+ });
2405
2544
  msg += `
2406
2545
 
2407
2546
  ${serializeReadBlock(block)}`;
@@ -2726,7 +2865,7 @@ function isSupportedExtension(ext) {
2726
2865
  }
2727
2866
 
2728
2867
  // ../hex-common/src/parser/tree-sitter.mjs
2729
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
2868
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "node:fs";
2730
2869
  import { dirname as dirname3, resolve as resolve3 } from "node:path";
2731
2870
  import { fileURLToPath } from "node:url";
2732
2871
  var parserInstance = null;
@@ -2753,7 +2892,7 @@ function loadArtifactManifest() {
2753
2892
  `Tree-sitter artifact manifest is missing. Checked: ${artifactDirCandidates.join(", ")}. This package now ships first-party grammar WASM artifacts; restore artifacts/tree-sitter or rerun the artifact materialization step.`
2754
2893
  );
2755
2894
  }
2756
- artifactManifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
2895
+ artifactManifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
2757
2896
  return artifactManifest;
2758
2897
  }
2759
2898
  function treeSitterArtifactPath(grammar) {
@@ -3115,7 +3254,7 @@ import { statSync as statSync8 } from "node:fs";
3115
3254
  import { resolve as resolve6 } from "node:path";
3116
3255
 
3117
3256
  // lib/tree.mjs
3118
- import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync6, existsSync as existsSync5 } from "node:fs";
3257
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync6, existsSync as existsSync5 } from "node:fs";
3119
3258
  import { resolve as resolve4, basename, join as join4, relative as relative2 } from "node:path";
3120
3259
  import ignore from "ignore";
3121
3260
  var SKIP_DIRS = /* @__PURE__ */ new Set([
@@ -3136,7 +3275,7 @@ function loadGitignore(rootDir) {
3136
3275
  const gi = join4(rootDir, ".gitignore");
3137
3276
  if (!existsSync5(gi)) return null;
3138
3277
  try {
3139
- const content = readFileSync3(gi, "utf-8");
3278
+ const content = readFileSync4(gi, "utf-8");
3140
3279
  return ignore().add(content);
3141
3280
  } catch {
3142
3281
  return null;
@@ -3391,7 +3530,7 @@ function inspectPath(inputPath, opts = {}) {
3391
3530
  }
3392
3531
 
3393
3532
  // lib/setup.mjs
3394
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync, copyFileSync } from "node:fs";
3533
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync, copyFileSync } from "node:fs";
3395
3534
  import { resolve as resolve7, dirname as dirname4, join as join5 } from "node:path";
3396
3535
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3397
3536
  import { homedir } from "node:os";
@@ -3422,7 +3561,7 @@ var CLAUDE_HOOKS = {
3422
3561
  };
3423
3562
  function readJson(filePath) {
3424
3563
  if (!existsSync6(filePath)) return null;
3425
- return JSON.parse(readFileSync4(filePath, "utf-8"));
3564
+ return JSON.parse(readFileSync5(filePath, "utf-8"));
3426
3565
  }
3427
3566
  function writeJson(filePath, data) {
3428
3567
  mkdirSync(dirname4(filePath), { recursive: true });
@@ -3437,7 +3576,7 @@ function findEntryByCommand(entries) {
3437
3576
  }
3438
3577
  function safeRead(filePath) {
3439
3578
  try {
3440
- return readFileSync4(filePath, "utf-8");
3579
+ return readFileSync5(filePath, "utf-8");
3441
3580
  } catch {
3442
3581
  return null;
3443
3582
  }
@@ -3528,7 +3667,7 @@ import { join as join6 } from "node:path";
3528
3667
 
3529
3668
  // ../hex-common/src/git/semantic-diff.mjs
3530
3669
  import { execFileSync } from "node:child_process";
3531
- import { existsSync as existsSync7, readFileSync as readFileSync5, statSync as statSync9 } from "node:fs";
3670
+ import { existsSync as existsSync7, readFileSync as readFileSync6, statSync as statSync9 } from "node:fs";
3532
3671
  import { dirname as dirname5, extname as extname3, relative as relative3, resolve as resolve8 } from "node:path";
3533
3672
  function normalizePath2(value) {
3534
3673
  return value.replace(/\\/g, "/");
@@ -3708,7 +3847,7 @@ function compareSymbols(beforeEntries = [], afterEntries = []) {
3708
3847
  function readWorkingTreeFile(repoRoot, relPath) {
3709
3848
  const absPath = resolve8(repoRoot, relPath);
3710
3849
  if (!existsSync7(absPath)) return null;
3711
- return readFileSync5(absPath, "utf8").replace(/\r\n/g, "\n");
3850
+ return readFileSync6(absPath, "utf8").replace(/\r\n/g, "\n");
3712
3851
  }
3713
3852
  function readGitFile(repoRoot, ref, relPath) {
3714
3853
  if (!ref) return null;
@@ -4053,7 +4192,7 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
4053
4192
  }
4054
4193
 
4055
4194
  // server.mjs
4056
- var version = true ? "1.12.0" : (await null).createRequire(import.meta.url)("./package.json").version;
4195
+ var version = true ? "1.13.0" : (await null).createRequire(import.meta.url)("./package.json").version;
4057
4196
  var { server, StdioServerTransport } = await createServerRuntime({
4058
4197
  name: "hex-line-mcp",
4059
4198
  version
@@ -4078,7 +4217,7 @@ function parseReadRanges(rawRanges) {
4078
4217
  }
4079
4218
  server.registerTool("read_file", {
4080
4219
  title: "Read File",
4081
- description: "Read file with hash-annotated lines, checksums, revision metadata, and automatic graph hints when available. Default: edit-ready output. Use plain:true for non-edit workflows.",
4220
+ description: "Read file with hash-annotated lines, checksums, logical revision metadata, EOL/trailing-newline state, and graph hints when available. Default: edit-ready output.",
4082
4221
  inputSchema: z2.object({
4083
4222
  path: z2.string().optional().describe("File path"),
4084
4223
  paths: z2.array(z2.string()).optional().describe("Array of file paths to read (batch mode)"),
@@ -4113,7 +4252,7 @@ ERROR: ${e.message}`);
4113
4252
  });
4114
4253
  server.registerTool("edit_file", {
4115
4254
  title: "Edit File",
4116
- description: "Apply hash-verified partial edits to one file. Batch multiple edits in one call. Carry base_revision from prior read/edit for auto-rebase on concurrent changes. Conservative conflicts return retry_edit/retry_edits, suggested_read_call, and retry_plan when available.",
4255
+ description: "Apply hash-verified partial edits to one file. Carry base_revision on same-file follow-ups. Preserves existing line endings and trailing-newline shape; conservative conflicts return retry helpers.",
4117
4256
  inputSchema: z2.object({
4118
4257
  path: z2.string().describe("File to edit"),
4119
4258
  edits: z2.union([z2.string(), z2.array(z2.any())]).describe(
@@ -4248,7 +4387,7 @@ server.registerTool("outline", {
4248
4387
  });
4249
4388
  server.registerTool("verify", {
4250
4389
  title: "Verify Checksums",
4251
- description: "Check if held checksums are still valid without rereading. Returns canonical status, next_action, and suggested_read_call when rereading specific ranges is the right recovery.",
4390
+ description: "Check if held checksums are still valid without rereading. Use before delayed or mixed-tool follow-up edits; returns canonical status, next_action, and reread guidance.",
4252
4391
  inputSchema: z2.object({
4253
4392
  path: z2.string().describe("File path"),
4254
4393
  checksums: z2.array(z2.string()).describe('Checksum strings, e.g. ["1-50:f7e2a1b0", "51-100:abcd1234"]'),
package/output-style.md CHANGED
@@ -27,9 +27,9 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
27
27
  | Path | Flow |
28
28
  |------|------|
29
29
  | Surgical | `grep_search -> edit_file` |
30
- | Exploratory | `outline -> read_file (ranges) -> edit_file` |
30
+ | Exploratory | `outline -> read_file (ranges) -> edit_file(base_revision)` |
31
31
  | Multi-file | `bulk_replace(path=<project root>)` |
32
- | Follow-up after delay | `verify -> reread only if STALE` |
32
+ | Follow-up after delay | `verify(base_revision) -> reread only if STALE -> retry with returned helpers` |
33
33
 
34
34
  ## Scope Discipline
35
35
 
@@ -42,10 +42,14 @@ Prefer `hex-line` for text files you may inspect or modify. Hash-annotated reads
42
42
 
43
43
  - Never invent `range_checksum`. Copy it from a fresh `read_file` or `grep_search(output:"content")` block.
44
44
  - First mutation in a file: use `grep_search` for narrow targets, or `outline -> read_file(ranges)` for structural edits.
45
+ - Preserve file conventions mentally: `hex-line` hashes normalized logical text, but `edit_file` preserves the file's existing line endings and trailing-newline shape on write.
45
46
  - Prefer `set_line` or `insert_after` for small local changes. Prefer `replace_between` for larger bounded block rewrites.
46
47
  - Use `replace_lines` only when you already hold the exact inclusive range checksum for that block.
47
- - Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned `revision`.
48
+ - Avoid large first-pass edit batches. Start with 1-2 hunks, then continue from the returned `revision` as `base_revision`.
49
+ - Before a delayed follow-up edit, a formatter pass, or any mixed-tool workflow on the same file, run `verify` with the last checksums and `base_revision`.
48
50
  - If `edit_file` returns `retry_edit`, `retry_edits`, or `retry_plan`, reuse those directly instead of rebuilding anchors/checksums by hand.
51
+ - Reuse `retry_checksum` when it is returned for the exact same target range.
52
+ - Once `hex-line` owns a file edit session, avoid mixing built-in `Edit`/`Write` on that file unless you intentionally want a new baseline.
49
53
  - Follow `next_action` first. Treat `summary` and `snippet` as the compact local context, not as prose to reinterpret.
50
54
 
51
55
  ## Exceptions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levnikolaevich/hex-line-mcp",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "mcpName": "io.github.levnikolaevich/hex-line-mcp",
5
5
  "type": "module",
6
6
  "description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 9 tools: inspect_path, read, edit, write, grep, outline, verify, changes, bulk_replace.",
@@ -63,7 +63,7 @@
63
63
  "llm"
64
64
  ],
65
65
  "engines": {
66
- "node": ">=18.0.0"
66
+ "node": ">=20.19.0"
67
67
  },
68
68
  "repository": {
69
69
  "type": "git",