@levnikolaevich/hex-line-mcp 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,10 +18,10 @@ Every line carries an FNV-1a content hash. Every edit must present those hashes
18
18
  | `read_file` | Read file with hash-annotated lines and range checksums | Partial reads via `offset`/`limit` |
19
19
  | `edit_file` | Hash-verified edits with anchor or text replacement | Returns compact diff via `diff` package |
20
20
  | `write_file` | Create new file or overwrite, auto-creates parent dirs | Path validation, no hash overhead |
21
- | `grep_search` | Search with ripgrep, returns hash-annotated matches | Edit-ready results -- search then edit directly |
21
+ | `grep_search` | Search with ripgrep, 3 output modes, per-group checksums | Edit-ready: grep -> edit directly with checksums |
22
22
  | `outline` | AST-based structural overview via tree-sitter WASM | 95% token reduction (10 lines instead of 500) |
23
23
  | `verify` | Check if held range checksums are still valid | Single-line response avoids full re-read |
24
- | `directory_tree` | Compact directory tree with .gitignore support | Skips node_modules/.git, shows file sizes |
24
+ | `directory_tree` | Compact directory tree with root .gitignore support | Skips node_modules/.git, shows file sizes |
25
25
  | `get_file_info` | File metadata without reading content | Size, lines, mtime, type, binary detection |
26
26
  | `setup_hooks` | Configure PreToolUse + PostToolUse hooks for Claude/Gemini/Codex | One call sets up everything, idempotent |
27
27
  | `changes` | Compare file against git ref, shows added/removed/modified symbols | AST-level semantic diff |
@@ -124,7 +124,7 @@ checksum: 1-50:f7e2a1b0
124
124
 
125
125
  ### edit_file
126
126
 
127
- Edit using hash-verified anchors or text replacement. Returns a unified diff.
127
+ Edit using hash-verified anchors or text replacement. Returns diff + post-edit checksums for chaining edits.
128
128
 
129
129
  | Parameter | Type | Required | Description |
130
130
  |-----------|------|----------|-------------|
@@ -140,7 +140,8 @@ Edit operations (JSON array):
140
140
  {"set_line": {"anchor": "ab.12", "new_text": "replacement line"}},
141
141
  {"replace_lines": {"start_anchor": "ab.10", "end_anchor": "cd.15", "new_text": "..."}},
142
142
  {"insert_after": {"anchor": "ab.20", "text": "inserted line"}},
143
- {"replace": {"old_text": "find this", "new_text": "replace with", "all": false}}
143
+ {"replace": {"old_text": "unique text", "new_text": "replacement"}},
144
+ {"replace": {"old_text": "find all", "new_text": "replace all", "all": true}}
144
145
  ]
