pi-hashline-edit-pro 0.12.3 → 0.13.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
@@ -66,18 +66,18 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
66
66
  {
67
67
  "path": "src/main.ts",
68
68
  "changes": [
69
- { "hash_range_incl": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
69
+ { "hash_range_inclusive": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
70
70
  ]
71
71
  }
72
72
  ```
73
73
 
74
74
  | Field | Description |
75
75
  | --- | --- |
76
- | `hash_range_incl` | Inclusive line range `[start_hash, end_hash]` (required). |
76
+ | `hash_range_inclusive` | Inclusive line range `[start_hash, end_hash]` (required). |
77
77
  | `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
78
78
 
79
79
  - **Request structure validation.** The request envelope (`path`, `changes`) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
80
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_incl: ["<START>", "<END>"], content_lines: [...]}`.
80
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
81
81
  - **Batched atomicity.** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
82
82
 
83
83
  ### Chained edits
@@ -101,7 +101,7 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
101
101
 
102
102
  ## Design Decisions
103
103
 
104
- - **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_incl` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
104
+ - **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
105
105
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
106
106
  - **Strict patch content.** If `content_lines` contains `+HASH│` display prefixes (or `-N ` numbered deletion rows), the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
107
107
  - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
package/index.ts CHANGED
@@ -4,7 +4,7 @@ import { join, isAbsolute } from "path";
4
4
  import { lineHashes, initHasher, fmtRegion } from "./src/hashline";
5
5
  import { regReplace } from "./src/replace";
6
6
  import { regRead, formatPaginationHint } from "./src/read";
7
- import { toLF } from "./src/replace-diff";
7
+ import { toLF, stripBOM } from "./src/replace-diff";
8
8
  import { visLines } from "./src/utils";
9
9
  import { AUTO_READ_MAX } from "./src/constants";
10
10
 
@@ -12,10 +12,15 @@ export default function (pi: ExtensionAPI): void {
12
12
  regRead(pi);
13
13
  regReplace(pi);
14
14
 
15
+ const debugValue = process.env.PI_HASHLINE_DEBUG;
16
+
15
17
  pi.on("session_start", async (_event, ctx) => {
16
18
  const active = pi.getActiveTools();
17
19
  pi.setActiveTools(active.filter((t) => t !== "edit"));
18
20
  await initHasher();
21
+ if (debugValue === "1" || debugValue === "true") {
22
+ ctx.ui.notify("Hashline Edit mode active", "info");
23
+ }
19
24
  });
20
25
 
21
26
  const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
@@ -40,8 +45,8 @@ export default function (pi: ExtensionAPI): void {
40
45
  try {
41
46
  const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
42
47
  const content = await readFile(absolutePath, "utf-8");
43
-
44
- const normalized = toLF(content);
48
+ const { text: rawContent } = stripBOM(content);
49
+ const normalized = toLF(rawContent);
45
50
  const visibleLines = visLines(normalized);
46
51
 
47
52
  if (visibleLines.length === 0) return;
@@ -65,14 +70,9 @@ export default function (pi: ExtensionAPI): void {
65
70
  ],
66
71
  };
67
72
  }
68
- } catch {
73
+ } catch (error) {
74
+ console.error("Auto-read after write failed:", error);
69
75
  }
70
76
  });
71
77
 
72
- const debugValue = process.env.PI_HASHLINE_DEBUG;
73
- if (debugValue === "1" || debugValue === "true") {
74
- pi.on("session_start", async (_event, ctx) => {
75
- ctx.ui.notify("Hashline Edit mode active", "info");
76
- });
77
- }
78
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.12.3",
3
+ "version": "0.13.1",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -1,3 +1,4 @@
1
1
  - Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
2
2
  - After a successful `replace`, the response text is empty (warnings only). Call `read` to get fresh anchors for follow-up edits.
3
- - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_incl`, and retry.
3
+ - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
+ - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
@@ -9,10 +9,10 @@ How to use:
9
9
  read({ path: "src/main.ts" })
10
10
  ```
