pi-hashline-edit-pro 1.2.1 → 2.0.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
@@ -42,8 +42,8 @@ kQm│}
42
42
  ```json
43
43
  {
44
44
  "path": "src/main.ts",
45
- "hash_range_inclusive": ["szJ", "szJ"],
46
- "content_lines": [" console.log('hi');"]
45
+ "hash_bounds": ["szJ", "szJ"],
46
+ "new_content": " console.log('hi');"
47
47
  }
48
48
  ```
49
49
 
@@ -77,31 +77,30 @@ Edge cases:
77
77
 
78
78
  The built-in `edit` tool is disabled — `replace` is the only edit path; call it with the hash anchors from `read` output.
79
79
 
80
- Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at the top level of the request:
80
+ Exactly one edit per call, with `hash_bounds` and `new_content` at the top level of the request:
81
81
 
82
82
  ```json
83
83
  {
84
84
  "path": "src/main.ts",
85
- "hash_range_inclusive": ["szJ", "kQm"],
86
- "content_lines": [" console.log('hi');", "}"]
85
+ "hash_bounds": ["szJ", "kQm"],
86
+ "new_content": " console.log('hi');\n}"
87
87
  }
88
88
  ```
89
89
 
90
90
  | Field | Description |
91
91
  | --- | --- |
92
- | `hash_range_inclusive` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
93
- | `content_lines` | Replacement content, one string per line; entries must not contain line breaks. Use `[]` to delete the range. |
92
+ | `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
93
+ | `new_content` | Replacement content as a single string with `\n` line separators; a trailing newline is the last line's ending, not an extra empty line. Use `""` to delete the range. |
94
94
 
95
95
  Behavior:
96
96
 
97
97
  - **Validation before any file I/O.** Unknown fields, missing fields, wrong types, and malformed anchors are rejected with `[E_BAD_SHAPE]` / `[E_BAD_REF]`. The edit applies against the pre-edit snapshot, so all hashes in the request come from one consistent file state.
98
- - **Rejected dialects.** The `changes` array dialect and the legacy `oldText`/`newText` dialect are rejected with `[E_LEGACY_SHAPE]`; the error tells you to send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
99
98
  - **Autocorrections** (all accompanied by a warning unless noted):
100
- - A `HASH│` prefix accidentally left on a `content_lines` entry is stripped.
101
- - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `content_lines` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
99
+ - A `HASH│` prefix accidentally left on a `new_content` line is stripped.
100
+ - Diff-preview rows (`+HASH│…`, `-HASH│…`, `- │…`) pasted into `new_content` have their markers stripped. Numbered deletion rows (`-1 foo`) and unified-diff lines are written literally — never silently altered.
102
101
  - A reversed range (start hash after end hash) is swapped and applied.
103
102
  - A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
104
- - `file_path` is accepted as an alias for `path`; a JSON-string `content_lines` is parsed into an array.
103
+ - `file_path` is accepted as an alias for `path`.
105
104
  - **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it — and reaches the model-visible text only while auto-read is on.
106
105
  - **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
107
106
 
@@ -157,13 +156,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
157
156
 
158
157
  | Code | Meaning |
159
158
  | --- | --- |
160
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields, or a `content_lines` entry contains a line break. |
161
- | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
159
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (e.g. `new_content` must be a string with `\n` line separators). |
160
+ | `[E_BAD_REF]` | An anchor in `hash_bounds` is not a bare 3-char hash. |
162
161
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
163
162
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
164
- | `[E_INVALID_PATCH]` | A `content_lines` entry is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
165
- | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
166
- | `[E_LEGACY_SHAPE]` | The request uses an unsupported dialect: `oldText`/`newText` fields or a `changes` array. |
163
+ | `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`) — the marker is stripped automatically with a warning. |
164
+ | `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix — the prefix is stripped automatically with a warning. |
167
165
  | `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
168
166
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
169
167
  | `[E_NOT_FOUND]` | The path does not exist. |
@@ -187,7 +185,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
187
185
  - **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
188
186
  - **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
189
187
  - **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
190
- - **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
188
+ - **One edit per call.** The request shape stays `{path, hash_bounds, new_content}` from schema through validation to application; there is no batching dialect.
191
189
 
192
190
  ## Troubleshooting
193
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "1.2.1",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
6
6
  "main": "index.ts",
@@ -1,7 +1,6 @@
1
- - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
2
- - `replace`: hash_range_inclusive marks the exact lines that are REMOVED, and content_lines is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in content_lines is deleted from the file — including closing braces and other structural lines.
1
+ - `replace`: hash_bounds marks the exact lines that are REMOVED, and new_content is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in new_content is deleted from the file — including closing braces and other structural lines.
3
2
  - `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
