@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/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 "./hash.mjs";
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 content = readText(real);
34
- const lines = content.split("\n");
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 = `${header}${graphLine}\n\n\`\`\`\n${parts.join("\n")}\n\`\`\``;
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 "./hash.mjs";
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("rg", args, { timeout: TIMEOUT });
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
- if (err.code === "ENOENT") {
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
- return p[1] + ":" + p.slice(2);
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 at user (global) level
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
- config.outputStyle = "hex-line";
176
- writeJson(userSettings, config);
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
- let msg = "Output style 'hex-line' installed and activated globally";
194
- if (prev && prev !== "hex-line") msg += ` (was '${prev}')`;
195
- if (cleared.length) msg += `. Removed overrides: ${cleared.join(", ")}`;
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
 
@@ -1,56 +1 @@
1
- import { readFile, writeFile } from "node:fs/promises";
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 { fnv1a, rangeChecksum, parseChecksum } from "./hash.mjs";
7
- import { validatePath } from "./security.mjs";
8
- import { readText } from "./format.mjs";
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 content = readText(real);
20
- const lines = content.split("\n");
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 currentHashes = lineHashes.slice(parsed.start - 1, parsed.end);
38
- const current = rangeChecksum(currentHashes, parsed.start, parsed.end);
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
- results.push(`${cs}: STALE → current: ${current}`);
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
- return `All ${checksums.length} checksum(s) valid for ${filePath}`;
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
- **MANDATORY:** NEVER use built-in Read, Edit, Write, Grep. Use hex-line MCP equivalents:
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, edit-ready |
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, ALWAYS:
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
- NEVER read a large file in full — outline+targeted read saves 75% tokens.
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
- **Exceptions** (use built-in Read): images, PDFs, Jupyter notebooks.
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.1",
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.mjs",
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
- "@modelcontextprotocol/sdk": "^1.17.0",
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
- "tree-sitter-wasms": "^0.1.0",
30
- "web-tree-sitter": "^0.25.0",
31
- "zod": "^3.24.0"
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",