11
11
 
12
- 2. Copy the 3-character HASH (before `│`) into `hash_range_incl`:
12
+ 2. Copy the 3-character HASH (before `│`) into `hash_range_inclusive`:
13
13
  ```json
14
14
  { "path": "src/main.ts", "changes": [
15
- { "hash_range_incl": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
15
+ { "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
16
16
  ] }
17
17
  ```
18
18
 
@@ -21,14 +21,14 @@ Examples:
21
21
  1. Single line replace:
22
22
  ```json
23
23
  { "path": "src/main.ts", "changes": [
24
- { "hash_range_incl": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
24
+ { "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
25
25
  ] }
26
26
  ```
27
27
 
28
28
  2. Range replace (3 lines → 3 new lines):
29
29
  ```json
30
30
  { "path": "src/main.ts", "changes": [
31
- { "hash_range_incl": ["ZPM", "VRW"], "content_lines": [
31
+ { "hash_range_inclusive": ["ZPM", "VRW"], "content_lines": [
32
32
  "function greet(name) {",
33
33
  " return `Hello, ${name}`;",
34
34
  "}"
@@ -39,8 +39,8 @@ Examples:
39
39
  3. Multiple regions in one call (delete two non-adjacent ranges):
40
40
  ```json
41
41
  { "path": "src/server.ts", "changes": [
42
- { "hash_range_incl": ["aB3", "xY7"], "content_lines": [] },
43
- { "hash_range_incl": ["MQX", "ZPM"], "content_lines": [] }
42
+ { "hash_range_inclusive": ["aB3", "xY7"], "content_lines": [] },
43
+ { "hash_range_inclusive": ["MQX", "ZPM"], "content_lines": [] }
44
44
  ] }
45
45
  ```
46
46
 
@@ -48,7 +48,7 @@ Examples:
48
48
 
49
49
  ```json