145
146
  ```
146
147
 
@@ -155,20 +156,28 @@ Create a new file or overwrite an existing one. Creates parent directories autom
155
156
 
156
157
  ### grep_search
157
158
 
158
- Search file contents using ripgrep with hash-annotated results.
159
+ Search file contents using ripgrep. Three output modes: `content` (hash-annotated with checksums), `files` (paths only), `count` (match counts).
159
160
 
160
161
  | Parameter | Type | Required | Description |
161
162
  |-----------|------|----------|-------------|
162
- | `pattern` | string | yes | Regex search pattern |
163
+ | `pattern` | string | yes | Search pattern (regex by default, literal if `literal:true`) |
163
164
  | `path` | string | no | Directory or file to search (default: cwd) |
164
165
  | `glob` | string | no | Glob filter, e.g. `"*.ts"` |
165
166
  | `type` | string | no | File type filter, e.g. `"js"`, `"py"` |
167
+ | `output` | enum | no | Output format: `"content"` (default), `"files"`, `"count"` |
166
168
  | `case_insensitive` | boolean | no | Ignore case |
167
- | `smart_case` | boolean | no | Case-insensitive when pattern is all lowercase, case-sensitive if it has uppercase (`-S`) |
168
- | `context` | number | no | Context lines around matches |
169
+ | `smart_case` | boolean | no | CI when lowercase, CS when uppercase (`-S`) |
170
+ | `literal` | boolean | no | Literal string search, no regex (`-F`) |
171
+ | `multiline` | boolean | no | Pattern can span multiple lines (`-U`) |
172
+ | `context` | number | no | Symmetric context lines around matches (`-C`) |
173
+ | `context_before` | number | no | Context lines BEFORE match (`-B`) |
174
+ | `context_after` | number | no | Context lines AFTER match (`-A`) |
169
175
  | `limit` | number | no | Max matches per file (default: 100) |
176
+ | `total_limit` | number | no | Total match events across all files; multiline matches count as 1 (0 = unlimited) |
170
177
  | `plain` | boolean | no | Omit hash tags, return `file:line:content` |
171
178
 
179
+ **Content mode** returns per-group checksums enabling direct `replace_lines` from grep results without intermediate `read_file`.
180
+
172
181
  ### outline
173
182
 
174
183
  AST-based structural outline: functions, classes, interfaces with line ranges.
@@ -194,7 +203,7 @@ Returns a single-line confirmation or lists changed ranges.
194
203
 
195
204
  ### directory_tree
196
205
 
197
- Compact directory tree with .gitignore support and file sizes.
206
+ Compact directory tree with root .gitignore support (path-based rules, negation, dir-only). Nested .gitignore files are not loaded.
198
207
 
199
208
  | Parameter | Type | Required | Description |
200
209
  |-----------|------|----------|-------------|
@@ -202,7 +211,7 @@ Compact directory tree with .gitignore support and file sizes.
202
211
  | `pattern` | string | no | Glob filter on names (e.g. `"*-mcp"`, `"*.mjs"`). Returns flat match list instead of tree |
203
212
  | `type` | string | no | `"file"`, `"dir"`, or `"all"` (default). Like `find -type f/d` |
204
213
  | `max_depth` | number | no | Max recursion depth (default: 3, or 20 in pattern mode) |
205
- | `gitignore` | boolean | no | Respect .gitignore patterns (default: true) |
214
+ | `gitignore` | boolean | no | Respect root .gitignore patterns (default: true). Nested .gitignore not supported |
206
215
  | `format` | string | no | `"compact"` = names only, no sizes, depth 1. `"full"` = default with sizes |
207
216
 
208
217
  Skips `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `.next`, `coverage` by default.
@@ -219,39 +228,43 @@ Returns: size, line count, modification time (absolute + relative), file type, b
219
228
 
220
229
  ## Hook
221
230
 
222
- The unified PostToolUse hook (`hook.mjs`) handles two concerns:
231
+ The unified hook (`hook.mjs`) handles four events:
232
+
233
+ ### PreToolUse: Tool Redirect
223
234
 
224
- ### Hex-line Reminder
235
+ Blocks built-in `Read`, `Edit`, `Write`, `Grep` on text files and redirects to hex-line equivalents. Binary files (images, PDFs, notebooks, archives, executables, fonts, media) are excluded.
225
236
 
226
- Triggers on built-in `Read`, `Edit`, `Write`, `Grep` tool usage for text files. Outputs a short reminder to stderr (exit code 2) nudging the agent to use the corresponding hex-line tool instead.
237
+ ### PreToolUse: Bash Redirect + Dangerous Blocker
227
238
 
228
- Binary files (images, PDFs, notebooks, archives, executables, fonts, media) are excluded -- those should use built-in tools.
239
+ Intercepts simple Bash commands (`cat`, `head`, `tail`, `ls`, `find`, `grep`, `sed -i`, etc.) and redirects to hex-line tools. Blocks dangerous commands (`rm -rf /`, `git push --force`, `git reset --hard`, `DROP TABLE`, `chmod 777`, `mkfs`, `dd`).
229
240
 
230
- ### RTK Output Filter
241
+ ### PostToolUse: RTK Output Filter
231
242
 
232
243
  Triggers on `Bash` tool output exceeding 50 lines. Pipeline:
233
244
 
234
245
  1. **Detect command type** -- npm install, test, build, pip install, git verbose, or generic