4
- - `replace`: to replace a single line, repeat its hash in both positions of hash_range_inclusive: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
5
- - `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
- - `replace`: content_lines entries are single lines never embed a line break inside an entry; pass each line as its own array entry.
3
+ - `replace`: to replace a single line, repeat its hash in both positions of hash_bounds: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
4
+ - `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
5
+ - `replace`: to add a blank line, end new_content with an explicit empty line (e.g. ending with \n\n).
7
6
  - `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading.
package/src/constants.ts CHANGED
@@ -5,6 +5,6 @@ export const MAX_READ_LINE_BYTES = 200 * 1024;
5
5
 
6
6
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
7
7
  export const HASH_STORE_VERSION = 5;
8
- export const CONTENT_LINES_NOT_STRING_MSG =
9
- `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
10
- + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
8
+ export const NEW_CONTENT_NOT_STRING_MSG =
9
+ `[E_BAD_SHAPE] "new_content" must be a string with \\n line separators, not an array.`
10
+ + ` Do not pass an array of lines — pass the replacement text as one string: "line1\\nline2". Use "" to delete a range.`;
package/src/hash-store.ts CHANGED
@@ -418,16 +418,32 @@ export function deleteUndo(store: HashStore, path: string): void {
418
418
  store.stmts.undoDelete(path);
419
419
  }
420
420
 