50
50
  { "path": "src/main.ts", "changes": [
51
- { "hash_range_incl": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
51
+ { "hash_range_inclusive": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
52
52
  ] }
53
53
  ```
54
54
 
@@ -56,7 +56,7 @@ Examples:
56
56
 
57
57
  ```json
58
58
  { "path": "src/main.ts", "changes": [
59
- { "hash_range_incl": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
59
+ { "hash_range_inclusive": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
60
60
  ] }
61
61
  ```
62
62
 
@@ -64,48 +64,69 @@ Examples:
64
64
 
65
65
  Wrong:
66
66
  ```json
67
- { "hash_range_incl": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
67
+ { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
68
68
  ```
69
69
 
70
70
  Right:
71
71
  ```json
72
- { "hash_range_incl": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
72
+ { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
73
73
  ```
74
74
 
75
- `hash_range_incl` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
75
+ `hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
76
76
 
77
- ⚠️ Common mistake: `hash_range_incl` is only the 3-character HASH, not the full `HASH│content` line.
77
+ ⚠️ Common mistake: `hash_range_inclusive` is only the 3-character HASH, not the full `HASH│content` line.
78
78
 
79
79
  Wrong:
80
80
  ```json
81
- { "hash_range_incl": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
81
+ { "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
82
82
  ```
83
83
 
84
84
  Right:
85
85
  ```json
86
- { "hash_range_incl": ["F4T", "F4T"], "content_lines": [...] }
86
+ { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": [...] }
87
87
  ```
88
88
 
89
89
  Rules:
90
- - `hash_range_incl` is a pair `[start, end]`. A single-line replace is `hash_range_incl: ["X", "X"]`.
90
+ - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
91
91
  - To delete a range, use `content_lines: []`.
92
- - `hash_range_incl` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
92
+ - `hash_range_inclusive` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
93
93
  - `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix. A line that happens to start with `+` or `-` is written as-is; the only rejected form is the diff preview's `+HASH│…` row (see `[E_INVALID_PATCH]`).
94
94
  - Don't add `""` for spacing unless you actually want a new blank line.
95
95
  - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
96
96
  - All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
97
97
  - If `content_lines` matches current content, the replace is classified as `noop` (file unchanged).
98
- - The `hash_range_incl` is inclusive — both anchors and every line between them are replaced. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
98
+ - The `hash_range_inclusive` is inclusive — the entire span from the first anchor through the second anchor is deleted and replaced with `content_lines`. The old lines in that span are gone. If your replacement content includes lines that already exist in the file (e.g. closing brackets), make sure those lines are within your range, otherwise they will appear twice.
99
99
  On success, the response text is empty (or contains only warnings if present). Call `read` to get fresh anchors for follow-up edits.
100
100
 
101
+ ⚠️ Common mistake: `hash_range_inclusive` replaces the ENTIRE range. Every line from the first anchor through the second anchor is deleted and replaced with `content_lines`. Do not include "context" or "surrounding" lines in `content_lines` — they are outside the range and will be preserved automatically.
102
+
103
+ Wrong: To replace lines 2-3 in this function:
104
+ ```
105
+ function greet() {
106
+ const x = 1;
107
+ return x;
108
+ }
109
+ ```
110
+ A model might write:
111
+ ```json
112
+ { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;", "}"] }
113
+ ```
114
+ This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
115
+
116
+ Right: Only include the new lines that belong in the range:
117
+ ```json
118
+ { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;"] }
119
+ ```
120
+ The `}` on line 4 is outside the range and stays in place.
121
+
101
122
  Error recovery:
102
- - `[E_STALE_ANCHOR]` — the anchored line's content changed since the last read. Call `read` to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into `hash_range_incl` and retry. (Staleness is per-line: editing or appending lines does not invalidate anchors for lines whose content is unchanged, so anchors for untouched regions stay valid across edits.)
123
+ - `[E_STALE_ANCHOR]` — the anchored line's content changed since the last read. Call `read` to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into `hash_range_inclusive` and retry. (Staleness is per-line: editing or appending lines does not invalidate anchors for lines whose content is unchanged, so anchors for untouched regions stay valid across edits.)
103
124
  - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
104
125
  - `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
105
126
  - `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
106
- - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_incl, content_lines}` instead.
127
+ - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_inclusive, content_lines}` instead.
107
128
  - `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
108
129
  - `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
109
- - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_incl` uses hashes, `content_lines` does not.
130
+ - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
110
131
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
111
132
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
@@ -76,8 +76,8 @@ function resToSpan(
76
76
  ): RESpan | null {
77
77
  const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
78
78
 
79
- const startLine = edit.hash_range_incl[0].line;
80
- const endLine = edit.hash_range_incl[1].line;
79
+ const startLine = edit.hash_range_inclusive[0].line;
80
+ const endLine = edit.hash_range_inclusive[1].line;
81
81
  const originalLines = fileLines.slice(startLine - 1, endLine);
82
82
  if (
83
83
  originalLines.length === edit.content_lines.length &&
@@ -87,7 +87,7 @@ function resToSpan(
87
87
  ) {
88
88
  noopEdits.push({
89
89
  editIndex: index,
90
- loc: edit.hash_range_incl[0].hash,
90
+ loc: edit.hash_range_inclusive[0].hash,
91
91
  currentContent: originalLines.join("\n"),
92
92
  });
93
93
  return null;
@@ -228,8 +228,8 @@ function fmtBoundaryWarning(params: {
228
228
  }): string {
229
229
  const header =
230
230
  params.kind === "trailing"
231
- ? "Boundary duplication (trailing): the last replacement line duplicated the next line. Delete the duplicate."
232
- : "Boundary duplication (leading): the first replacement line duplicated the previous line. Delete the duplicate.";
231
+ ? "Boundary duplication (trailing): the last replacement line duplicated the next line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there."
232
+ : "Boundary duplication (leading): the first replacement line duplicated the previous line. This happens when `content_lines` includes a line that was already outside the replaced range. Delete the duplicate — the original line outside the range is still there.";
233
233
 
234
234
  // Locate the adjacent duplicated pair (two identical neighboring lines). The
235
235
  // occurrence-based matchIndex usually lands inside the pair; when a file has
@@ -98,8 +98,5 @@ export function lineHashes(content: string): string[] {
98
98
  return hashes;
99
99
  }
100
100
 
101
- export function lineHash(idx: number, line: string): string {
102
- return h2s(xxh32(canon(line)));
103
- }
104
101
 
105
102
  export { ALPH_RE };
@@ -8,7 +8,6 @@ export {
8
8
  DIFF_MINUS_RE,
9
9
  HL_BARE_PREFIX_RE,
10
10
  lineHashes,
11
- lineHash,
12
11
  initHasher,
13
12
  } from "./hash";
14
13
 
@@ -20,7 +20,7 @@ function diagRef(ref: string): string {
20
20
  }
21
21
 
22
22
  if (trimmed.includes("│")) {
23
- return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_range_incl must contain the 3-char hash only — remove everything from "│" onward.`;
23
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_range_inclusive must contain the 3-char hash only — remove everything from "│" onward.`;
24
24
  }
25
25
 
26
26
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char base64 anchor (e.g. "aB3").`;
@@ -9,9 +9,9 @@ export type RAnchor = {
9
9
  hashMatched: boolean;
10
10
  };
11
11
 
12
- export type HEdit = { hash_range_incl: [Anchor, Anchor]; content_lines: string[] };
12
+ export type HEdit = { hash_range_inclusive: [Anchor, Anchor]; content_lines: string[] };
13
13
  export type RHEdit = {
14
- hash_range_incl: [RAnchor, RAnchor];
14
+ hash_range_inclusive: [RAnchor, RAnchor];
15
15
  content_lines: string[];
16
16
  };
17
17
 
@@ -37,7 +37,7 @@ export interface NEdit {
37
37
  }
38
38
 
39
39
  export type HTEdit = {
40
- hash_range_incl: [string, string];
40
+ hash_range_inclusive: [string, string];
41
41
  content_lines: string[];
42
42
  };
43
43
 
@@ -90,13 +90,13 @@ export function fmtMismatch(
90
90
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
91
91
  if (notFound.length > 0) {
92
92
  out.push(
93
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_incl of your next replace call.`
93
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
94
94
  );
95
95
  }
96
96
  if (ambiguous.length > 0) {
97
97
  if (out.length > 0) out.push("");
98
98
  out.push(
99
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_incl of your next replace call.`
99
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
100
100
  );
101
101
  for (const m of ambiguous) {
102
102
  const sample = (m.candidates ?? []).slice(0, 5);
@@ -119,7 +119,7 @@ export function fmtMismatch(
119
119
  return out.join("\n");
120
120
  }
121
121
 
122
- const ITEM_KS = new Set(["hash_range_incl", "content_lines"]);
122
+ const ITEM_KS = new Set(["hash_range_inclusive", "content_lines"]);
123
123
 
124
124
  function isStrArr(value: unknown): value is string[] {
125
125
  return (
@@ -136,11 +136,11 @@ function isStrPair(value: unknown): value is [string, string] {
136
136
  }
137
137
 
138
138
  function assertItem(edit: Record<string, unknown>, index: number): void {
139
- rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { hash_range_incl, content_lines }.");
139
+ rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { hash_range_inclusive, content_lines }.");
140
140
 
141
- if ("hash_range_incl" in edit && !isStrPair(edit.hash_range_incl)) {
141
+ if ("hash_range_inclusive" in edit && !isStrPair(edit.hash_range_inclusive)) {
142
142
  throw new Error(
143
- `[E_BAD_SHAPE] Edit ${index} field "hash_range_incl" must be a pair of anchor strings [start, end].`,
143
+ `[E_BAD_SHAPE] Edit ${index} field "hash_range_inclusive" must be a pair of anchor strings [start, end].`,
144
144
  );
145
145
  }
146
146
  if (!("content_lines" in edit)) {
@@ -149,9 +149,9 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
149
149
  if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
150
150
  throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
151
151
  }
152
- if (!isStrPair(edit.hash_range_incl)) {
152
+ if (!isStrPair(edit.hash_range_inclusive)) {
153
153
  throw new Error(
154
- `[E_BAD_SHAPE] Edit ${index} requires an "hash_range_incl" pair of anchor strings [start, end].`,
154
+ `[E_BAD_SHAPE] Edit ${index} requires an "hash_range_inclusive" pair of anchor strings [start, end].`,
155
155
  );
156
156
  }
157
157
  }
@@ -163,7 +163,7 @@ export function resEdits(edits: HTEdit[]): HEdit[] {
163
163
 
164
164
  const replaceLines = parseText(edit.content_lines);
165
165
  result.push({
166
- hash_range_incl: [parseHashRef(edit.hash_range_incl[0]), parseHashRef(edit.hash_range_incl[1])],
166
+ hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
167
167
  content_lines: replaceLines,
168
168
  });
169
169
  }
@@ -214,14 +214,39 @@ export function assertNoBarePrefix(
214
214
  : `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
215
215
 
216
216
  throw new Error(
217
- `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember: hash_range_incl uses hash anchors, content_lines uses file content only.`
217
+ `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember: hash_range_inclusive uses hash anchors, content_lines uses file content only.`
218
218
  );
219
219
  }
220
220
 
221
221
  export function descEdit(edit: RHEdit): string {
222
- return `replace ${edit.hash_range_incl[0].hash}-${edit.hash_range_incl[1].hash}`;
222
+ return `replace ${edit.hash_range_inclusive[0].hash}-${edit.hash_range_inclusive[1].hash}`;
223
223
  }
224
224
 
225
+ function checkBoundaryDup(
226
+ adjacentLine: string | undefined,
227
+ replacementEdge: string | undefined,
228
+ kind: "trailing" | "leading",
229
+ survivingLineIndex: number,
230
+ fileLines: string[],
231
+ editIndex: number,
232
+ ): BDupWarn | null {
233
+ if (
234
+ adjacentLine === undefined ||
235
+ replacementEdge === undefined ||
236
+ replacementEdge.length === 0 ||
237
+ replacementEdge !== adjacentLine
238
+ ) return null;
239
+ return {
240
+ kind,
241
+ survivingLineContent: adjacentLine,
242
+ survivingLineIndex,
243
+ occurrence: fileLines.slice(0, survivingLineIndex).filter((l) => l === adjacentLine).length,
244
+ replacementLineContent: replacementEdge,
245
+ editIndex,
246
+ };
247
+ }
248
+
249
+
225
250
  export function valEdits(
226
251
  edits: HEdit[],
227
252
  fileLines: string[],
@@ -245,53 +270,27 @@ export function valEdits(
245
270
 
246
271
  for (const edit of edits) {
247
272
  abortIf(signal);
248
- const startResolved = tryResolve(edit.hash_range_incl[0]);
249
- const endResolved = tryResolve(edit.hash_range_incl[1]);
273
+ const startResolved = tryResolve(edit.hash_range_inclusive[0]);
274
+ const endResolved = tryResolve(edit.hash_range_inclusive[1]);
250
275
  if (!startResolved || !endResolved) {
251
276
  continue;
252
277
  }
253
278
  if (startResolved.line > endResolved.line) {
254
279
  throw new Error(
255
- `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_incl[0].hash} and ${edit.hash_range_incl[1].hash}).`,
280
+ `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_range_inclusive[0].hash} and ${edit.hash_range_inclusive[1].hash}).`,
256
281
  );
257
282
  }
258
283
  const endLine = endResolved.line;
259
284
  const nextLine = fileLines[endLine];
260
285
  const replacementLastLine = edit.content_lines.at(-1);
261
- if (
262
- nextLine !== undefined &&
263
- replacementLastLine !== undefined &&
264
- replacementLastLine.length > 0 &&
265
- replacementLastLine === nextLine
266
- ) {
267
- boundaryWarnings.push({
268
- kind: "trailing",
269
- survivingLineContent: nextLine,
270
- survivingLineIndex: endLine,
271
- occurrence: fileLines.slice(0, endLine).filter(l => l === nextLine).length,
272
- replacementLineContent: replacementLastLine,
273
- editIndex: resolved.length,
274
- });
275
- }
286
+ const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine, fileLines, resolved.length);
287
+ if (trailing) boundaryWarnings.push(trailing);
276
288
  const prevLine = fileLines[startResolved.line - 2];
277
289
  const replacementFirstLine = edit.content_lines[0];
278
- if (
279
- prevLine !== undefined &&
280
- replacementFirstLine !== undefined &&
281
- replacementFirstLine.length > 0 &&
282
- replacementFirstLine === prevLine
283
- ) {
284
- boundaryWarnings.push({
285
- kind: "leading",
286
- survivingLineContent: prevLine,
287
- survivingLineIndex: startResolved.line - 2,
288
- occurrence: fileLines.slice(0, startResolved.line - 2).filter(l => l === prevLine).length,
289
- replacementLineContent: replacementFirstLine,
290
- editIndex: resolved.length,
291
- });
292
- }
290
+ const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, fileLines, resolved.length);
291
+ if (leading) boundaryWarnings.push(leading);
293
292
  resolved.push({
294
- hash_range_incl: [startResolved, endResolved],
293
+ hash_range_inclusive: [startResolved, endResolved],
295
294
  content_lines: edit.content_lines,
296
295
  });
297
296
  }
@@ -1,46 +1,12 @@
1
1
  import { isRec, has } from "./utils";
2
2
 
3
- function coerceChangesArray(changes: unknown): unknown {
4
- if (typeof changes !== "string") {
5
- return changes;
6
- }
3
+ function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | undefined {
4
+ if (typeof value !== "string") return undefined;
7
5
  try {
8
- const parsed: unknown = JSON.parse(changes);
9
- return Array.isArray(parsed) ? parsed : changes;
10
- } catch {
11
- return changes;
12
- }
13
- }
14
-
15
- function coerceContentLines(changes: unknown): unknown {
16
- if (!Array.isArray(changes)) return changes;
17
- return changes.map((change: unknown) => {
18
- if (!isRec(change)) return change;
19
- if (typeof change.content_lines !== "string") return change;
20
- try {
21
- const parsed: unknown = JSON.parse(change.content_lines);
22
- if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
23
- return { ...change, content_lines: parsed };
24
- }
25
- } catch {
26
- // not valid JSON, leave as-is for downstream validation
27
- }
28
- return change;
29
- });
30
- }
31
-
32
- function coerceChangeItems(changes: unknown): unknown {
33
- if (!Array.isArray(changes)) return changes;
34
- return changes.map((item: unknown) => {
35
- if (typeof item !== "string") return item;
36
- try {
37
- const parsed: unknown = JSON.parse(item);
38
- if (isRec(parsed)) return parsed;
39
- } catch {
40
- // not valid JSON, leave as-is for downstream validation
41
- }
42
- return item;
43
- });
6
+ const parsed: unknown = JSON.parse(value);
7
+ if (guard(parsed)) return parsed;
8
+ } catch {}
9
+ return undefined;
44
10
  }
45
11
 
46
12
  export function normReq(input: unknown): unknown {
@@ -59,14 +25,33 @@ export function normReq(input: unknown): unknown {
59
25
  const hasEditsField = has(record, "edits");
60
26
 
61
27
  if (hasChangesField) {
62
- record.changes = coerceChangesArray(record.changes);
63
- record.changes = coerceChangeItems(record.changes);
64
- record.changes = coerceContentLines(record.changes);
28
+ const raw = tryParseJSON(record.changes, Array.isArray) ?? record.changes;
29
+ if (Array.isArray(raw)) {
30
+ record.changes = raw
31
+ .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
32
+ .map((change: unknown) => {
33
+ if (!isRec(change)) return change;
34
+ if (typeof change.content_lines !== "string") return change;
35
+ const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
36
+ Array.isArray(v) && v.every((i) => typeof i === "string"),
37
+ );
38
+ return parsed ? { ...change, content_lines: parsed } : change;
39
+ });
40
+ }
65
41
  } else if (hasEditsField) {
66
- // Accept "edits" as an alias for "changes" for backward compatibility
67
- record.changes = coerceChangesArray(record.edits);
68
- record.changes = coerceChangeItems(record.changes);
69
- record.changes = coerceContentLines(record.changes);
42
+ const raw = tryParseJSON(record.edits, Array.isArray) ?? record.edits;
43
+ if (Array.isArray(raw)) {
44
+ record.changes = raw
45
+ .map((item: unknown) => tryParseJSON(item, isRec) ?? item)
46
+ .map((change: unknown) => {
47
+ if (!isRec(change)) return change;
48
+ if (typeof change.content_lines !== "string") return change;
49
+ const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
50
+ Array.isArray(v) && v.every((i) => typeof i === "string"),
51
+ );
52
+ return parsed ? { ...change, content_lines: parsed } : change;
53
+ });
54
+ }
70
55
  delete record.edits;
71
56
  }
72
57
 
package/src/replace.ts CHANGED
@@ -59,7 +59,7 @@ const hashRangeInclSchema = Type.Array(
59
59
 
60
60
  const changeItemSchema = Type.Object(
61
61
  {
62
- hash_range_incl: hashRangeInclSchema,
62
+ hash_range_inclusive: hashRangeInclSchema,
63
63
  content_lines: contentLinesSchema,
64
64
  },
65
65
  { additionalProperties: false },
@@ -86,6 +86,21 @@ export type ReplaceDetails = {
86
86
  metrics?: RMetrics;
87
87
  };
88
88
 
89
+ interface PipelineResult {
90
+ path: string;
91
+ toolEdits: HTEdit[];
92
+ originalNormalized: string;
93
+ result: string;
94
+ bom: string;
95
+ originalEnding: "\r\n" | "\n";
96
+ hadUtf8DecodeErrors: boolean;
97
+ warnings: string[];
98
+ noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
99
+ firstChangedLine?: number;
100
+ lastChangedLine?: number;
101
+ originalHashes?: string[];
102
+ }
103
+
89
104
  const E_DESC = loadP("../prompts/replace.md");
90
105
  const E_SNIPPET = loadP("../prompts/replace-snippet.md");
91
106
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
@@ -101,7 +116,7 @@ export function assertReq(
101
116
  for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
102
117
  if (has(request, legacyKey)) {
103
118
  throw new Error(
104
- `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_incl: ["<START>", "<END>"], content_lines: [...]}.`
119
+ `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
105
120
  );
106
121
  }
107
122
  }
@@ -113,7 +128,7 @@ export function assertReq(
113
128
  }
114
129
 
115
130
  if (!Array.isArray(request.changes)) {
116
- throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_incl: ["<START>", "<END>"], content_lines: [...] }.');
131
+ throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_inclusive: ["<START>", "<END>"], content_lines: [...] }.');
117
132
  }
118
133
  }
119
134
 
@@ -122,20 +137,7 @@ async function execPipeline(
122
137
  cwd: string,
123
138
  accessMode: number,
124
139
  signal?: AbortSignal,
125
- ): Promise<{
126
- path: string;
127
- toolEdits: HTEdit[];
128
- originalNormalized: string;
129
- result: string;
130
- bom: string;
131
- originalEnding: "\r\n" | "\n";
132
- hadUtf8DecodeErrors: boolean;
133
- warnings: string[];
134
- noopEdits?: { editIndex: number; loc: string; currentContent: string }[];
135
- firstChangedLine?: number;
136
- lastChangedLine?: number;
137
- originalHashes?: string[];
138
- }> {
140
+ ): Promise<PipelineResult> {
139
141
 
140
142
  const path = params.path;
141
143
  const toolEdits = Array.isArray(params.changes)
@@ -205,6 +207,21 @@ type ToolDef = ToolDefinition<
205
207
  ReplaceDetails,
206
208
  RRState
207
209
  > & { renderShell?: "default" | "self" };
210
+ function reuseText(context: any, content: string): Text {
211
+ const t = context.lastComponent instanceof Text
212
+ ? context.lastComponent
213
+ : new Text("", 0, 0);
214
+ t.setText(content);
215
+ return t;
216
+ }
217
+
218
+ function reuseMarkdown(context: any, content: string, theme: any): Markdown {
219
+ const m = context.lastComponent instanceof Markdown
220
+ ? context.lastComponent
221
+ : new Markdown("", 0, 0, mkMdTheme(theme));
222
+ m.setText(content);
223
+ return m;
224
+ }
208
225
 
209
226
  const toolDef: ToolDef = {
210
227
  name: "replace",
@@ -273,10 +290,7 @@ const toolDef: ToolDef = {
273
290
 
274
291
  renderResult(result, { isPartial }, theme, context) {
275
292
  if (isPartial) {
276
- const text =
277
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
278
- text.setText(theme.fg("warning", "Editing..."));
279
- return text;
293
+ return reuseText(context, theme.fg("warning", "Editing..."));
280
294
  }
281
295
 
282
296
  const typedResult = result as {
@@ -292,44 +306,18 @@ const toolDef: ToolDef = {
292
306
  }
293
307
 
294
308
  if (context.isError) {
295
- if (!renderedText) {
296
- return new Text("", 0, 0);
297
- }
298
- const text =
299
- context.lastComponent instanceof Text
300
- ? context.lastComponent
301
- : new Text("", 0, 0);
302
- text.setText(`\n${theme.fg("error", renderedText)}`);
303
- return text;
309
+ return renderedText
310
+ ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
311
+ : new Text("", 0, 0);
304
312
  }
305
313
 
306
314
  if (isApplied(typedResult.details)) {
307
- const appliedChangedText = buildAppliedText(
308
- renderedText,
309
- typedResult.details,
310
- theme,
311
- );
312
- if (!appliedChangedText) {
313
- return new Text("", 0, 0);
314
- }
315
- const text =
316
- context.lastComponent instanceof Text
317
- ? context.lastComponent
318
- : new Text("", 0, 0);
319
- text.setText(appliedChangedText);
320
- return text;
321
- }
322
-
323
- if (!renderedText) {
324
- return new Text("", 0, 0);
315
+ const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
316
+ return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
325
317
  }
326
318
 
327
- const markdown =
328
- context.lastComponent instanceof Markdown
329
- ? context.lastComponent
330
- : new Markdown("", 0, 0, mkMdTheme(theme));
331
- markdown.setText(fmtResultMd(renderedText));
332
- return markdown;
319
+ if (!renderedText) return new Text("", 0, 0);
320
+ return reuseMarkdown(context, fmtResultMd(renderedText), theme);
333
321
  },
334
322
 
335
323
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -353,7 +341,7 @@ const toolDef: ToolDef = {
353
341
  firstChangedLine,
354
342
  lastChangedLine,
355
343
  } = await execPipeline(
356
- normalized,
344
+ normalizedParams,
357
345
  ctx.cwd,
358
346
  constants.R_OK | constants.W_OK,
359
347
  signal,