235
- 2. **Type-specific summary** -- extracts key metrics (e.g., `npm install: 42 added, 3 warnings`)
236
- 3. **Normalize** -- replaces UUIDs, timestamps, IPs, hex values, large numbers with placeholders
237
- 4. **Deduplicate** -- collapses identical normalized lines with `(xN)` counts
238
- 5. **Truncate** -- keeps first 12 + last 12 lines, omits the middle
246
+ 2. **Normalize** -- replaces UUIDs, timestamps, IPs, hex values, large numbers with placeholders
247
+ 3. **Deduplicate** -- collapses identical normalized lines with `(xN)` counts
248
+ 4. **Truncate** -- keeps first 15 + last 15 lines, omits the middle
239
249
 
240
250
  Configuration constants in `hook.mjs`:
241
251
 
242
252
  | Constant | Default | Purpose |
243
- |----------|---------|---------|
253
+ |----------|---------|--------|
244
254
  | `LINE_THRESHOLD` | 50 | Minimum lines to trigger filtering |
245
- | `TRUNCATE_LIMIT` | 30 | Lines below this are kept as-is after dedup |
246
- | `HEAD_LINES` | 12 | Lines to keep from start |
247
- | `TAIL_LINES` | 12 | Lines to keep from end |
255
+ | `HEAD_LINES` | 15 | Lines to keep from start |
256
+ | `TAIL_LINES` | 15 | Lines to keep from end |
257
+
258
+ ### SessionStart: Tool Preferences
259
+
260
+ Injects hex-line tool preference list into agent context at session start.
248
261
 
249
262
  ## Architecture
250
263
 
251
264
  ```
252
265
  hex-line-mcp/
253
- server.mjs MCP server (stdio transport, 6 tools)
254
- hook.mjs PostToolUse hook (reminder + RTK filter)
266
+ server.mjs MCP server (stdio transport, 11 tools)
267
+ hook.mjs Unified hook (PreToolUse + PostToolUse + SessionStart)
255
268
  package.json
256
269
  lib/
257
270
  hash.mjs FNV-1a hashing, 2-char tags, range checksums
@@ -260,6 +273,13 @@ hex-line-mcp/
260
273
  search.mjs ripgrep wrapper with hash-annotated results
261
274
  outline.mjs tree-sitter WASM AST outline
262
275
  verify.mjs Range checksum verification
276
+ info.mjs File metadata (size, lines, mtime, type)
277
+ tree.mjs Directory tree with .gitignore support
278
+ changes.mjs Semantic git diff via AST
279
+ bulk-replace.mjs Multi-file search-and-replace
280
+ setup.mjs Hook installation for Claude/Gemini/Codex
281
+ format.mjs Output formatting utilities
282
+ coerce.mjs Parameter alias mapping
263
283
  security.mjs Path validation, binary detection, size limits
264
284
  normalize.mjs Output normalization, deduplication, truncation
265
285
  ```
@@ -318,7 +338,7 @@ Outline works on code files only (15+ languages via tree-sitter WASM). For markd
318
338
  <details>
319
339
  <summary><b>How does the RTK filter reduce tokens?</b></summary>
320
340
 
321
- The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs with placeholders), deduplicates identical lines, and truncates to first 12 + last 12 lines. Average savings: 45% (flat) / 52% (weighted) across 18 benchmark scenarios.
341
+ The PostToolUse hook normalizes Bash output (replaces UUIDs, timestamps, IPs with placeholders), deduplicates identical lines, and truncates to first 15 + last 15 lines. Average savings: 45% (flat) / 52% (weighted) across 18 benchmark scenarios.
322
342
 
323
343
  </details>
324
344
 
package/hook.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  * Exit 2 = block (PreToolUse) or feedback via stderr (PostToolUse)
24
24
  */
25
25
 
26
- import { deduplicateLines, smartTruncate } from "./lib/normalize.mjs";
26
+ import { normalizeOutput } from "./lib/normalize.mjs";
27
27
  import { readFileSync } from "node:fs";
28
28
  import { resolve } from "node:path";
29
29
  import { homedir } from "node:os";