421
- export async function pruneMissing(store: HashStore): Promise<void> {
422
- const rows = store.stmts.allPaths() as { path: string }[];
421
+ const STAT_BATCH = 64;
422
+
423
+ async function statMissing(rows: { path: string }[]): Promise<string[]> {
423
424
  const missing: string[] = [];
424
- for (const row of rows) {
425
- try {
426
- await stat(row.path);
427
- } catch {
428
- missing.push(row.path);
425
+ for (let i = 0; i < rows.length; i += STAT_BATCH) {
426
+ const batch = rows.slice(i, i + STAT_BATCH);
427
+ const results = await Promise.all(
428
+ batch.map(async (row) => {
429
+ try {
430
+ await stat(row.path);
431
+ return undefined;
432
+ } catch {
433
+ return row.path;
434
+ }
435
+ }),
436
+ );
437
+ for (const path of results) {
438
+ if (path !== undefined) missing.push(path);
429
439
  }
430
440
  }
441
+ return missing;
442
+ }
443
+
444
+ export async function pruneMissing(store: HashStore): Promise<void> {
445
+ const rows = store.stmts.allPaths() as { path: string }[];
446
+ const missing = await statMissing(rows);
431
447
  if (missing.length === 0) return;
432
448
  withStore(() => {
433
449
  for (const path of missing) {
@@ -65,8 +65,8 @@ function resToSpan(
65
65
  ): RESpan | NoopSpan {
66
66
  const { fileLines, lineStarts } = lineIndex;
67
67
 
68
- const startLine = edit.hash_range_inclusive[0].line;
69
- const endLine = edit.hash_range_inclusive[1].line;
68
+ const startLine = edit.hash_bounds[0].line;
69
+ const endLine = edit.hash_bounds[1].line;
70
70
  const originalLines = fileLines.slice(startLine - 1, endLine);
71
71
  if (
72
72
  originalLines.length === edit.content_lines.length &&
@@ -76,7 +76,7 @@ function resToSpan(
76
76
  ) {
77
77
  return {
78
78
  kind: "noop",
79
- loc: edit.hash_range_inclusive[0].hash,
79
+ loc: edit.hash_bounds[0].hash,
80
80
  currentContent: originalLines.join("\n"),
81
81
  };
82
82
  }
@@ -117,9 +117,13 @@ function resToSpan(
117
117
  };
118
118
  }
119
119
 
120
+ const prevLine = startLine >= 2 ? fileLines[startLine - 2] : undefined;
120
121
  return {
121
122
  kind: "replace",
122
- start: Math.max(0, lineStarts[startLine - 1]! - 1),
123
+ start:
124
+ prevLine !== undefined && prevLine.length === 0
125
+ ? lineStarts[startLine - 1]!
126
+ : Math.max(0, lineStarts[startLine - 1]! - 1),
123
127
  end: content.length,
124
128
  replacement: "",
125
129
  };
@@ -2,7 +2,7 @@ import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
4
  } from "./hash";
5
- import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
5
+ import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
6
6
 
7
7
  export type Anchor = { hash: string };
8
8
 
@@ -18,7 +18,7 @@ function diagRef(ref: string): string {
18
18
  }
19
19
 
20
20
  if (trimmed.includes("│")) {
21
- return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_range_inclusive must contain the 3-char hash only — remove everything from "│" onward.`;
21
+ return `[E_BAD_REF] Invalid anchor "${trimmed}". hash_bounds must contain the 3-char hash only — remove everything from "│" onward.`;
22
22
  }
23
23
 
24
24
  return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a 3-char alphanumeric anchor (e.g. "aB3").`;
@@ -39,18 +39,13 @@ function parseRef(ref: string): Anchor {
39
39
 
40
40
  export const parseHashRef = parseRef;
41
41
 
42
- export function parseText(edit: string[] | string | null): string[] {
43
- if (edit === null) {
44
- throw new Error('[E_BAD_SHAPE] "content_lines" must be a string array; use [] to delete a range.');
42
+ export function parseText(edit: string): string[] {
43
+ if (typeof edit !== "string") {
44
+ throw new Error(NEW_CONTENT_NOT_STRING_MSG);
45
45
  }
46
- if (typeof edit === "string") {
47
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
48
- }
49
- const lineBreakIndex = edit.findIndex((line) => /[\r\n]/.test(line));
50
- if (lineBreakIndex >= 0) {
51
- throw new Error(
52
- `[E_BAD_SHAPE] "content_lines" entry at index ${lineBreakIndex} contains a \\r or \\n line break. Pass each line as its own array entry.`,
53
- );
54
- }
55
- return edit;
46
+ const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
47
+ if (normalized === "") return [];
48
+ const lines = normalized.split("\n");
49
+ if (normalized.endsWith("\n")) lines.pop();
50
+ return lines;
56
51
  }
@@ -1,7 +1,7 @@
1
1
  import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
2
2
  import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
- import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
4
+ import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
5
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
@@ -9,10 +9,10 @@ export type RAnchor = {
9
9
  hashMatched: boolean;
10
10
  };
11
11
 
12
- export type HEdit = { content_lines: string[]; hash_range_inclusive: [Anchor, Anchor] };
12
+ export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
13
13
  export type RHEdit = {
14
14
  content_lines: string[];
15
- hash_range_inclusive: [RAnchor, RAnchor];
15
+ hash_bounds: [RAnchor, RAnchor];
16
16
  };
17
17
 
18
18
  interface HMismatch {
@@ -40,8 +40,8 @@ export interface NEdit {
40
40
  }
41
41
 
42
42
  export type HTEdit = {
43
- content_lines: string[];
44
- hash_range_inclusive: [string, string];
43
+ new_content: string;
44
+ hash_bounds: [string, string];
45
45
  };
46
46
 
47
47
  function resAnchorFromMap(
@@ -89,7 +89,7 @@ export function fmtMismatch(
89
89
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
90
90
  if (notFound.length > 0) {
91
91
  out.push(
92
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were 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 of your next replace call.`
92
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. The file content has changed since those anchors were 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_bounds of your next replace call.`
93
93
  );
94
94
  for (const m of notFound) {
95
95
  const ctx = m.context;
@@ -107,7 +107,7 @@ export function fmtMismatch(
107
107
  if (ambiguous.length > 0) {
108
108
  if (out.length > 0) out.push("");
109
109
  out.push(
110
- `[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.`
110
+ `[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_bounds of your next replace call.`
111
111
  );
112
112
  for (const m of ambiguous) {
113
113
  const sample = (m.candidates ?? []).slice(0, 5);
@@ -130,13 +130,7 @@ export function fmtMismatch(
130
130
  return out.join("\n");
131
131
  }
132
132
 
133
- const ITEM_KS = new Set(["content_lines", "hash_range_inclusive"]);
134
-
135
- function isStrArr(value: unknown): value is string[] {
136
- return (
137
- Array.isArray(value) && value.every((item) => typeof item === "string")
138
- );
139
- }
133
+ const ITEM_KS = new Set(["new_content", "hash_bounds"]);
140
134
 
141
135
  function isStrPair(value: unknown): value is [string, string] {
142
136
  return (
@@ -147,36 +141,22 @@ function isStrPair(value: unknown): value is [string, string] {
147
141
  }
148
142
 
149
143
  function assertItem(edit: Record<string, unknown>): void {
150
- rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { content_lines, hash_range_inclusive }.");
144
+ rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { new_content, hash_bounds }.");
151
145
 
152
- if ("hash_range_inclusive" in edit && !isStrPair(edit.hash_range_inclusive)) {
146
+ if ("hash_bounds" in edit && !isStrPair(edit.hash_bounds)) {
153
147
  throw new Error(
154
- `[E_BAD_SHAPE] Field "hash_range_inclusive" must be a pair of anchor strings [start, end].`,
148
+ `[E_BAD_SHAPE] Field "hash_bounds" must be a pair of anchor strings [start, end].`,
155
149
  );
156
150
  }
157
- if (!("content_lines" in edit)) {
158
- throw new Error(`[E_BAD_SHAPE] The edit requires a "content_lines" field. Provide the replacement lines (use [] to delete).`);
151
+ if (!("new_content" in edit)) {
152
+ throw new Error(`[E_BAD_SHAPE] The edit requires a "new_content" field. Provide the replacement text (use "" to delete).`);
159
153
  }
160
- if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
161
- const val = edit.content_lines;
162
- if (typeof val === "string") {
163
- try {
164
- const parsed = JSON.parse(val);
165
- if (Array.isArray(parsed)) {
166
- edit.content_lines = parsed;
167
- } else {
168
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
169
- }
170
- } catch {
171
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
172
- }
173
- } else {
174
- throw new Error(`[E_BAD_SHAPE] Field "content_lines" must be a string array.`);
175
- }
154
+ if (typeof edit.new_content !== "string") {
155
+ throw new Error(NEW_CONTENT_NOT_STRING_MSG);
176
156
  }
177
- if (!isStrPair(edit.hash_range_inclusive)) {
157
+ if (!isStrPair(edit.hash_bounds)) {
178
158
  throw new Error(
179
- `[E_BAD_SHAPE] The edit requires a "hash_range_inclusive" pair of anchor strings [start, end].`,
159
+ `[E_BAD_SHAPE] The edit requires a "hash_bounds" pair of anchor strings [start, end].`,
180
160
  );
181
161
  }
182
162
  }
@@ -184,10 +164,10 @@ function assertItem(edit: Record<string, unknown>): void {
184
164
  export function resEdit(edit: HTEdit): HEdit {
185
165
  assertItem(edit as Record<string, unknown>);
186
166
 
187
- const replaceLines = parseText(edit.content_lines);
167
+ const replaceLines = parseText(edit.new_content);
188
168
  return {
189
169
  content_lines: replaceLines,
190
- hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
170
+ hash_bounds: [parseHashRef(edit.hash_bounds[0]), parseHashRef(edit.hash_bounds[1])],
191
171
  };
192
172
  }
193
173
 
@@ -217,7 +197,7 @@ export function stripBarePrefixes(
217
197
  });
218
198
  if (stripped.length === 0) return edit;
219
199
  const locations = stripped
220
- .map((s) => `content_lines[${s.lineIndex}]`)
200
+ .map((s) => `new_content line ${s.lineIndex + 1}`)
221
201
  .join(", ");
222
202
  const matchedCount = stripped.filter((s) => s.matched).length;
223
203
  const evidence =
@@ -253,7 +233,7 @@ export function stripDiffPrefixes(
253
233
  return line;
254
234
  });
255
235
  if (stripped.length === 0) return edit;
256
- const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
236
+ const locations = stripped.map((i) => `new_content line ${i + 1}`).join(", ");
257
237
  warnings.push(
258
238
  `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
259
239
  );
@@ -269,7 +249,7 @@ export function swapReversedRanges(
269
249
  for (let i = 0; i < fileHashes.length; i++) {
270
250
  lineByHash.set(fileHashes[i]!, i + 1);
271
251
  }
272
- const [startRef, endRef] = edit.hash_range_inclusive;
252
+ const [startRef, endRef] = edit.hash_bounds;
273
253
  const startLine = lineByHash.get(startRef.hash);
274
254
  const endLine = lineByHash.get(endRef.hash);
275
255
  if (
@@ -280,9 +260,9 @@ export function swapReversedRanges(
280
260
  return edit;
281
261
  }
282
262
  warnings.push(
283
- `[E_BAD_OP] Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
263
+ `[E_BAD_OP] Autocorrected: hash_bounds was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
284
264
  );
285
- return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
265
+ return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
286
266
  }
287
267
 
288
268
  function checkBoundaryDup(
@@ -334,21 +314,21 @@ export function valEdit(
334
314
  };
335
315
 
336
316
  abortIf(signal);
337
- const startResolved = tryResolve(edit.hash_range_inclusive[0]);
338
- const endResolved = tryResolve(edit.hash_range_inclusive[1]);
317
+ const startResolved = tryResolve(edit.hash_bounds[0]);
318
+ const endResolved = tryResolve(edit.hash_bounds[1]);
339
319
  if (!startResolved || !endResolved) {
340
320
  if (!startResolved && endResolved) {
341
- const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_range_inclusive[0]);
321
+ const startMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[0]);
342
322
  if (startMismatch && startMismatch.kind === "not_found") startMismatch.context = endResolved;
343
323
  } else if (startResolved && !endResolved) {
344
- const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_range_inclusive[1]);
324
+ const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[1]);
345
325
  if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
346
326
  }
347
327
  return { resolved: undefined, mismatches, boundaryWarnings };
348
328
  }
349
329
  if (startResolved.line > endResolved.line) {
350
330
  throw new Error(
351
- `[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}).`,
331
+ `[E_BAD_OP] Range start line ${startResolved.line} must be <= end line ${endResolved.line} (anchors ${edit.hash_bounds[0].hash} and ${edit.hash_bounds[1].hash}).`,
352
332
  );
353
333
  }
354
334
  const endLine = endResolved.line;
@@ -364,7 +344,7 @@ export function valEdit(
364
344
  return {
365
345
  resolved: {
366
346
  content_lines: edit.content_lines,
367
- hash_range_inclusive: [startResolved, endResolved],
347
+ hash_bounds: [startResolved, endResolved],
368
348
  },
369
349
  mismatches,
370
350
  boundaryWarnings,
@@ -1,19 +1,4 @@
1
- import { isRec, has } from "./utils";
2
- import { CONTENT_LINES_NOT_STRING_MSG } from "./constants";
3
-
4
- export function tryParseContentLines(record: Record<string, unknown>, key: string): void {
5
- const val = record[key];
6
- if (typeof val !== "string") return;
7
- try {
8
- const parsed = JSON.parse(val);
9
- if (Array.isArray(parsed)) {
10
- record[key] = parsed;
11
- return;
12
- }
13
- } catch {
14
- }
15
- throw new Error(CONTENT_LINES_NOT_STRING_MSG);
16
- }
1
+ import { isRec } from "./utils";
17
2
 
18
3
  export function normalizeFilePath(record: Record<string, unknown>): void {
19
4
  if (typeof record.path !== "string" && typeof record.file_path === "string") {
@@ -31,9 +16,5 @@ export function normReq(input: unknown): unknown {
31
16
 
32
17
  normalizeFilePath(record);
33
18
 
34
- if (has(record, "content_lines") && typeof record.content_lines === "string") {
35
- tryParseContentLines(record, "content_lines");
36
- }
37
-
38
19
  return record;
39
20
  }
@@ -32,18 +32,14 @@ export function getPreviewInput(
32
32
  return null;
33
33
  }
34
34
 
35
- if (Array.isArray(normalized.changes)) {
36
- return { path: normalized.path, hash_range_inclusive: ["", ""] as [string, string], content_lines: [] };
37
- }
38
-
39
- if (!Array.isArray(normalized.hash_range_inclusive) || !Array.isArray(normalized.content_lines)) {
35
+ if (!Array.isArray(normalized.hash_bounds) || typeof normalized.new_content !== "string") {
40
36
  return null;
41
37
  }
42
38
 
43
39
  const request: ReqParams = {
44
40
  path: normalized.path,
45
- hash_range_inclusive: normalized.hash_range_inclusive as [string, string],
46
- content_lines: normalized.content_lines as string[],
41
+ hash_bounds: normalized.hash_bounds as [string, string],
42
+ new_content: normalized.new_content,
47
43
  };
48
44
  return request;
49
45
  }
package/src/replace.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  type LineEnding,
13
13
  } from "./replace-diff";
14
14
  import { readNormFile } from "./file-reader";
15
- import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
16
- import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
15
+ import { normReq, normalizeFilePath } from "./replace-normalize";
16
+ import { isRec, rejectUnknownFields, abortIf } from "./utils";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import { applyEdit,
19
19
  lineHashes,
@@ -45,12 +45,12 @@ import { loadP, loadGuide } from "./prompts";
45
45
  import { saveUndo } from "./replace-undo";
46
46
  import { loadHashStore, type HashStore } from "./hash-store";
47
47
 
48
- const contentLinesSchema = Type.Array(Type.String(), {
48
+ const newContentSchema = Type.String({
49
49
  description:
50
- "Replacement content, one string per line; entries must not contain line breaks. Use [] to delete the range."
50
+ "Replacement content as a single string with \\n line separators; a trailing newline is the last line's ending, not an extra empty line. Use \"\" to delete the range."
51
51
  });
52
52
 
53
- const hashRangeInclSchema = Type.Array(
53
+ const hashBoundsSchema = Type.Array(
54
54
  Type.String({ description: "A 3-char HASH from read output" }),
55
55
  {
56
56
  description: "Pair of 3-char hashes from read output marking the first and last line of the range to replace (inclusive).",
@@ -62,15 +62,15 @@ const hashRangeInclSchema = Type.Array(
62
62
  export const editToolSchema = Type.Object(
63
63
  {
64
64
  path: Type.String({ description: "Path to edit" }),
65
- hash_range_inclusive: hashRangeInclSchema,
66
- content_lines: contentLinesSchema,
65
+ hash_bounds: hashBoundsSchema,
66
+ new_content: newContentSchema,
67
67
  },
68
68
  { additionalProperties: false },
69
69
  );
70
70
  export type ReqParams = {
71
71
  path: string;
72
- hash_range_inclusive: [string, string];
73
- content_lines: string[];
72
+ hash_bounds: [string, string];
73
+ new_content: string;
74
74
  };
75
75
 
76
76
  export type ReplaceDetails = {
@@ -100,20 +100,7 @@ interface PipelineResult {
100
100
 
101
101
  const PREVIEW_DEBOUNCE_MS = 150;
102
102
 
103
- const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
104
-
105
- const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
106
-
107
- export function assertNoLegacyKeys(request: unknown): void {
108
- if (!isRec(request)) return;
109
- for (const legacyKey of LEGACY_KS) {
110
- if (has(request, legacyKey)) {
111
- throw new Error(
112
- `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
113
- );
114
- }
115
- }
116
- }
103
+ const ROOT_KS = new Set(["path", "new_content", "hash_bounds"]);
117
104
 
118
105
  export function assertReq(
119
106
  request: unknown,
@@ -122,17 +109,15 @@ export function assertReq(
122
109
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
123
110
  }
124
111
 
125
- assertNoLegacyKeys(request);
126
-
127
112
  rejectUnknownFields(request, ROOT_KS, "Edit request");
128
113
 
129
114
  if (typeof request.path !== "string" || request.path.length === 0) {
130
115
  throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
131
116
  }
132
117
 
133
- if (!Array.isArray(request.hash_range_inclusive) || !Array.isArray(request.content_lines)) {
118
+ if (!Array.isArray(request.hash_bounds) || typeof request.new_content !== "string") {
134
119
  throw new Error(
135
- '[E_BAD_SHAPE] Edit request requires both "hash_range_inclusive" and "content_lines" at the top level.',
120
+ '[E_BAD_SHAPE] Edit request requires both "hash_bounds" and "new_content" at the top level.',
136
121
  );
137
122
  }
138
123
  }
@@ -149,8 +134,8 @@ function collectRemovedHashes(
149
134
  originalHashes: string[],
150
135
  ): Set<string> {
151
136
  const removedHashes = new Set<string>();
152
- const startHash = edit.hash_range_inclusive[0].hash;
153
- const endHash = edit.hash_range_inclusive[1].hash;
137
+ const startHash = edit.hash_bounds[0].hash;
138
+ const endHash = edit.hash_bounds[1].hash;
154
139
  const startLine = originalHashes.indexOf(startHash);
155
140
  const endLine = originalHashes.indexOf(endHash);
156
141
  if (startLine >= 0 && endLine >= 0) {
@@ -171,8 +156,8 @@ function countLineChanges(
171
156
  ): { totalAddedLines: number; totalRemovedLines: number } {
172
157
  if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
173
158
  let totalRemovedLines = 0;
174
- const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
175
- const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
159
+ const startLine = originalHashes.indexOf(edit.hash_bounds[0].hash);
160
+ const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
176
161
  if (startLine >= 0 && endLine >= 0) {
177
162
  totalRemovedLines = Math.abs(endLine - startLine) + 1;
178
163
  }
@@ -191,8 +176,8 @@ export async function execPipeline(
191
176
  const path = params.path;
192
177
 
193
178
  const edit = resEdit({
194
- hash_range_inclusive: params.hash_range_inclusive,
195
- content_lines: params.content_lines,
179
+ hash_bounds: params.hash_bounds,
180
+ new_content: params.new_content,
196
181
  });
197
182
 
198
183
  const hashStore = options?.store ?? await loadHashStore();
@@ -307,13 +292,9 @@ export function buildToolDef(): ToolDef {
307
292
  promptSnippet: E_SNIPPET,
308
293
  promptGuidelines: E_GUIDE,
309
294
  prepareArguments: (args: unknown) => {
310
- assertNoLegacyKeys(args);
311
295
  if (!isRec(args)) return args as any;
312
296
  const record = { ...args };
313
297
  normalizeFilePath(record);
314
- if (has(record, "content_lines") && typeof record.content_lines === "string") {
315
- tryParseContentLines(record, "content_lines");
316
- }
317
298
  return record;
318
299
  },
319
300
  renderShell: "default",