@levnikolaevich/hex-line-mcp 1.12.1 → 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 +9 -3
- package/dist/hook.mjs +2 -2
- package/dist/server.mjs +180 -43
- package/output-style.md +7 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Hash-verified file editing MCP + token efficiency hook for AI coding agents.
|
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|

|
|
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
|
|
|
@@ -126,7 +126,8 @@ If a project already has `.hex-skills/codegraph/index.db`, `hex-line` automatica
|
|
|
126
126
|
|
|
127
127
|
1. Carry `revision` from the earlier `read_file` or `edit_file`
|
|
128
128
|
2. Pass it back as `base_revision`
|
|
129
|
-
3. Use `verify` before
|
|
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
|
|
130
131
|
|
|
131
132
|
### Rewrite a long block
|
|
132
133
|
|
|
@@ -156,9 +157,13 @@ File: lib/search.mjs
|
|
|
156
157
|
meta: 282 lines, 10.2KB, 2 hours ago
|
|
157
158
|
revision: rev-12-a1b2c3d4
|
|
158
159
|
file: 1-282:beefcafe
|
|
160
|
+
eol: lf
|
|
161
|
+
trailing_newline: true
|
|
159
162
|
|
|
160
163
|
block: read_range
|
|
161
164
|
span: 1-3
|
|
165
|
+
eol: lf
|
|
166
|
+
trailing_newline: true
|
|
162
167
|
ab.1 import { resolve } from "node:path";
|
|
163
168
|
cd.2 import { readFileSync } from "node:fs";
|
|
164
169
|
ef.3 ...
|
|
@@ -193,7 +198,8 @@ Discipline:
|
|
|
193
198
|
|
|
194
199
|
- Never invent `range_checksum`. Copy it from `read_file` or `grep_search(output:"content")`.
|
|
195
200
|
- First mutation in a file: prefer `grep_search` for narrow targets, or `outline -> read_file(ranges)` for structural edits.
|
|
196
|
-
- 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`.
|
|
197
203
|
|
|
198
204
|
Result footer includes:
|
|
199
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 (
|
|
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
|
@@ -669,6 +669,70 @@ function parseChecksum(cs) {
|
|
|
669
669
|
return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
|
|
670
670
|
}
|
|
671
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
|
+
|
|
672
736
|
// lib/snapshot.mjs
|
|
673
737
|
var MAX_FILES = 200;
|
|
674
738
|
var MAX_REVISIONS_PER_FILE = 5;
|
|
@@ -705,6 +769,10 @@ function pruneExpired(now = Date.now()) {
|
|
|
705
769
|
}
|
|
706
770
|
function rememberRevisionId(filePath, revision) {
|
|
707
771
|
const ids = fileRevisionIds.get(filePath) || [];
|
|
772
|
+
if (ids.includes(revision)) {
|
|
773
|
+
fileRevisionIds.set(filePath, ids);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
708
776
|
ids.push(revision);
|
|
709
777
|
while (ids.length > MAX_REVISIONS_PER_FILE) {
|
|
710
778
|
const removed = ids.shift();
|
|
@@ -796,36 +864,42 @@ function describeChangedRanges(ranges) {
|
|
|
796
864
|
if (!ranges?.length) return "none";
|
|
797
865
|
return ranges.map((r) => `${r.start}-${r.end}${r.kind ? `(${r.kind})` : ""}`).join(", ");
|
|
798
866
|
}
|
|
799
|
-
function createSnapshot(filePath,
|
|
800
|
-
const lines =
|
|
867
|
+
function createSnapshot(filePath, parsed, mtimeMs, size, prevSnapshot = null, revisionOverride = null) {
|
|
868
|
+
const { content, lines, lineEndings, rawText, eol, defaultEol, trailingNewline } = parsed;
|
|
801
869
|
const lineHashes = lines.map((line) => fnv1a(line));
|
|
802
870
|
const fileChecksum = computeFileChecksum(lineHashes);
|
|
803
|
-
const revision = `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
|
|
871
|
+
const revision = revisionOverride || `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
|
|
804
872
|
return {
|
|
805
873
|
revision,
|
|
806
874
|
path: filePath,
|
|
807
875
|
content,
|
|
876
|
+
rawText,
|
|
808
877
|
lines,
|
|
878
|
+
lineEndings,
|
|
809
879
|
lineHashes,
|
|
810
880
|
fileChecksum,
|
|
811
881
|
uniqueTagIndex: buildUniqueTagIndex(lineHashes),
|
|
812
882
|
changedRangesFromPrev: prevSnapshot ? computeChangedRanges(prevSnapshot.lines, lines) : [],
|
|
813
883
|
prevRevision: prevSnapshot?.revision || null,
|
|
884
|
+
eol,
|
|
885
|
+
defaultEol,
|
|
886
|
+
trailingNewline,
|
|
814
887
|
mtimeMs,
|
|
815
888
|
size,
|
|
816
889
|
createdAt: Date.now()
|
|
817
890
|
};
|
|
818
891
|
}
|
|
819
|
-
function rememberSnapshot(filePath,
|
|
892
|
+
function rememberSnapshot(filePath, input, meta = {}) {
|
|
820
893
|
pruneExpired();
|
|
821
894
|
const latest = latestByFile.get(filePath);
|
|
895
|
+
const parsed = typeof input === "string" ? parseUtf8TextWithMetadata(input) : input;
|
|
822
896
|
const mtimeMs = meta.mtimeMs ?? latest?.mtimeMs ?? Date.now();
|
|
823
|
-
const size = meta.size ?? Buffer.byteLength(
|
|
824
|
-
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) {
|
|
825
899
|
touchFile(filePath);
|
|
826
900
|
return latest;
|
|
827
901
|
}
|
|
828
|
-
const snapshot = createSnapshot(filePath,
|
|
902
|
+
const snapshot = latest && latest.content === parsed.content ? createSnapshot(filePath, parsed, mtimeMs, size, latest || null, latest.revision) : createSnapshot(filePath, parsed, mtimeMs, size, latest || null);
|
|
829
903
|
latestByFile.set(filePath, snapshot);
|
|
830
904
|
revisionsById.set(snapshot.revision, snapshot);
|
|
831
905
|
rememberRevisionId(filePath, snapshot.revision);
|
|
@@ -841,8 +915,8 @@ function readSnapshot(filePath) {
|
|
|
841
915
|
touchFile(filePath);
|
|
842
916
|
return latest;
|
|
843
917
|
}
|
|
844
|
-
const
|
|
845
|
-
return rememberSnapshot(filePath,
|
|
918
|
+
const parsed = readUtf8WithMetadata(filePath);
|
|
919
|
+
return rememberSnapshot(filePath, parsed, { mtimeMs: stat.mtimeMs, size: stat.size });
|
|
846
920
|
}
|
|
847
921
|
function getSnapshotByRevision(revision) {
|
|
848
922
|
pruneExpired();
|
|
@@ -874,6 +948,9 @@ function renderRequestedSpan(block) {
|
|
|
874
948
|
if (block.requestedStartLine === block.startLine && block.requestedEndLine === block.endLine) return null;
|
|
875
949
|
return `requested_span: ${block.requestedStartLine}-${block.requestedEndLine}`;
|
|
876
950
|
}
|
|
951
|
+
function renderMetaLines(meta = {}) {
|
|
952
|
+
return Object.entries(meta).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => `${key}: ${value}`);
|
|
953
|
+
}
|
|
877
954
|
function renderBaseEntry(entry, plain = false) {
|
|
878
955
|
return plain ? `${entry.lineNumber}|${entry.text}` : `${entry.tag}.${entry.lineNumber} ${entry.text}`;
|
|
879
956
|
}
|
|
@@ -950,6 +1027,7 @@ function serializeReadBlock(block, opts = {}) {
|
|
|
950
1027
|
];
|
|
951
1028
|
const requestedSpan = renderRequestedSpan(block);
|
|
952
1029
|
if (requestedSpan) lines.push(requestedSpan);
|
|
1030
|
+
lines.push(...renderMetaLines(block.meta));
|
|
953
1031
|
lines.push(...block.entries.map((entry) => serializeReadEntry(entry, opts)));
|
|
954
1032
|
lines.push(`checksum: ${block.checksum}`);
|
|
955
1033
|
return lines.join("\n");
|
|
@@ -967,6 +1045,9 @@ function serializeSearchBlock(block, opts = {}) {
|
|
|
967
1045
|
lines.push(`match_lines: ${block.meta.matchLines.join(",")}`);
|
|
968
1046
|
}
|
|
969
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
|
+
)));
|
|
970
1051
|
lines.push(...block.entries.map((entry) => serializeSearchEntry(entry, opts)));
|
|
971
1052
|
lines.push(`checksum: ${block.checksum}`);
|
|
972
1053
|
return lines.join("\n");
|
|
@@ -1060,7 +1141,11 @@ function buildReadBlock(snapshot, range, plain, remainingChars) {
|
|
|
1060
1141
|
kind: "read_range",
|
|
1061
1142
|
entries,
|
|
1062
1143
|
requestedStartLine: range.requestedStartLine,
|
|
1063
|
-
requestedEndLine: range.requestedEndLine
|
|
1144
|
+
requestedEndLine: range.requestedEndLine,
|
|
1145
|
+
meta: {
|
|
1146
|
+
eol: snapshot.eol,
|
|
1147
|
+
trailing_newline: snapshot.trailingNewline
|
|
1148
|
+
}
|
|
1064
1149
|
}),
|
|
1065
1150
|
remainingChars: nextBudget,
|
|
1066
1151
|
cappedAtLine
|
|
@@ -1166,6 +1251,8 @@ Graph: ${items.join(" | ")}`;
|
|
|
1166
1251
|
meta: ${meta}
|
|
1167
1252
|
revision: ${snapshot.revision}
|
|
1168
1253
|
file: ${snapshot.fileChecksum}
|
|
1254
|
+
eol: ${snapshot.eol}
|
|
1255
|
+
trailing_newline: ${snapshot.trailingNewline}
|
|
1169
1256
|
|
|
1170
1257
|
${serializedBlocks.join("\n\n")}`.trim();
|
|
1171
1258
|
}
|
|
@@ -1337,6 +1424,41 @@ function sanitizeEditText(text) {
|
|
|
1337
1424
|
if (hadTrailingNewline && !cleaned.endsWith("\n")) cleaned += "\n";
|
|
1338
1425
|
return cleaned;
|
|
1339
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
|
+
}
|
|
1340
1462
|
function findLine(lines, lineNum, expectedTag, hashIndex) {
|
|
1341
1463
|
const idx = lineNum - 1;
|
|
1342
1464
|
if (idx < 0 || idx >= lines.length) {
|
|
@@ -1994,7 +2116,7 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
|
|
|
1994
2116
|
return conflicts;
|
|
1995
2117
|
}
|
|
1996
2118
|
function applySetLineEdit(edit, ctx) {
|
|
1997
|
-
const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
|
|
2119
|
+
const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
|
|
1998
2120
|
const { tag, line } = parseRef(edit.set_line.anchor);
|
|
1999
2121
|
const idx = locateOrConflict({ tag, line }, "stale_anchor", () => buildRetryEdit(edit, lines));
|
|
2000
2122
|
if (typeof idx === "string") return idx;
|
|
@@ -2004,17 +2126,17 @@ function applySetLineEdit(edit, ctx) {
|
|
|
2004
2126
|
if (conflict) return conflict;
|
|
2005
2127
|
const txt = edit.set_line.new_text;
|
|
2006
2128
|
if (!txt && txt !== 0) {
|
|
2007
|
-
lines
|
|
2129
|
+
replaceLogicalRange(lines, lineEndings, idx, idx, [], defaultEol);
|
|
2008
2130
|
return null;
|
|
2009
2131
|
}
|
|
2010
2132
|
const origLine = [lines[idx]];
|
|
2011
2133
|
const raw = sanitizeEditText(txt).split("\n");
|
|
2012
2134
|
const newLines = opts.restoreIndent ? restoreIndent(origLine, raw) : raw;
|
|
2013
|
-
lines
|
|
2135
|
+
replaceLogicalRange(lines, lineEndings, idx, idx, newLines, defaultEol);
|
|
2014
2136
|
return null;
|
|
2015
2137
|
}
|
|
2016
2138
|
function applyInsertAfterEdit(edit, ctx) {
|
|
2017
|
-
const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
|
|
2139
|
+
const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
|
|
2018
2140
|
const { tag, line } = parseRef(edit.insert_after.anchor);
|
|
2019
2141
|
const idx = locateOrConflict({ tag, line }, "stale_anchor", () => buildRetryEdit(edit, lines));
|
|
2020
2142
|
if (typeof idx === "string") return idx;
|
|
@@ -2024,7 +2146,7 @@ function applyInsertAfterEdit(edit, ctx) {
|
|
|
2024
2146
|
if (conflict) return conflict;
|
|
2025
2147
|
let insertLines = sanitizeEditText(edit.insert_after.text).split("\n");
|
|
2026
2148
|
if (opts.restoreIndent) insertLines = restoreIndent([lines[idx]], insertLines);
|
|
2027
|
-
lines
|
|
2149
|
+
insertLogicalLinesAfter(lines, lineEndings, idx, insertLines, defaultEol);
|
|
2028
2150
|
return null;
|
|
2029
2151
|
}
|
|
2030
2152
|
function applyReplaceLinesEdit(edit, ctx) {
|
|
@@ -2035,6 +2157,8 @@ function applyReplaceLinesEdit(edit, ctx) {
|
|
|
2035
2157
|
currentSnapshot,
|
|
2036
2158
|
ensureRevisionContext,
|
|
2037
2159
|
hasBaseSnapshot,
|
|
2160
|
+
lineEndings,
|
|
2161
|
+
defaultEol,
|
|
2038
2162
|
lines,
|
|
2039
2163
|
locateOrConflict,
|
|
2040
2164
|
opts,
|
|
@@ -2124,17 +2248,17 @@ Recovery: read_file path ranges=["${csStart}-${csEnd}"], then retry edit with fr
|
|
|
2124
2248
|
}
|
|
2125
2249
|
const txt = edit.replace_lines.new_text;
|
|
2126
2250
|
if (!txt && txt !== 0) {
|
|
2127
|
-
lines
|
|
2251
|
+
replaceLogicalRange(lines, lineEndings, startIdx, endIdx, [], defaultEol);
|
|
2128
2252
|
return null;
|
|
2129
2253
|
}
|
|
2130
2254
|
const origRange = lines.slice(startIdx, endIdx + 1);
|
|
2131
2255
|
let newLines = sanitizeEditText(txt).split("\n");
|
|
2132
2256
|
if (opts.restoreIndent) newLines = restoreIndent(origRange, newLines);
|
|
2133
|
-
lines
|
|
2257
|
+
replaceLogicalRange(lines, lineEndings, startIdx, endIdx, newLines, defaultEol);
|
|
2134
2258
|
return null;
|
|
2135
2259
|
}
|
|
2136
2260
|
function applyReplaceBetweenEdit(edit, ctx) {
|
|
2137
|
-
const { lines, opts, locateOrConflict, ensureRevisionContext } = ctx;
|
|
2261
|
+
const { lines, lineEndings, defaultEol, opts, locateOrConflict, ensureRevisionContext } = ctx;
|
|
2138
2262
|
const boundaryMode = edit.replace_between.boundary_mode || "inclusive";
|
|
2139
2263
|
if (boundaryMode !== "inclusive" && boundaryMode !== "exclusive") {
|
|
2140
2264
|
throw new Error(`BAD_INPUT: replace_between boundary_mode must be inclusive or exclusive, got ${boundaryMode}`);
|
|
@@ -2163,7 +2287,11 @@ function applyReplaceBetweenEdit(edit, ctx) {
|
|
|
2163
2287
|
const origRange = lines.slice(sliceStart, sliceStart + removeCount);
|
|
2164
2288
|
if (opts.restoreIndent && origRange.length > 0) newLines = restoreIndent(origRange, newLines);
|
|
2165
2289
|
if (txt === "" || txt === null) newLines = [];
|
|
2166
|
-
|
|
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);
|
|
2167
2295
|
return null;
|
|
2168
2296
|
}
|
|
2169
2297
|
function editFile(filePath, edits, opts = {}) {
|
|
@@ -2175,11 +2303,12 @@ function editFile(filePath, edits, opts = {}) {
|
|
|
2175
2303
|
const staleRevision = !!opts.baseRevision && opts.baseRevision !== currentSnapshot.revision && hasBaseSnapshot;
|
|
2176
2304
|
const changedRanges = staleRevision && hasBaseSnapshot ? computeChangedRanges(baseSnapshot.lines, currentSnapshot.lines) : [];
|
|
2177
2305
|
const conflictPolicy = opts.conflictPolicy || "conservative";
|
|
2178
|
-
const
|
|
2306
|
+
const originalRaw = currentSnapshot.rawText;
|
|
2179
2307
|
const lines = [...currentSnapshot.lines];
|
|
2308
|
+
const lineEndings = [...currentSnapshot.lineEndings];
|
|
2180
2309
|
const origLines = [...currentSnapshot.lines];
|
|
2181
|
-
const hadTrailingNewline = original.endsWith("\n");
|
|
2182
2310
|
const hashIndex = currentSnapshot.uniqueTagIndex;
|
|
2311
|
+
const defaultEol = currentSnapshot.defaultEol || "\n";
|
|
2183
2312
|
let autoRebased = false;
|
|
2184
2313
|
const remaps = [];
|
|
2185
2314
|
const remapKeys = /* @__PURE__ */ new Set();
|
|
@@ -2300,6 +2429,8 @@ ${snip.text}`;
|
|
|
2300
2429
|
ensureRevisionContext,
|
|
2301
2430
|
hasBaseSnapshot,
|
|
2302
2431
|
lines,
|
|
2432
|
+
lineEndings,
|
|
2433
|
+
defaultEol,
|
|
2303
2434
|
locateOrConflict,
|
|
2304
2435
|
opts,
|
|
2305
2436
|
origLines,
|
|
@@ -2340,19 +2471,17 @@ ${snip.text}`;
|
|
|
2340
2471
|
throw editErr;
|
|
2341
2472
|
}
|
|
2342
2473
|
}
|
|
2343
|
-
|
|
2344
|
-
if (
|
|
2345
|
-
if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
|
|
2346
|
-
if (original === content) {
|
|
2474
|
+
const content = composeRawText(lines, lineEndings);
|
|
2475
|
+
if (originalRaw === content) {
|
|
2347
2476
|
throw new Error("NOOP_EDIT: File already contains the desired content. No changes needed.");
|
|
2348
2477
|
}
|
|
2349
|
-
const fullDiff = simpleDiff(origLines,
|
|
2478
|
+
const fullDiff = simpleDiff(origLines, lines);
|
|
2350
2479
|
let displayDiff = fullDiff;
|
|
2351
2480
|
if (displayDiff && displayDiff.length > MAX_DIFF_CHARS) {
|
|
2352
2481
|
displayDiff = displayDiff.slice(0, MAX_DIFF_CHARS) + `
|
|
2353
2482
|
... (diff truncated, ${displayDiff.length} chars total)`;
|
|
2354
2483
|
}
|
|
2355
|
-
const newLinesAll =
|
|
2484
|
+
const newLinesAll = lines;
|
|
2356
2485
|
let minLine = Infinity, maxLine = 0;
|
|
2357
2486
|
if (fullDiff) {
|
|
2358
2487
|
for (const dl of fullDiff.split("\n")) {
|
|
@@ -2369,7 +2498,7 @@ ${snip.text}`;
|
|
|
2369
2498
|
reason: ${REASON.DRY_RUN_PREVIEW}
|
|
2370
2499
|
revision: ${currentSnapshot.revision}
|
|
2371
2500
|
file: ${currentSnapshot.fileChecksum}
|
|
2372
|
-
Dry run: ${filePath} would change (${
|
|
2501
|
+
Dry run: ${filePath} would change (${lines.length} lines)`;
|
|
2373
2502
|
if (staleRevision && hasBaseSnapshot) msg2 += `
|
|
2374
2503
|
changed_ranges: ${describeChangedRanges(changedRanges)}`;
|
|
2375
2504
|
if (displayDiff) msg2 += `
|
|
@@ -2397,13 +2526,21 @@ remapped_refs:
|
|
|
2397
2526
|
${remaps.map(({ from, to }) => `${from} -> ${to}`).join("\n")}`;
|
|
2398
2527
|
}
|
|
2399
2528
|
msg += `
|
|
2400
|
-
Updated ${filePath} (${
|
|
2529
|
+
Updated ${filePath} (${lines.length} lines)`;
|
|
2401
2530
|
if (fullDiff && minLine <= maxLine) {
|
|
2402
2531
|
const ctxStart = Math.max(0, minLine - 6) + 1;
|
|
2403
2532
|
const ctxEnd = Math.min(newLinesAll.length, maxLine + 5);
|
|
2404
2533
|
const entries = createSnapshotEntries(nextSnapshot, ctxStart, ctxEnd);
|
|
2405
2534
|
if (entries.length > 0) {
|
|
2406
|
-
const block = buildEditReadyBlock({
|
|
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
|
+
});
|
|
2407
2544
|
msg += `
|
|
2408
2545
|
|
|
2409
2546
|
${serializeReadBlock(block)}`;
|
|
@@ -2728,7 +2865,7 @@ function isSupportedExtension(ext) {
|
|
|
2728
2865
|
}
|
|
2729
2866
|
|
|
2730
2867
|
// ../hex-common/src/parser/tree-sitter.mjs
|
|
2731
|
-
import { existsSync as existsSync4, readFileSync as
|
|
2868
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "node:fs";
|
|
2732
2869
|
import { dirname as dirname3, resolve as resolve3 } from "node:path";
|
|
2733
2870
|
import { fileURLToPath } from "node:url";
|
|
2734
2871
|
var parserInstance = null;
|
|
@@ -2755,7 +2892,7 @@ function loadArtifactManifest() {
|
|
|
2755
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.`
|
|
2756
2893
|
);
|
|
2757
2894
|
}
|
|
2758
|
-
artifactManifest = JSON.parse(
|
|
2895
|
+
artifactManifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
|
|
2759
2896
|
return artifactManifest;
|
|
2760
2897
|
}
|
|
2761
2898
|
function treeSitterArtifactPath(grammar) {
|
|
@@ -3117,7 +3254,7 @@ import { statSync as statSync8 } from "node:fs";
|
|
|
3117
3254
|
import { resolve as resolve6 } from "node:path";
|
|
3118
3255
|
|
|
3119
3256
|
// lib/tree.mjs
|
|
3120
|
-
import { readdirSync as readdirSync2, readFileSync as
|
|
3257
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync6, existsSync as existsSync5 } from "node:fs";
|
|
3121
3258
|
import { resolve as resolve4, basename, join as join4, relative as relative2 } from "node:path";
|
|
3122
3259
|
import ignore from "ignore";
|
|
3123
3260
|
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
@@ -3138,7 +3275,7 @@ function loadGitignore(rootDir) {
|
|
|
3138
3275
|
const gi = join4(rootDir, ".gitignore");
|
|
3139
3276
|
if (!existsSync5(gi)) return null;
|
|
3140
3277
|
try {
|
|
3141
|
-
const content =
|
|
3278
|
+
const content = readFileSync4(gi, "utf-8");
|
|
3142
3279
|
return ignore().add(content);
|
|
3143
3280
|
} catch {
|
|
3144
3281
|
return null;
|
|
@@ -3393,7 +3530,7 @@ function inspectPath(inputPath, opts = {}) {
|
|
|
3393
3530
|
}
|
|
3394
3531
|
|
|
3395
3532
|
// lib/setup.mjs
|
|
3396
|
-
import { readFileSync as
|
|
3533
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync, copyFileSync } from "node:fs";
|
|
3397
3534
|
import { resolve as resolve7, dirname as dirname4, join as join5 } from "node:path";
|
|
3398
3535
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3399
3536
|
import { homedir } from "node:os";
|
|
@@ -3424,7 +3561,7 @@ var CLAUDE_HOOKS = {
|
|
|
3424
3561
|
};
|
|
3425
3562
|
function readJson(filePath) {
|
|
3426
3563
|
if (!existsSync6(filePath)) return null;
|
|
3427
|
-
return JSON.parse(
|
|
3564
|
+
return JSON.parse(readFileSync5(filePath, "utf-8"));
|
|
3428
3565
|
}
|
|
3429
3566
|
function writeJson(filePath, data) {
|
|
3430
3567
|
mkdirSync(dirname4(filePath), { recursive: true });
|
|
@@ -3439,7 +3576,7 @@ function findEntryByCommand(entries) {
|
|
|
3439
3576
|
}
|
|
3440
3577
|
function safeRead(filePath) {
|
|
3441
3578
|
try {
|
|
3442
|
-
return
|
|
3579
|
+
return readFileSync5(filePath, "utf-8");
|
|
3443
3580
|
} catch {
|
|
3444
3581
|
return null;
|
|
3445
3582
|
}
|
|
@@ -3530,7 +3667,7 @@ import { join as join6 } from "node:path";
|
|
|
3530
3667
|
|
|
3531
3668
|
// ../hex-common/src/git/semantic-diff.mjs
|
|
3532
3669
|
import { execFileSync } from "node:child_process";
|
|
3533
|
-
import { existsSync as existsSync7, readFileSync as
|
|
3670
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, statSync as statSync9 } from "node:fs";
|
|
3534
3671
|
import { dirname as dirname5, extname as extname3, relative as relative3, resolve as resolve8 } from "node:path";
|
|
3535
3672
|
function normalizePath2(value) {
|
|
3536
3673
|
return value.replace(/\\/g, "/");
|
|
@@ -3710,7 +3847,7 @@ function compareSymbols(beforeEntries = [], afterEntries = []) {
|
|
|
3710
3847
|
function readWorkingTreeFile(repoRoot, relPath) {
|
|
3711
3848
|
const absPath = resolve8(repoRoot, relPath);
|
|
3712
3849
|
if (!existsSync7(absPath)) return null;
|
|
3713
|
-
return
|
|
3850
|
+
return readFileSync6(absPath, "utf8").replace(/\r\n/g, "\n");
|
|
3714
3851
|
}
|
|
3715
3852
|
function readGitFile(repoRoot, ref, relPath) {
|
|
3716
3853
|
if (!ref) return null;
|
|
@@ -4055,7 +4192,7 @@ OUTPUT_CAPPED: Output exceeded ${MAX_BULK_OUTPUT_CHARS} chars.`;
|
|
|
4055
4192
|
}
|
|
4056
4193
|
|
|
4057
4194
|
// server.mjs
|
|
4058
|
-
var version = true ? "1.
|
|
4195
|
+
var version = true ? "1.13.0" : (await null).createRequire(import.meta.url)("./package.json").version;
|
|
4059
4196
|
var { server, StdioServerTransport } = await createServerRuntime({
|
|
4060
4197
|
name: "hex-line-mcp",
|
|
4061
4198
|
version
|
|
@@ -4080,7 +4217,7 @@ function parseReadRanges(rawRanges) {
|
|
|
4080
4217
|
}
|
|
4081
4218
|
server.registerTool("read_file", {
|
|
4082
4219
|
title: "Read File",
|
|
4083
|
-
description: "Read file with hash-annotated lines, checksums, revision metadata, and
|
|
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.",
|
|
4084
4221
|
inputSchema: z2.object({
|
|
4085
4222
|
path: z2.string().optional().describe("File path"),
|
|
4086
4223
|
paths: z2.array(z2.string()).optional().describe("Array of file paths to read (batch mode)"),
|
|
@@ -4115,7 +4252,7 @@ ERROR: ${e.message}`);
|
|
|
4115
4252
|
});
|
|
4116
4253
|
server.registerTool("edit_file", {
|
|
4117
4254
|
title: "Edit File",
|
|
4118
|
-
description: "Apply hash-verified partial edits to one file.
|
|
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.",
|
|
4119
4256
|
inputSchema: z2.object({
|
|
4120
4257
|
path: z2.string().describe("File to edit"),
|
|
4121
4258
|
edits: z2.union([z2.string(), z2.array(z2.any())]).describe(
|
|
@@ -4250,7 +4387,7 @@ server.registerTool("outline", {
|
|
|
4250
4387
|
});
|
|
4251
4388
|
server.registerTool("verify", {
|
|
4252
4389
|
title: "Verify Checksums",
|
|
4253
|
-
description: "Check if held checksums are still valid without rereading.
|
|
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.",
|
|
4254
4391
|
inputSchema: z2.object({
|
|
4255
4392
|
path: z2.string().describe("File path"),
|
|
4256
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.
|
|
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.",
|