@levnikolaevich/hex-line-mcp 1.3.1 → 1.3.3
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 +120 -47
- package/benchmark/atomic.mjs +502 -0
- package/benchmark/graph.mjs +80 -0
- package/benchmark/index.mjs +144 -0
- package/benchmark/workflows.mjs +350 -0
- package/hook.mjs +48 -15
- package/lib/benchmark-helpers.mjs +1 -1
- package/lib/changes.mjs +2 -1
- package/lib/coerce.mjs +1 -42
- package/lib/edit.mjs +258 -248
- package/lib/graph-enrich.mjs +76 -58
- package/lib/hash.mjs +1 -109
- package/lib/info.mjs +1 -1
- package/lib/normalize.mjs +1 -106
- package/lib/outline.mjs +32 -87
- package/lib/read.mjs +8 -5
- package/lib/revisions.mjs +238 -0
- package/lib/search.mjs +6 -7
- package/lib/security.mjs +4 -4
- package/lib/setup.mjs +7 -20
- package/lib/update-check.mjs +1 -56
- package/lib/verify.mjs +32 -16
- package/output-style.md +21 -6
- package/package.json +18 -6
- package/server.mjs +35 -43
- package/benchmark.mjs +0 -1106
package/lib/read.mjs
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { statSync } from "node:fs";
|
|
9
|
-
import { fnv1a, lineTag, rangeChecksum } from "
|
|
10
|
-
import { validatePath } from "./security.mjs";
|
|
9
|
+
import { fnv1a, lineTag, rangeChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
|
|
10
|
+
import { validatePath, normalizePath } from "./security.mjs";
|
|
11
11
|
import { getGraphDB, fileAnnotations, getRelativePath } from "./graph-enrich.mjs";
|
|
12
12
|
import { relativeTime, listDirectory, readText, MAX_OUTPUT_CHARS } from "./format.mjs";
|
|
13
|
+
import { rememberSnapshot } from "./revisions.mjs";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_LIMIT = 2000;
|
|
15
16
|
|
|
@@ -21,6 +22,7 @@ const DEFAULT_LIMIT = 2000;
|
|
|
21
22
|
* @returns {string} formatted output
|
|
22
23
|
*/
|
|
23
24
|
export function readFile(filePath, opts = {}) {
|
|
25
|
+
filePath = normalizePath(filePath);
|
|
24
26
|
const real = validatePath(filePath);
|
|
25
27
|
const stat = statSync(real);
|
|
26
28
|
|
|
@@ -30,8 +32,8 @@ export function readFile(filePath, opts = {}) {
|
|
|
30
32
|
return `Directory: ${filePath}\n\n\`\`\`\n${text}\n\`\`\``;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
const
|
|
34
|
-
const lines =
|
|
35
|
+
const snapshot = rememberSnapshot(real, readText(real), { mtimeMs: stat.mtimeMs, size: stat.size });
|
|
36
|
+
const lines = snapshot.lines;
|
|
35
37
|
const total = lines.length;
|
|
36
38
|
|
|
37
39
|
// Determine ranges to read
|
|
@@ -119,7 +121,8 @@ export function readFile(filePath, opts = {}) {
|
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
let result =
|
|
124
|
+
let result =
|
|
125
|
+
`${header}${graphLine}\nrevision: ${snapshot.revision}\nfile: ${snapshot.fileChecksum}\n\n\`\`\`\n${parts.join("\n")}\n\`\`\``;
|
|
123
126
|
|
|
124
127
|
// Auto-hint for large files read from start without offset
|
|
125
128
|
if (total > 200 && (!opts.offset || opts.offset <= 1) && !cappedAtLine) {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision journal for hex-line-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Keeps short-lived file snapshots in memory so read/edit/verify can reuse
|
|
5
|
+
* line hashes, file checksums, and changed ranges without re-reading the file
|
|
6
|
+
* on every same-file operation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { diffLines } from "diff";
|
|
11
|
+
import { fnv1a, lineTag, rangeChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
|
|
12
|
+
import { readText } from "./format.mjs";
|
|
13
|
+
|
|
14
|
+
const MAX_FILES = 200;
|
|
15
|
+
const MAX_REVISIONS_PER_FILE = 5;
|
|
16
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
const latestByFile = new Map();
|
|
19
|
+
const revisionsById = new Map();
|
|
20
|
+
const fileRevisionIds = new Map();
|
|
21
|
+
let revisionSeq = 0;
|
|
22
|
+
|
|
23
|
+
function touchFile(filePath) {
|
|
24
|
+
const latest = latestByFile.get(filePath);
|
|
25
|
+
if (!latest) return;
|
|
26
|
+
latestByFile.delete(filePath);
|
|
27
|
+
latestByFile.set(filePath, latest);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pruneExpired(now = Date.now()) {
|
|
31
|
+
for (const [revision, snapshot] of revisionsById) {
|
|
32
|
+
if (now - snapshot.createdAt <= TTL_MS) continue;
|
|
33
|
+
revisionsById.delete(revision);
|
|
34
|
+
const ids = fileRevisionIds.get(snapshot.path);
|
|
35
|
+
if (!ids) continue;
|
|
36
|
+
const next = ids.filter(id => id !== revision);
|
|
37
|
+
if (next.length > 0) fileRevisionIds.set(snapshot.path, next);
|
|
38
|
+
else fileRevisionIds.delete(snapshot.path);
|
|
39
|
+
const latest = latestByFile.get(snapshot.path);
|
|
40
|
+
if (latest?.revision === revision) latestByFile.delete(snapshot.path);
|
|
41
|
+
}
|
|
42
|
+
while (latestByFile.size > MAX_FILES) {
|
|
43
|
+
const oldestPath = latestByFile.keys().next().value;
|
|
44
|
+
const ids = fileRevisionIds.get(oldestPath) || [];
|
|
45
|
+
for (const id of ids) revisionsById.delete(id);
|
|
46
|
+
fileRevisionIds.delete(oldestPath);
|
|
47
|
+
latestByFile.delete(oldestPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rememberRevisionId(filePath, revision) {
|
|
52
|
+
const ids = fileRevisionIds.get(filePath) || [];
|
|
53
|
+
ids.push(revision);
|
|
54
|
+
while (ids.length > MAX_REVISIONS_PER_FILE) {
|
|
55
|
+
const removed = ids.shift();
|
|
56
|
+
revisionsById.delete(removed);
|
|
57
|
+
}
|
|
58
|
+
fileRevisionIds.set(filePath, ids);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildUniqueTagIndex(lineHashes) {
|
|
62
|
+
const index = new Map();
|
|
63
|
+
const duplicates = new Set();
|
|
64
|
+
for (let i = 0; i < lineHashes.length; i++) {
|
|
65
|
+
const tag = lineTag(lineHashes[i]);
|
|
66
|
+
if (duplicates.has(tag)) continue;
|
|
67
|
+
if (index.has(tag)) {
|
|
68
|
+
index.delete(tag);
|
|
69
|
+
duplicates.add(tag);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
index.set(tag, i);
|
|
73
|
+
}
|
|
74
|
+
return index;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function computeFileChecksum(lineHashes) {
|
|
78
|
+
return rangeChecksum(lineHashes, 1, lineHashes.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function diffLineCount(value) {
|
|
82
|
+
if (!value) return 0;
|
|
83
|
+
return value.split("\n").length - 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function coalesceRanges(ranges) {
|
|
87
|
+
if (!ranges.length) return [];
|
|
88
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
89
|
+
const merged = [sorted[0]];
|
|
90
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
91
|
+
const prev = merged[merged.length - 1];
|
|
92
|
+
const curr = sorted[i];
|
|
93
|
+
if (curr.start <= prev.end + 1) {
|
|
94
|
+
prev.end = Math.max(prev.end, curr.end);
|
|
95
|
+
prev.kind = prev.kind === curr.kind ? prev.kind : "mixed";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
merged.push({ ...curr });
|
|
99
|
+
}
|
|
100
|
+
return merged;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function computeChangedRanges(oldLines, newLines) {
|
|
104
|
+
const oldText = oldLines.join("\n") + "\n";
|
|
105
|
+
const newText = newLines.join("\n") + "\n";
|
|
106
|
+
const parts = diffLines(oldText, newText);
|
|
107
|
+
const ranges = [];
|
|
108
|
+
let oldNum = 1;
|
|
109
|
+
let newNum = 1;
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < parts.length; i++) {
|
|
112
|
+
const part = parts[i];
|
|
113
|
+
const count = diffLineCount(part.value);
|
|
114
|
+
|
|
115
|
+
if (part.removed) {
|
|
116
|
+
const next = parts[i + 1];
|
|
117
|
+
if (next?.added) {
|
|
118
|
+
const addedCount = diffLineCount(next.value);
|
|
119
|
+
ranges.push({
|
|
120
|
+
start: newNum,
|
|
121
|
+
end: addedCount > 0 ? newNum + addedCount - 1 : newNum,
|
|
122
|
+
kind: "replace",
|
|
123
|
+
});
|
|
124
|
+
oldNum += count;
|
|
125
|
+
newNum += addedCount;
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
ranges.push({ start: newNum, end: newNum, kind: "delete" });
|
|
130
|
+
oldNum += count;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (part.added) {
|
|
135
|
+
ranges.push({
|
|
136
|
+
start: newNum,
|
|
137
|
+
end: count > 0 ? newNum + count - 1 : newNum,
|
|
138
|
+
kind: "insert",
|
|
139
|
+
});
|
|
140
|
+
newNum += count;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
oldNum += count;
|
|
145
|
+
newNum += count;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return coalesceRanges(ranges);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function describeChangedRanges(ranges) {
|
|
152
|
+
if (!ranges?.length) return "none";
|
|
153
|
+
return ranges.map(r => `${r.start}-${r.end}${r.kind ? `(${r.kind})` : ""}`).join(", ");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createSnapshot(filePath, content, mtimeMs, size, prevSnapshot = null) {
|
|
157
|
+
const lines = content.split("\n");
|
|
158
|
+
const lineHashes = lines.map(line => fnv1a(line));
|
|
159
|
+
const fileChecksum = computeFileChecksum(lineHashes);
|
|
160
|
+
const revision = `rev-${++revisionSeq}-${fileChecksum.split(":")[1]}`;
|
|
161
|
+
return {
|
|
162
|
+
revision,
|
|
163
|
+
path: filePath,
|
|
164
|
+
content,
|
|
165
|
+
lines,
|
|
166
|
+
lineHashes,
|
|
167
|
+
fileChecksum,
|
|
168
|
+
uniqueTagIndex: buildUniqueTagIndex(lineHashes),
|
|
169
|
+
changedRangesFromPrev: prevSnapshot ? computeChangedRanges(prevSnapshot.lines, lines) : [],
|
|
170
|
+
prevRevision: prevSnapshot?.revision || null,
|
|
171
|
+
mtimeMs,
|
|
172
|
+
size,
|
|
173
|
+
createdAt: Date.now(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function rememberSnapshot(filePath, content, meta = {}) {
|
|
178
|
+
pruneExpired();
|
|
179
|
+
const latest = latestByFile.get(filePath);
|
|
180
|
+
const mtimeMs = meta.mtimeMs ?? latest?.mtimeMs ?? Date.now();
|
|
181
|
+
const size = meta.size ?? Buffer.byteLength(content, "utf8");
|
|
182
|
+
|
|
183
|
+
if (latest && latest.content === content && latest.mtimeMs === mtimeMs && latest.size === size) {
|
|
184
|
+
touchFile(filePath);
|
|
185
|
+
return latest;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const snapshot = createSnapshot(filePath, content, mtimeMs, size, latest || null);
|
|
189
|
+
latestByFile.set(filePath, snapshot);
|
|
190
|
+
revisionsById.set(snapshot.revision, snapshot);
|
|
191
|
+
rememberRevisionId(filePath, snapshot.revision);
|
|
192
|
+
touchFile(filePath);
|
|
193
|
+
pruneExpired();
|
|
194
|
+
return snapshot;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function readSnapshot(filePath) {
|
|
198
|
+
pruneExpired();
|
|
199
|
+
const stat = statSync(filePath);
|
|
200
|
+
const latest = latestByFile.get(filePath);
|
|
201
|
+
if (latest && latest.mtimeMs === stat.mtimeMs && latest.size === stat.size) {
|
|
202
|
+
touchFile(filePath);
|
|
203
|
+
return latest;
|
|
204
|
+
}
|
|
205
|
+
const content = readText(filePath);
|
|
206
|
+
return rememberSnapshot(filePath, content, { mtimeMs: stat.mtimeMs, size: stat.size });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getLatestSnapshot(filePath) {
|
|
210
|
+
pruneExpired();
|
|
211
|
+
const latest = latestByFile.get(filePath);
|
|
212
|
+
if (!latest) return null;
|
|
213
|
+
touchFile(filePath);
|
|
214
|
+
return latest;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getSnapshotByRevision(revision) {
|
|
218
|
+
pruneExpired();
|
|
219
|
+
return revisionsById.get(revision) || null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function overlapsChangedRanges(ranges, startLine, endLine) {
|
|
223
|
+
return (ranges || []).some((range) => range.start <= endLine && startLine <= range.end);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function buildRangeChecksum(snapshot, startLine, endLine) {
|
|
227
|
+
const startIdx = startLine - 1;
|
|
228
|
+
const endIdx = endLine - 1;
|
|
229
|
+
if (startIdx < 0 || endIdx >= snapshot.lineHashes.length || startIdx > endIdx) return null;
|
|
230
|
+
return rangeChecksum(snapshot.lineHashes.slice(startIdx, endIdx + 1), startLine, endLine);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function _resetRevisionCache() {
|
|
234
|
+
latestByFile.clear();
|
|
235
|
+
revisionsById.clear();
|
|
236
|
+
fileRevisionIds.clear();
|
|
237
|
+
revisionSeq = 0;
|
|
238
|
+
}
|
package/lib/search.mjs
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import { fnv1a, lineTag, rangeChecksum } from "
|
|
13
|
+
import { fnv1a, lineTag, rangeChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
|
|
14
14
|
import { getGraphDB, matchAnnotation, getRelativePath } from "./graph-enrich.mjs";
|
|
15
15
|
import { normalizePath } from "./security.mjs";
|
|
16
16
|
|
|
17
|
+
let rgBin = "rg";
|
|
18
|
+
try { rgBin = (await import("@vscode/ripgrep")).rgPath; } catch { /* system rg */ }
|
|
19
|
+
|
|
17
20
|
const DEFAULT_LIMIT = 100;
|
|
18
21
|
const MAX_OUTPUT = 10 * 1024 * 1024; // 10 MB
|
|
19
22
|
const TIMEOUT = 30000; // 30s
|
|
@@ -31,7 +34,7 @@ function spawnRg(args) {
|
|
|
31
34
|
let killed = false;
|
|
32
35
|
let stderrBuf = "";
|
|
33
36
|
|
|
34
|
-
const child = spawn(
|
|
37
|
+
const child = spawn(rgBin, args, { timeout: TIMEOUT });
|
|
35
38
|
|
|
36
39
|
child.stdout.on("data", (chunk) => {
|
|
37
40
|
totalBytes += chunk.length;
|
|
@@ -46,11 +49,7 @@ function spawnRg(args) {
|
|
|
46
49
|
child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString("utf-8"); });
|
|
47
50
|
|
|
48
51
|
child.on("error", (err) => {
|
|
49
|
-
|
|
50
|
-
reject(new Error("ripgrep (rg) not found. Install: https://github.com/BurntSushi/ripgrep#installation"));
|
|
51
|
-
} else {
|
|
52
|
-
reject(new Error(`rg spawn error: ${err.message}`));
|
|
53
|
-
}
|
|
52
|
+
reject(new Error(`rg spawn error: ${err.message}`));
|
|
54
53
|
});
|
|
55
54
|
|
|
56
55
|
child.on("close", (code) => {
|
package/lib/security.mjs
CHANGED
|
@@ -18,9 +18,9 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
|
18
18
|
*/
|
|
19
19
|
export function normalizePath(p) {
|
|
20
20
|
if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(p)) {
|
|
21
|
-
|
|
21
|
+
p = p[1] + ":" + p.slice(2);
|
|
22
22
|
}
|
|
23
|
-
return p;
|
|
23
|
+
return p.replace(/\\/g, "/");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -80,7 +80,7 @@ export function validatePath(filePath) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
return real;
|
|
83
|
+
return real.replace(/\\/g, "/");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -108,5 +108,5 @@ export function validateWritePath(filePath) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
return abs;
|
|
111
|
+
return abs.replace(/\\/g, "/");
|
|
112
112
|
}
|
package/lib/setup.mjs
CHANGED
|
@@ -168,31 +168,18 @@ function installOutputStyle() {
|
|
|
168
168
|
mkdirSync(dirname(target), { recursive: true });
|
|
169
169
|
writeFileSync(target, readFileSync(source, "utf-8"), "utf-8");
|
|
170
170
|
|
|
171
|
-
// Set hex-line
|
|
171
|
+
// Set hex-line only if no explicit style is already active
|
|
172
172
|
const userSettings = resolve(homedir(), ".claude/settings.json");
|
|
173
173
|
const config = readJson(userSettings) || {};
|
|
174
174
|
const prev = config.outputStyle;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Remove outputStyle from local/project scopes so global is not overridden
|
|
179
|
-
const overrides = [
|
|
180
|
-
resolve(process.cwd(), ".claude/settings.local.json"),
|
|
181
|
-
resolve(process.cwd(), ".claude/settings.json"),
|
|
182
|
-
];
|
|
183
|
-
const cleared = [];
|
|
184
|
-
for (const p of overrides) {
|
|
185
|
-
const c = readJson(p);
|
|
186
|
-
if (c && c.outputStyle) {
|
|
187
|
-
cleared.push(`${c.outputStyle} (${p.includes("local") ? "local" : "project"})`);
|
|
188
|
-
delete c.outputStyle;
|
|
189
|
-
writeJson(p, c);
|
|
190
|
-
}
|
|
175
|
+
if (!prev) {
|
|
176
|
+
config.outputStyle = "hex-line";
|
|
177
|
+
writeJson(userSettings, config);
|
|
191
178
|
}
|
|
192
179
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
const msg = prev
|
|
181
|
+
? `Output style file installed. Existing style '${prev}' preserved (not overridden)`
|
|
182
|
+
: "Output style 'hex-line' installed and activated globally";
|
|
196
183
|
return msg;
|
|
197
184
|
}
|
|
198
185
|
|
package/lib/update-check.mjs
CHANGED
|
@@ -1,56 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
|
|
5
|
-
const CACHE_FILE = join(tmpdir(), "hex-line-mcp-update.json");
|
|
6
|
-
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
|
7
|
-
const TIMEOUT = 3000;
|
|
8
|
-
|
|
9
|
-
async function readCache() {
|
|
10
|
-
try {
|
|
11
|
-
return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
|
|
12
|
-
} catch { return null; }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function writeCache(entry) {
|
|
16
|
-
await writeFile(CACHE_FILE, JSON.stringify(entry)).catch(() => {});
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function fetchLatest(packageName) {
|
|
20
|
-
try {
|
|
21
|
-
const ctrl = new AbortController();
|
|
22
|
-
const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
|
|
23
|
-
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { signal: ctrl.signal });
|
|
24
|
-
clearTimeout(timer);
|
|
25
|
-
if (!res.ok) return null;
|
|
26
|
-
const data = await res.json();
|
|
27
|
-
return data.version ?? null;
|
|
28
|
-
} catch { return null; }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function compareVersions(a, b) {
|
|
32
|
-
const pa = a.split(".").map(Number);
|
|
33
|
-
const pb = b.split(".").map(Number);
|
|
34
|
-
for (let i = 0; i < 3; i++) {
|
|
35
|
-
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
36
|
-
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
37
|
-
}
|
|
38
|
-
return 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function checkForUpdates(packageName, currentVersion) {
|
|
42
|
-
const cached = await readCache();
|
|
43
|
-
if (cached && Date.now() - cached.timestamp < CHECK_INTERVAL) {
|
|
44
|
-
if (cached.latest && compareVersions(currentVersion, cached.latest) < 0) {
|
|
45
|
-
process.stderr.write(`${packageName} update: ${currentVersion} → ${cached.latest}. Run: npm install -g ${packageName}\n`);
|
|
46
|
-
}
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const latest = await fetchLatest(packageName);
|
|
50
|
-
if (latest) {
|
|
51
|
-
await writeCache({ timestamp: Date.now(), latest });
|
|
52
|
-
if (compareVersions(currentVersion, latest) < 0) {
|
|
53
|
-
process.stderr.write(`${packageName} update: ${currentVersion} → ${latest}. Run: npm install -g ${packageName}\n`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
export * from "@levnikolaevich/hex-common/runtime/update-check";
|
package/lib/verify.mjs
CHANGED
|
@@ -3,24 +3,29 @@
|
|
|
3
3
|
* Validates range checksums from prior reads.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import { validatePath } from "./security.mjs";
|
|
8
|
-
import {
|
|
6
|
+
import { parseChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
|
|
7
|
+
import { validatePath, normalizePath } from "./security.mjs";
|
|
8
|
+
import {
|
|
9
|
+
buildRangeChecksum,
|
|
10
|
+
computeChangedRanges,
|
|
11
|
+
describeChangedRanges,
|
|
12
|
+
getSnapshotByRevision,
|
|
13
|
+
readSnapshot,
|
|
14
|
+
} from "./revisions.mjs";
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Verify checksums against current file state.
|
|
12
18
|
*
|
|
13
19
|
* @param {string} filePath
|
|
14
20
|
* @param {string[]} checksums - array of "start-end:8hex" strings
|
|
21
|
+
* @param {object} opts
|
|
15
22
|
* @returns {string} verification result
|
|
16
23
|
*/
|
|
17
|
-
export function verifyChecksums(filePath, checksums) {
|
|
24
|
+
export function verifyChecksums(filePath, checksums, opts = {}) {
|
|
25
|
+
filePath = normalizePath(filePath);
|
|
18
26
|
const real = validatePath(filePath);
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
// Pre-compute all line hashes
|
|
23
|
-
const lineHashes = lines.map((l) => fnv1a(l));
|
|
27
|
+
const current = readSnapshot(real);
|
|
28
|
+
const baseSnapshot = opts.baseRevision ? getSnapshotByRevision(opts.baseRevision) : null;
|
|
24
29
|
|
|
25
30
|
const results = [];
|
|
26
31
|
let allValid = true;
|
|
@@ -28,26 +33,37 @@ export function verifyChecksums(filePath, checksums) {
|
|
|
28
33
|
for (const cs of checksums) {
|
|
29
34
|
const parsed = parseChecksum(cs);
|
|
30
35
|
|
|
31
|
-
if (parsed.start < 1 || parsed.end > lines.length) {
|
|
32
|
-
results.push(`${cs}: INVALID (range ${parsed.start}-${parsed.end} exceeds file length ${lines.length})`);
|
|
36
|
+
if (parsed.start < 1 || parsed.end > current.lines.length) {
|
|
37
|
+
results.push(`${cs}: INVALID (range ${parsed.start}-${parsed.end} exceeds file length ${current.lines.length})`);
|
|
33
38
|
allValid = false;
|
|
34
39
|
continue;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const currentHex = current.split(":")[1];
|
|
42
|
+
const actual = buildRangeChecksum(current, parsed.start, parsed.end);
|
|
43
|
+
const currentHex = actual.split(":")[1];
|
|
40
44
|
|
|
41
45
|
if (currentHex === parsed.hex) {
|
|
42
46
|
results.push(`${cs}: valid`);
|
|
43
47
|
} else {
|
|
44
|
-
|
|
48
|
+
const staleBits = [`${cs}: STALE → current: ${actual}`];
|
|
49
|
+
if (baseSnapshot?.path === real) {
|
|
50
|
+
const changedRanges = computeChangedRanges(baseSnapshot.lines, current.lines);
|
|
51
|
+
staleBits.push(`revision: ${current.revision}`);
|
|
52
|
+
staleBits.push(`changed_ranges: ${describeChangedRanges(changedRanges)}`);
|
|
53
|
+
} else if (opts.baseRevision) {
|
|
54
|
+
staleBits.push(`revision: ${current.revision}`);
|
|
55
|
+
staleBits.push(`changed_ranges: unavailable (base revision evicted)`);
|
|
56
|
+
}
|
|
57
|
+
results.push(staleBits.join("\n"));
|
|
45
58
|
allValid = false;
|
|
46
59
|
}
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
if (allValid && checksums.length > 0) {
|
|
50
|
-
|
|
63
|
+
let msg = `All ${checksums.length} checksum(s) valid for ${filePath}`;
|
|
64
|
+
msg += `\nrevision: ${current.revision}`;
|
|
65
|
+
msg += `\nfile: ${current.fileChecksum}`;
|
|
66
|
+
return msg;
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
return results.join("\n");
|
package/output-style.md
CHANGED
|
@@ -6,25 +6,40 @@ keep-coding-instructions: true
|
|
|
6
6
|
|
|
7
7
|
# MCP Tool Preferences
|
|
8
8
|
|
|
9
|
-
**
|
|
9
|
+
**PREFER** hex-line MCP for code files — hash-annotated reads enable safe edits:
|
|
10
10
|
|
|
11
11
|
| Instead of | Use | Why |
|
|
12
12
|
|-----------|-----|-----|
|
|
13
|
-
| Read | `mcp__hex-line__read_file` | Hash-annotated,
|
|
14
|
-
| Edit | `mcp__hex-line__edit_file` | Hash-verified anchors |
|
|
13
|
+
| Read | `mcp__hex-line__read_file` | Hash-annotated, revision-aware |
|
|
14
|
+
| Edit | `mcp__hex-line__edit_file` | Hash-verified anchors + conservative auto-rebase |
|
|
15
15
|
| Write | `mcp__hex-line__write_file` | Consistent workflow |
|
|
16
16
|
| Grep | `mcp__hex-line__grep_search` | Hash-annotated matches |
|
|
17
|
+
| Edit (text rename) | `mcp__hex-line__bulk_replace` | Multi-file text rename/refactor |
|
|
17
18
|
|
|
18
19
|
## Efficient File Reading
|
|
19
20
|
|
|
20
|
-
For code files >100 lines,
|
|
21
|
+
For UNFAMILIAR code files >100 lines, PREFER:
|
|
21
22
|
1. `outline` first (10-20 lines of structure)
|
|
22
23
|
2. `read_file` with offset/limit for the specific section you need
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
Avoid reading a large file in full — outline+targeted read saves 75% tokens.
|
|
25
26
|
|
|
26
27
|
Bash OK for: npm/node/git/docker/curl, pipes, compound commands.
|
|
27
|
-
**
|
|
28
|
+
**Built-in OK for:** images, PDFs, notebooks, Glob (always), `.claude/settings.json` and `.claude/settings.local.json`.
|
|
29
|
+
|
|
30
|
+
## Edit Workflow
|
|
31
|
+
|
|
32
|
+
Prefer:
|
|
33
|
+
1. collect all known hunks for one file
|
|
34
|
+
2. send one `edit_file` call with batched edits
|
|
35
|
+
3. carry `revision` from `read_file` into `base_revision` on follow-up edits
|
|
36
|
+
4. use `replace_between` for large block rewrites
|
|
37
|
+
5. use `verify` before rereading a file after staleness
|
|
38
|
+
|
|
39
|
+
Avoid:
|
|
40
|
+
- chained same-file `edit_file` calls when all edits are already known
|
|
41
|
+
- full-file rewrites for local changes
|
|
42
|
+
- using `bulk_replace` for structural block rewrites
|
|
28
43
|
|
|
29
44
|
# Explanatory Style
|
|
30
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levnikolaevich/hex-line-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
|
+
"mcpName": "io.github.levnikolaevich/hex-line-mcp",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"description": "Hash-verified file editing MCP + token efficiency hook for AI coding agents. 11 tools: read, edit, write, grep, outline, verify, directory_tree, file_info, setup_hooks, changes, bulk_replace.",
|
|
6
7
|
"main": "server.mjs",
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
"files": [
|
|
11
12
|
"server.mjs",
|
|
12
13
|
"hook.mjs",
|
|
13
|
-
"benchmark
|
|
14
|
+
"benchmark/",
|
|
14
15
|
"lib/",
|
|
15
16
|
"README.md",
|
|
16
17
|
"output-style.md"
|
|
@@ -20,15 +21,26 @@
|
|
|
20
21
|
"lint": "eslint .",
|
|
21
22
|
"lint:fix": "eslint . --fix",
|
|
22
23
|
"test": "node --test test/*.mjs",
|
|
24
|
+
"benchmark": "node benchmark/index.mjs",
|
|
25
|
+
"benchmark:diagnostic": "node benchmark/index.mjs --diagnostics",
|
|
26
|
+
"benchmark:diagnostic:graph": "node benchmark/index.mjs --diagnostics --with-graph",
|
|
23
27
|
"check": "node --check server.mjs && node --check hook.mjs"
|
|
24
28
|
},
|
|
29
|
+
"_dep_notes": {
|
|
30
|
+
"web-tree-sitter": "Pinned ^0.25.0: v0.26 ABI incompatible with tree-sitter-wasms 0.1.x (built with tree-sitter-cli 0.20.8). Language.load() silently fails.",
|
|
31
|
+
"zod": "Pinned ^3.25.0: zod 4 breaks zod-to-json-schema (used by MCP SDK internally). Tool parameter descriptions not sent to clients. Revisit when MCP SDK switches to z.toJSONSchema().",
|
|
32
|
+
"better-sqlite3": "Optional. Used only by lib/graph-enrich.mjs for readonly access to hex-graph .codegraph/index.db. Graceful fallback if absent."
|
|
33
|
+
},
|
|
25
34
|
"dependencies": {
|
|
26
|
-
"@
|
|
35
|
+
"@levnikolaevich/hex-common": "file:../hex-common",
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
27
37
|
"diff": "^8.0.3",
|
|
28
38
|
"ignore": "^7.0.5",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
39
|
+
"zod": "^3.25.0",
|
|
40
|
+
"@vscode/ripgrep": "^1.15.9"
|
|
41
|
+
},
|
|
42
|
+
"optionalDependencies": {
|
|
43
|
+
"better-sqlite3": "^12.0.0"
|
|
32
44
|
},
|
|
33
45
|
"author": "Lev Nikolaevich <https://github.com/levnikolaevich>",
|
|
34
46
|
"license": "MIT",
|