@@ -57,13 +57,13 @@ const TOOL_HINTS = {
57
57
  Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
58
58
  Edit: "mcp__hex-line__edit_file (not Edit, not sed -i). read_file first for hashes",
59
59
  Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
60
- Grep: "mcp__hex-line__grep_search (not Grep). Params: case_insensitive, smart_case",
60
+ Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
61
61
  cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
62
62
  head: "mcp__hex-line__read_file with limit param (not head)",
63
63
  tail: "mcp__hex-line__read_file with offset param (not tail)",
64
64
  ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
65
65
  stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
66
- grep: "mcp__hex-line__grep_search (not grep/rg). Params: case_insensitive, smart_case",
66
+ grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
67
67
  sed: "mcp__hex-line__edit_file (not sed -i). read_file first for hashes",
68
68
  diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
69
69
  outline: "mcp__hex-line__outline (before reading large code files)",
@@ -335,10 +335,8 @@ function handlePostToolUse(data) {
335
335
 
336
336
  const type = detectCommandType(command);
337
337
 
338
- // Pipeline: deduplicate -> smart truncate
339
- const deduped = deduplicateLines(lines);
340
- const dedupedText = deduped.join("\n");
341
- const filtered = smartTruncate(dedupedText, HEAD_LINES, TAIL_LINES);
338
+ // Pipeline: normalize -> deduplicate -> smart truncate
339
+ const filtered = normalizeOutput(lines.join("\n"), { headLines: HEAD_LINES, tailLines: TAIL_LINES });
342
340
  const filteredCount = filtered.split("\n").length;
343
341
 
344
342
  const header = `RTK FILTERED: ${type} (${originalCount} lines -> ${filteredCount} lines)`;
@@ -400,31 +398,36 @@ function handleSessionStart() {
400
398
  }
401
399
 
402
400
  // ---- Main: read stdin, route by hook_event_name ----
401
+ // Guard: only run when executed directly, not when imported for testing
403
402
 
404
- let input = "";
405
- process.stdin.on("data", (chunk) => {
406
- input += chunk;
407
- });
408
- process.stdin.on("end", () => {
409
- try {
410
- const data = JSON.parse(input);
411
- const event = data.hook_event_name || "";
403
+ import { fileURLToPath } from "node:url";
412
404
 
413
- if (isHexLineDisabled()) {
414
- // REVERSE MODE: block hex-line calls, approve everything else
415
- if (event === "PreToolUse") handlePreToolUseReverse(data);
416
- process.exit(0); // SessionStart, PostToolUse — silent exit
417
- }
405
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
406
+ let input = "";
407
+ process.stdin.on("data", (chunk) => {
408
+ input += chunk;
409
+ });
410
+ process.stdin.on("end", () => {
411
+ try {
412
+ const data = JSON.parse(input);
413
+ const event = data.hook_event_name || "";
418
414
 
419
- // NORMAL MODE
420
- if (event === "SessionStart") handleSessionStart();
421
- else if (event === "PreToolUse") handlePreToolUse(data);
422
- else if (event === "PostToolUse") handlePostToolUse(data);
423
- else process.exit(0);
424
- } catch {
425
- process.exit(0);
426
- }
427
- });
415
+ if (isHexLineDisabled()) {
416
+ // REVERSE MODE: block hex-line calls, approve everything else
417
+ if (event === "PreToolUse") handlePreToolUseReverse(data);
418
+ process.exit(0); // SessionStart, PostToolUse — silent exit
419
+ }
420
+
421
+ // NORMAL MODE
422
+ if (event === "SessionStart") handleSessionStart();
423
+ else if (event === "PreToolUse") handlePreToolUse(data);
424
+ else if (event === "PostToolUse") handlePostToolUse(data);
425
+ else process.exit(0);
426
+ } catch {
427
+ process.exit(0);
428
+ }
429
+ });
430
+ }
428
431
 
429
432
  // ---- Exports for testing ----
430
433
  export { isHexLineDisabled, _resetHexLineDisabledCache };
@@ -1,16 +1,18 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { execSync } from "node:child_process";
1
+ import { writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
3
  import { resolve } from "node:path";
4
4
  import { simpleDiff } from "./edit.mjs";
5
+ import { normalizePath } from "./security.mjs";
6
+ import { readText, MAX_OUTPUT_CHARS } from "./format.mjs";
5
7
 
6
8
  export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
7
9
  const { dryRun = false, maxFiles = 100 } = opts;
8
- const abs = resolve(rootDir);
10
+ const abs = resolve(normalizePath(rootDir));
9
11
 
10
12
  // Find files via ripgrep (respects .gitignore)
11
13
  let files;
12
14
  try {
13
- const rgOut = execSync(`rg --files -g "${globPattern}" "${abs}"`, { encoding: "utf-8", timeout: 10000 });
15
+ const rgOut = execFileSync("rg", ["--files", "-g", globPattern, abs], { encoding: "utf-8", timeout: 10000 });
14
16
  files = rgOut.trim().split("\n").filter(Boolean);
15
17
  } catch (e) {
16
18
  if (e.status === 1) return "No files matched the glob pattern.";
@@ -23,12 +25,12 @@ export function bulkReplace(rootDir, globPattern, replacements, opts = {}) {
23
25
 
24
26
  const results = [];
25
27
  let changed = 0, skipped = 0, errors = 0;
26
- const MAX_OUTPUT = 80000;
28
+ const MAX_OUTPUT = MAX_OUTPUT_CHARS;
27
29
  let totalChars = 0;
28
30
 
29
31
  for (const file of files) {
30
32
  try {
31
- const original = readFileSync(file, "utf-8").replace(/\r\n/g, "\n");
33
+ const original = readText(file);
32
34
  let content = original;
33
35
 
34
36
  for (const { old: oldText, new: newText } of replacements) {
package/lib/changes.mjs CHANGED
@@ -6,10 +6,11 @@
6
6
  * without temp files.
7
7
  */
8
8
 
9
- import { execSync } from "node:child_process";
10
- import { readFileSync, statSync } from "node:fs";
9
+ import { execFileSync } from "node:child_process";
10
+ import { statSync } from "node:fs";
11
11
  import { extname } from "node:path";
12
12
  import { validatePath } from "./security.mjs";
13
+ import { readText } from "./format.mjs";
13
14
  import { outlineFromContent } from "./outline.mjs";
14
15
 
15
16
  /**
@@ -50,7 +51,7 @@ function toSymbolMap(entries) {
50
51
  * Get relative path from git root for `git show`.
51
52
  */
52
53
  function gitRelativePath(absPath) {
53
- const root = execSync("git rev-parse --show-toplevel", {
54
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
54
55
  cwd: absPath.replace(/[/\\][^/\\]+$/, ""),
55
56
  encoding: "utf-8",
56
57
  timeout: 5000,
@@ -79,7 +80,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
79
80
  // Directory: return git diff --stat (compact file list, no content reads)
80
81
  if (statSync(real).isDirectory()) {
81
82
  try {
82
- const stat = execSync(`git diff --stat "${compareAgainst}" -- .`, {
83
+ const stat = execFileSync("git", ["diff", "--stat", compareAgainst, "--", "."], {
83
84
  cwd: real,
84
85
  encoding: "utf-8",
85
86
  timeout: 10000,
@@ -94,7 +95,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
94
95
  const ext = extname(real).toLowerCase();
95
96
 
96
97
  // Check if outline supports this extension
97
- const currentContent = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
98
+ const currentContent = readText(real);
98
99
  const currentResult = await outlineFromContent(currentContent, ext);
99
100
  if (!currentResult) {
100
101
  return `Cannot outline ${ext} files. Supported: .js .mjs .ts .py .go .rs .java .c .cpp .cs .rb .php .kt .swift .sh .bash`;
@@ -104,7 +105,7 @@ export async function fileChanges(filePath, compareAgainst = "HEAD") {
104
105
  const relPath = gitRelativePath(real);
105
106
  let gitContent;
106
107
  try {
107
- gitContent = execSync(`git show "${compareAgainst}:${relPath}"`, {
108
+ gitContent = execFileSync("git", ["show", `${compareAgainst}:${relPath}`], {
108
109
  cwd: real.replace(/[/\\][^/\\]+$/, ""),
109
110
  encoding: "utf-8",
110
111
  timeout: 5000,
package/lib/coerce.mjs CHANGED
@@ -12,12 +12,6 @@ const ALIASES = {
12
12
  // grep_search
13
13
  query: "pattern",
14
14
  search: "pattern",
15
- max_results: "limit",
16
- maxResults: "limit",
17
- maxMatches: "limit",
18
- max_matches: "limit",
19
- contextLines: "context",
20
- context_lines: "context",
21
15
  ignoreCase: "case_insensitive",
22
16
  ignore_case: "case_insensitive",
23
17
 
package/lib/edit.mjs CHANGED
@@ -8,11 +8,12 @@
8
8
  * - dry_run preview, noop detection, diff output
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync } from "node:fs";
11
+ import { writeFileSync } from "node:fs";
12
12
  import { diffLines } from "diff";
13
- import { fnv1a, lineTag, rangeChecksum, parseChecksum } from "./hash.mjs";
13
+ import { fnv1a, lineTag, rangeChecksum, parseChecksum, parseRef } from "./hash.mjs";
14
14
  import { validatePath } from "./security.mjs";
15
15
  import { getGraphDB, blastRadius, getRelativePath } from "./graph-enrich.mjs";
16
+ import { readText } from "./format.mjs";
16
17
 
17
18
  // Unicode characters visually similar to ASCII hyphen-minus (U+002D)
18
19
  const CONFUSABLE_HYPHENS = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
@@ -40,6 +41,24 @@ function restoreIndent(origLines, newLines) {
40
41
  });
41
42
  }
42
43
 
44
+ /**
45
+ * Build hash-annotated snippet around a position for error messages.
46
+ * @param {string[]} lines - file lines
47
+ * @param {number} centerIdx - 0-based center index
48
+ * @param {number} radius - lines before/after center (default 5)
49
+ * @returns {{ start: number, end: number, text: string }}
50
+ */
51
+ function buildErrorSnippet(lines, centerIdx, radius = 5) {
52
+ const start = Math.max(0, centerIdx - radius);
53
+ const end = Math.min(lines.length, centerIdx + radius + 1);
54
+ const text = lines.slice(start, end).map((line, i) => {
55
+ const num = start + i + 1;
56
+ const tag = lineTag(fnv1a(line));
57
+ return `${tag}.${num}\t${line}`;
58
+ }).join("\n");
59
+ return { start: start + 1, end, text };
60
+ }
61
+
43
62
  /**
44
63
  * Build a hash index of all lines, keeping only unique tags.
45
64
  * 2-char tags have collisions — duplicates are excluded to avoid wrong relocations.
@@ -65,21 +84,11 @@ function buildHashIndex(lines) {
65
84
  function findLine(lines, lineNum, expectedTag, hashIndex) {
66
85
  const idx = lineNum - 1;
67
86
  if (idx < 0 || idx >= lines.length) {
68
- const start = idx >= lines.length
69
- ? Math.max(0, lines.length - 10)
70
- : 0;
71
- const end = idx >= lines.length
72
- ? lines.length
73
- : Math.min(lines.length, 10);
74
- const snippet = lines.slice(start, end).map((line, i) => {
75
- const num = start + i + 1;
76
- const tag = lineTag(fnv1a(line));
77
- return `${tag}.${num}\t${line}`;
78
- }).join("\n");
79
-
87
+ const center = idx >= lines.length ? lines.length - 1 : 0;
88
+ const snip = buildErrorSnippet(lines, center);
80
89
  throw new Error(
81
90
  `Line ${lineNum} out of range (1-${lines.length}).\n\n` +
82
- `Current content (lines ${start + 1}-${end}):\n${snippet}\n\n` +
91
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
83
92
  `Tip: Use updated hashes above for retry.`
84
93
  );
85
94
  }
@@ -116,30 +125,14 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
116
125
  if (relocated !== undefined) return relocated;
117
126
  }
118
127
 
119
- // Build snippet with fresh hashes so agent can retry without re-reading
120
- const start = Math.max(0, idx - 5);
121
- const end = Math.min(lines.length, idx + 6);
122
- const snippet = lines.slice(start, end).map((line, i) => {
123
- const num = start + i + 1;
124
- const tag = lineTag(fnv1a(line));
125
- return `${tag}.${num}\t${line}`;
126
- }).join("\n");
127
-
128
+ const snip = buildErrorSnippet(lines, idx);
128
129
  throw new Error(
129
- `Hash mismatch line ${lineNum}: expected ${expectedTag}, got ${actual}.\n\n` +
130
- `Current content (lines ${start + 1}-${end}):\n${snippet}\n\n` +
130
+ `HASH_MISMATCH: line ${lineNum} expected ${expectedTag}, got ${actual}.\n\n` +
131
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
131
132
  `Tip: Use updated hashes above for retry.`
132
133
  );
133
134
  }
134
135
 
135
- /**
136
- * Parse a ref string: "ab.12" → { tag: "ab", line: 12 }
137
- */
138
- function parseRef(ref) {
139
- const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
140
- if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12"`);
141
- return { tag: m[1], line: parseInt(m[2], 10) };
142
- }
143
136
 
144
137
  /**
145
138
  * Context diff via `diff` package (Myers O(ND) algorithm).
@@ -243,7 +236,20 @@ function textReplace(content, oldText, newText, all) {
243
236
  const normNew = newText.replace(/\r\n/g, "\n");
244
237
 
245
238
  if (!all) {
246
- throw new Error("replace requires all:true (rename-all mode). For single replacements, use set_line or replace_lines with hash anchors.");
239
+ // Uniqueness check: count occurrences
240
+ const parts = norm.split(normOld);
241
+ const count = parts.length - 1;
242
+ if (count === 0) {
243
+ // Fall through to TEXT_NOT_FOUND below
244
+ } else if (count === 1) {
245
+ // Unique match — safe to replace single occurrence
246
+ return parts.join(normNew);
247
+ } else {
248
+ throw new Error(
249
+ `AMBIGUOUS_MATCH: "${normOld.slice(0, 80)}" found ${count} times. ` +
250
+ `Use all:true to replace all, or use set_line/replace_lines for a specific occurrence.`
251
+ );
252
+ }
247
253
  }
248
254
  let idx = norm.indexOf(normOld);
249
255
  let confusableMatch = false;
@@ -299,9 +305,10 @@ function textReplace(content, oldText, newText, all) {
299
305
  */
300
306
  export function editFile(filePath, edits, opts = {}) {
301
307
  const real = validatePath(filePath);
302
- const original = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
308
+ const original = readText(real);
303
309
  const lines = original.split("\n");
304
310
  const origLines = [...lines];
311
+ const hadTrailingNewline = original.endsWith("\n");
305
312
 
306
313
  // Build hash index once for global relocation in findLine
307
314
  const hashIndex = buildHashIndex(lines);
@@ -313,7 +320,35 @@ export function editFile(filePath, edits, opts = {}) {
313
320
  for (const e of edits) {
314
321
  if (e.set_line || e.replace_lines || e.insert_after) anchored.push(e);
315
322
  else if (e.replace) texts.push(e);
316
- else throw new Error(`Unknown edit type: ${JSON.stringify(e)}`);
323
+ else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
324
+ }
325
+
326
+ // Overlap validation: reject duplicate/overlapping edit targets
327
+ const editTargets = [];
328
+ for (const e of anchored) {
329
+ if (e.set_line) {
330
+ const line = parseRef(e.set_line.anchor).line;
331
+ editTargets.push({ start: line, end: line });
332
+ } else if (e.replace_lines) {
333
+ const s = parseRef(e.replace_lines.start_anchor).line;
334
+ const en = parseRef(e.replace_lines.end_anchor).line;
335
+ editTargets.push({ start: s, end: en });
336
+ } else if (e.insert_after) {
337
+ const line = parseRef(e.insert_after.anchor).line;
338
+ editTargets.push({ start: line, end: line, insert: true });
339
+ }
340
+ }
341
+ for (let i = 0; i < editTargets.length; i++) {
342
+ for (let j = i + 1; j < editTargets.length; j++) {
343
+ const a = editTargets[i], b = editTargets[j];
344
+ if (a.insert || b.insert) continue; // insert_after doesn't overlap
345
+ if (a.start <= b.end && b.start <= a.end) {
346
+ throw new Error(
347
+ `OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. ` +
348
+ `Split into separate edit_file calls.`
349
+ );
350
+ }
351
+ }
317
352
  }
318
353
 
319
354
  // Sort anchor edits bottom-to-top
@@ -356,9 +391,11 @@ export function editFile(filePath, edits, opts = {}) {
356
391
  const actualStart = si + 1;
357
392
  const actualEnd = ei + 1;
358
393
  if (csStart > actualStart || csEnd < actualEnd) {
394
+ const snip = buildErrorSnippet(origLines, actualStart - 1);
359
395
  throw new Error(
360
- `Checksum range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}. ` +
361
- `Re-read lines ${actualStart}-${actualEnd} first.`
396
+ `CHECKSUM_RANGE_GAP: range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}.\n\n` +
397
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
398
+ `Tip: Use updated hashes above for retry.`
362
399
  );
363
400
  }
364
401
 
@@ -366,14 +403,24 @@ export function editFile(filePath, edits, opts = {}) {
366
403
  const csStartIdx = csStart - 1;
367
404
  const csEndIdx = csEnd - 1;
368
405
  if (csStartIdx < 0 || csEndIdx >= origLines.length) {
369
- throw new Error(`Checksum range ${csStart}-${csEnd} out of bounds (file has ${origLines.length} lines). Re-read the file.`);
406
+ const snip = buildErrorSnippet(origLines, origLines.length - 1);
407
+ throw new Error(
408
+ `CHECKSUM_OUT_OF_BOUNDS: range ${csStart}-${csEnd} exceeds file length ${origLines.length}.\n\n` +
409
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
410
+ `Tip: Use updated hashes above for retry.`
411
+ );
370
412
  }
371
413
  const lineHashes = [];
372
414
  for (let i = csStartIdx; i <= csEndIdx; i++) lineHashes.push(fnv1a(origLines[i]));
373
415
  const actual = rangeChecksum(lineHashes, csStart, csEnd);
374
416
  const actualHex = actual.split(":")[1];
375
417
  if (csHex !== actualHex) {
376
- throw new Error(`Range checksum mismatch: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.`);
418
+ const snip = buildErrorSnippet(origLines, csStartIdx);
419
+ throw new Error(
420
+ `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.\n\n` +
421
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
422
+ `Retry with fresh checksum ${actual}, or use set_line with hashes above.`
423
+ );
377
424
  }
378
425
 
379
426
  const txt = e.replace_lines.new_text;
@@ -396,6 +443,8 @@ export function editFile(filePath, edits, opts = {}) {
396
443
 
397
444
  // Apply text replacements
398
445
  let content = lines.join("\n");
446
+ if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
447
+ if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
399
448
  for (const e of texts) {
400
449
  if (!e.replace.old_text) throw new Error("replace.old_text required");
401
450
  content = textReplace(content, e.replace.old_text, e.replace.new_text || "", e.replace.all || false);
@@ -442,5 +491,34 @@ export function editFile(filePath, edits, opts = {}) {
442
491
  }
443
492
  } catch { /* silent */ }
444
493
 
494
+ // Post-edit context: hash-annotated lines around changed region + checksums
495
+ const newLines = content.split("\n");
496
+ if (diff) {
497
+ const diffArr = diff.split("\n");
498
+ let minLine = Infinity, maxLine = 0;
499
+ for (const dl of diffArr) {
500
+ const m = dl.match(/^[+-](\d+)\|/);
501
+ if (m) { const n = +m[1]; if (n < minLine) minLine = n; if (n > maxLine) maxLine = n; }
502
+ }
503
+ if (minLine <= maxLine) {
504
+ const ctxStart = Math.max(0, minLine - 6);
505
+ const ctxEnd = Math.min(newLines.length, maxLine + 5);
506
+ const ctxLines = [];
507
+ const ctxHashes = [];
508
+ for (let i = ctxStart; i < ctxEnd; i++) {
509
+ const h = fnv1a(newLines[i]);
510
+ ctxHashes.push(h);
511
+ ctxLines.push(`${lineTag(h)}.${i + 1}\t${newLines[i]}`);
512
+ }
513
+ const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
514
+ msg += `\n\nPost-edit (lines ${ctxStart + 1}-${ctxEnd}):\n${ctxLines.join("\n")}\nchecksum: ${ctxCs}`;
515
+ }
516
+ }
517
+ // File-level checksum
518
+ const fileHashes = [];
519
+ for (let i = 0; i < newLines.length; i++) fileHashes.push(fnv1a(newLines[i]));
520
+ const fileCs = rangeChecksum(fileHashes, 1, newLines.length);
521
+ msg += `\nfile: ${fileCs}`;
522
+
445
523
  return msg;
446
524
  }