@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 +11 -3
- package/dist/hook.mjs +2 -2
- package/dist/server.mjs +184 -45
- package/output-style.md +7 -3
- package/package.json +2 -2
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
|
|
|
@@ -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
|
|
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 (
|
|
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 <
|
|
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 <
|
|
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,
|
|
798
|
-
const lines =
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
|
843
|
-
return rememberSnapshot(filePath,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2342
|
-
if (
|
|
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,
|
|
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 =
|
|
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 (${
|
|
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} (${
|
|
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({
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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": ">=
|
|
66
|
+
"node": ">=20.19.0"
|
|
67
67
|
},
|
|
68
68
|
"repository": {
|
|
69
69
|
"type": "git",
|