pi-hashline-edit-pro 2.5.3 → 2.6.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
@@ -32,7 +32,7 @@ kQm│}
32
32
  "path": "src/main.ts",
33
33
  "remove_from": "szJ",
34
34
  "remove_to": "szJ",
35
- "replacement_text": " console.log('hi');"
35
+ "replacement_lines": [" console.log('hi');"]
36
36
  }
37
37
  ```
38
38
 
@@ -76,14 +76,14 @@ Edge cases:
76
76
 
77
77
  The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
78
78
 
79
- One edit per call, with `remove_from`, `remove_to`, and `replacement_text` at the top level:
79
+ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at the top level:
80
80
 
81
81
  ```json
82
82
  {
83
83
  "path": "src/main.ts",
84
84
  "remove_from": "szJ",
85
85
  "remove_to": "kQm",
86
- "replacement_text": " console.log('hi');\n}"
86
+ "replacement_lines": [" console.log('hi');", "}"]
87
87
  }
88
88
  ```
89
89
 
@@ -91,12 +91,12 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_text` at th
91
91
  | --- | --- |
92
92
  | `remove_from` | 3-char hash from `read` output marking the FIRST line to remove (inclusive). |
93
93
  | `remove_to` | 3-char hash from `read` output marking the LAST line to remove (inclusive). |
94
- | `replacement_text` | Replacement text as a single string with `\n` line separators; every `\n` separates lines, so a trailing `\n` adds a final empty line mirror the removed lines exactly, blank lines included (a replacement that is only blank lines is written as one `\n` per blank line). Use `""` to delete the range. |
94
+ | `replacement_lines` | Replacement lines as an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element each element is exactly one line. |
95
95
 
96
96
  Notes:
97
97
 
98
98
  - The request is checked before any file I/O, so a bad request never touches the file.
99
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_text` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
99
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `replacement_lines` or `remove_from`/`remove_to`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that re-include a block adjacent to the range are stripped automatically when that block is unique in the file — the whole run is stripped as one unit (including repeated structural lines like `}`), so re-including an unchanged block next to the range never duplicates it. A missing `path` is resolved from the anchors when they uniquely identify a file in the hash store (reported as a warning); when the anchors match multiple known files the request is rejected with the candidate paths named. `file_path` works as an alias for `path` in all three tools.
100
100
  - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
101
101
  - Every line in the removed range must match what was last shown to you. The extension records the `HASH│content` rows it serves — `read` output, the auto-read block after `write`, the `+HASH│`/` HASH│` rows of post-edit diffs (replace and undo), the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale/ambiguous-anchor feedback — and verifies the whole range against that record before writing. If an interior line changed on disk since it was shown (external editor, formatter-on-save, code generation) or was never shown, the edit is refused with `[E_RANGE_STALE]` and the current range is returned with fresh anchors, so the retry needs no `read`. Edits outside the served record are only possible for files that were never read (for example right after a `write` with auto-read disabled); once the file has been served, every replaced line must have been shown.
102
102
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
@@ -157,12 +157,12 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
157
157
 
158
158
  | Code | Meaning |
159
159
  | --- | --- |
160
- | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_text` must be a string with `\n` line separators). |
160
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `replacement_lines` must be an array of strings, one element per line). |
161
161
  | `[E_BAD_REF]` | An anchor in `remove_from`/`remove_to` is not a bare 3-char hash. |
162
162
  | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
163
163
  | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
164
- | `[E_INVALID_PATCH]` | A `replacement_text` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
165
- | `[E_BARE_HASH_PREFIX]` | A `replacement_text` line starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
164
+ | `[E_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
165
+ | `[E_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
166
166
  | `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
167
167
  | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
168
168
  | `[E_NOT_FOUND]` | The path does not exist. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -1,9 +1,8 @@
1
1
  - `replace`: remove_from and remove_to take ONLY the bare 3-char hash — read row `ve7│function hello() {` means `"remove_from": "ve7"`. Never paste the line content, a code line, a paragraph, or the whole `HASH│content` row into these fields.
2
- - `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_text is their complete replacement applied in order; nothing outside the range changes. Every line inside the range that is not reproduced byte-exact in replacement_text is deleted from the file.
2
+ - `replace`: remove_from and remove_to mark the exact lines that are REMOVED, and replacement_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 replacement_lines is deleted from the file.
3
3
  - `replace`: keep the range as tight as the change — anchor only the first and last line that actually change, never a whole function, class, or import block when only part of it changes.
4
4
  - `replace`: to replace a single line, set both remove_from and remove_to to the same hash: remove_from: "<HASH>", remove_to: "<HASH>".
5
5
  - `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
6
- - `replace`: every `\n` in replacement_text separates lines, so a trailing `\n` adds a final empty line. Mirror the removed lines exactly: a range that ends on a blank line must end replacement_text with `\n` (e.g. `"code\n"`), and a replacement whose last line is not blank must not end with `\n`. To add a blank line after a line, end replacement_text with an explicit empty line after it (e.g. `"X\n"` adds a blank after X). A replacement that is only blank lines is written as one `\n` per blank line.
6
+ - `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element each element is exactly one line.
7
7
  - `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.
8
- - `replace`: `[E_RANGE_STALE]` means a line inside the replaced range changed on disk after it was last shown (or was never shown). Nothing was modified; the error lists the current range as `HASH│content` rows, so retry with those anchors and no `read`.
9
8
  - `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
package/src/constants.ts CHANGED
@@ -6,6 +6,6 @@ export const MAX_RANGE_STALE_LINES = 100;
6
6
 
7
7
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
8
8
  export const HASH_STORE_VERSION = 5;
9
- export const NEW_CONTENT_NOT_STRING_MSG =
10
- `[E_BAD_SHAPE] "replacement_text" must be a string with \\n line separators, not an array.`
11
- + ` Do not pass an array of lines — pass the replacement text as one string: "line1\\nline2". Use "" to delete a range.`;
9
+ export const NEW_CONTENT_NOT_ARRAY_MSG =
10
+ `[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one element per line, not a single string.`
11
+ + ` Do not pass one string with \\n separators — pass an array of lines: ["line1", "line2"]. Use [] to delete a range.`;
package/src/file-kind.ts CHANGED
@@ -119,7 +119,9 @@ export async function loadFileKindAndText(
119
119
  description: detectedMimeType,
120
120
  };
121
121
  }
122
-
122
+ if (detectedMimeType === undefined && sample.includes(0)) {
123
+ return { kind: "binary", description: "contains NUL bytes" };
124
+ }
123
125
 
124
126
  const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
125
127
  let hadUtf8DecodeErrors = false;
@@ -258,7 +258,7 @@ function mapStableHashes(
258
258
 
259
259
  const removedByContent = new Map<string, { hashes: string[]; pos: number }>();
260
260
  for (const entry of removedEntries) {
261
- const key = canon(oldLines[entry.index]!);
261
+ const key = oldLines[entry.index]!;
262
262
  let queue = removedByContent.get(key);
263
263
  if (!queue) {
264
264
  queue = { hashes: [], pos: 0 };
@@ -269,7 +269,7 @@ function mapStableHashes(
269
269
 
270
270
  for (let i = 0; i < newLines.length; i++) {
271
271
  if (newHashes[i]) continue;
272
- const queue = removedByContent.get(canon(newLines[i]!));
272
+ const queue = removedByContent.get(newLines[i]!);
273
273
  if (!queue || queue.pos >= queue.hashes.length) continue;
274
274
  newHashes[i] = queue.hashes[queue.pos]!;
275
275
  queue.pos += 1;
@@ -2,7 +2,7 @@ import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
4
  } from "./hash";
5
- import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
5
+ import { NEW_CONTENT_NOT_ARRAY_MSG } from "../constants";
6
6
 
7
7
  export type Anchor = { hash: string };
8
8
 
@@ -39,12 +39,21 @@ function parseRef(ref: string): Anchor {
39
39
 
40
40
  export const parseHashRef = parseRef;
41
41
 
42
- export function parseText(edit: string): string[] {
43
- if (typeof edit !== "string") {
44
- throw new Error(NEW_CONTENT_NOT_STRING_MSG);
42
+ export function parseText(edit: string[], warnings?: string[]): string[] {
43
+ if (!Array.isArray(edit) || edit.some((line) => typeof line !== "string")) {
44
+ throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
45
45
  }
46
- const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
47
- if (normalized === "") return [];
48
- if (/^\n+$/.test(normalized)) return new Array(normalized.length).fill("");
49
- return normalized.split("\n");
46
+ const out: string[] = [];
47
+ let split = false;
48
+ for (const line of edit) {
49
+ const normalized = line.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
50
+ if (normalized !== line) split = true;
51
+ out.push(...normalized.split("\n"));
52
+ }
53
+ if (split) {
54
+ warnings?.push(
55
+ "[E_BAD_SHAPE] Autocorrected: split replacement_lines element(s) containing embedded newlines into separate lines.",
56
+ );
57
+ }
58
+ return out;
50
59
  }
@@ -1,7 +1,7 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
2
  import { HASH_CLASS, HASH_SEP, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
- import { NEW_CONTENT_NOT_STRING_MSG, MAX_RANGE_STALE_LINES } from "../constants";
4
+ import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
@@ -39,7 +39,7 @@ export interface NEdit {
39
39
  }
40
40
 
41
41
  export type HTEdit = {
42
- replacement_text: string;
42
+ replacement_lines: string[];
43
43
  remove_from: string;
44
44
  remove_to: string;
45
45
  };
@@ -134,10 +134,10 @@ export function fmtMismatchWithHashes(
134
134
  }
135
135
 
136
136
 
137
- const ITEM_KS = new Set(["replacement_text", "remove_from", "remove_to"]);
137
+ const ITEM_KS = new Set(["replacement_lines", "remove_from", "remove_to"]);
138
138
 
139
139
  function assertItem(edit: Record<string, unknown>): void {
140
- rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_text, remove_from, remove_to }.");
140
+ rejectUnknownFields(edit, ITEM_KS, "Edit", "The edit takes only { replacement_lines, remove_from, remove_to }.");
141
141
 
142
142
  if ("remove_from" in edit && typeof edit.remove_from !== "string") {
143
143
  throw new Error(
@@ -149,11 +149,11 @@ function assertItem(edit: Record<string, unknown>): void {
149
149
  `[E_BAD_SHAPE] Field "remove_to" must be an anchor string (3-char hash).`,
150
150
  );
151
151
  }
152
- if (!("replacement_text" in edit)) {
153
- throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_text" field. Provide the replacement text (use "" to delete).`);
152
+ if (!("replacement_lines" in edit)) {
153
+ throw new Error(`[E_BAD_SHAPE] The edit requires a "replacement_lines" field. Provide the replacement lines as an array of strings (use [] to delete).`);
154
154
  }
155
- if (typeof edit.replacement_text !== "string") {
156
- throw new Error(NEW_CONTENT_NOT_STRING_MSG);
155
+ if (!Array.isArray(edit.replacement_lines) || edit.replacement_lines.some((line) => typeof line !== "string")) {
156
+ throw new Error(NEW_CONTENT_NOT_ARRAY_MSG);
157
157
  }
158
158
  if (typeof edit.remove_from !== "string" || typeof edit.remove_to !== "string") {
159
159
  throw new Error(
@@ -167,7 +167,7 @@ const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
167
167
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
168
168
  assertItem(edit as Record<string, unknown>);
169
169
 
170
- const replaceLines = parseText(edit.replacement_text);
170
+ const replaceLines = parseText(edit.replacement_lines, warnings);
171
171
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
172
172
  const trimmed = ref.trim();
173
173
  const match = trimmed.match(ANCHOR_ROW_RE);
@@ -217,7 +217,7 @@ export function stripBarePrefixes(
217
217
  });
218
218
  if (stripped.length === 0) return edit;
219
219
  const locations = stripped
220
- .map((s) => `replacement_text line ${s.lineIndex + 1}`)
220
+ .map((s) => `replacement_lines line ${s.lineIndex + 1}`)
221
221
  .join(", ");
222
222
  const matchedCount = stripped.filter((s) => s.matched).length;
223
223
  const evidence =
@@ -253,7 +253,7 @@ export function stripDiffPrefixes(
253
253
  return line;
254
254
  });
255
255
  if (stripped.length === 0) return edit;
256
- const locations = stripped.map((i) => `replacement_text line ${i + 1}`).join(", ");
256
+ const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
257
257
  warnings.push(
258
258
  `[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
259
259
  );
@@ -455,11 +455,6 @@ export function valEdit(
455
455
  }
456
456
  return { resolved: undefined, mismatches, boundaryDups };
457
457
  }
458
- if (startResolved.line > endResolved.line) {
459
- throw new Error(
460
- `[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}).`,
461
- );
462
- }
463
458
  const endLine = endResolved.line;
464
459
  const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
465
460
  const canonLines = fileLines.map((line) => canon(line));
@@ -35,7 +35,8 @@ export function getPreviewInput(
35
35
  if (
36
36
  typeof normalized.remove_from !== "string" ||
37
37
  typeof normalized.remove_to !== "string" ||
38
- typeof normalized.replacement_text !== "string"
38
+ !Array.isArray(normalized.replacement_lines) ||
39
+ normalized.replacement_lines.some((line) => typeof line !== "string")
39
40
  ) {
40
41
  return null;
41
42
  }
@@ -44,7 +45,7 @@ export function getPreviewInput(
44
45
  path: normalized.path,
45
46
  remove_from: normalized.remove_from,
46
47
  remove_to: normalized.remove_to,
47
- replacement_text: normalized.replacement_text,
48
+ replacement_lines: normalized.replacement_lines,
48
49
  };
49
50
  return request;
50
51
  }
package/src/replace.ts CHANGED
@@ -48,10 +48,16 @@ import { saveUndo } from "./replace-undo";
48
48
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
49
49
  import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
50
50
 
51
- const replacementTextSchema = Type.String({
52
- description:
53
- "Replacement text as a single string with \\n line separators; every \\n separates lines, so a trailing \\n adds a final empty line. Mirror the removed lines exactly, blank lines included. A replacement that is only blank lines is written as one \\n per blank line. Use \"\" to delete the range."
54
- });
51
+ const replacementLinesSchema = Type.Array(
52
+ Type.String({
53
+ description:
54
+ "One replacement line. Each element is exactly one line; do not embed \\n inside an element — use separate elements.",
55
+ }),
56
+ {
57
+ description:
58
+ "Replacement lines as an array of strings, one element per line. Use [] to delete the range."
59
+ }
60
+ );
55
61
 
56
62
  const removeFromSchema = Type.String({
57
63
  description: "Bare 3-char HASH only (e.g. \"aB3\") — copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
@@ -66,7 +72,7 @@ export const editToolSchema = Type.Object(
66
72
  path: Type.Optional(Type.String({ description: "Path to edit. Required — always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
67
73
  remove_from: removeFromSchema,
68
74
  remove_to: removeToSchema,
69
- replacement_text: replacementTextSchema,
75
+ replacement_lines: replacementLinesSchema,
70
76
  },
71
77
  { additionalProperties: false },
72
78
  );
@@ -74,7 +80,7 @@ export type ReqParams = {
74
80
  path: string;
75
81
  remove_from: string;
76
82
  remove_to: string;
77
- replacement_text: string;
83
+ replacement_lines: string[];
78
84
  };
79
85
 
80
86
  export type ReplaceDetails = {
@@ -104,7 +110,7 @@ interface PipelineResult {
104
110
 
105
111
  const PREVIEW_DEBOUNCE_MS = 150;
106
112
 
107
- const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_text"]);
113
+ const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
108
114
 
109
115
  export function assertReq(
110
116
  request: unknown,
@@ -122,10 +128,11 @@ export function assertReq(
122
128
  if (
123
129
  typeof request.remove_from !== "string" ||
124
130
  typeof request.remove_to !== "string" ||
125
- typeof request.replacement_text !== "string"
131
+ !Array.isArray(request.replacement_lines) ||
132
+ request.replacement_lines.some((line) => typeof line !== "string")
126
133
  ) {
127
134
  throw new Error(
128
- '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_text" at the top level.',
135
+ '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" at the top level. replacement_lines must be an array of strings, one element per line (use [] to delete).',
129
136
  );
130
137
  }
131
138
  }
@@ -224,7 +231,7 @@ export async function execPipeline(
224
231
  {
225
232
  remove_from: params.remove_from,
226
233
  remove_to: params.remove_to,
227
- replacement_text: params.replacement_text,
234
+ replacement_lines: params.replacement_lines,
228
235
  },
229
236
  editWarnings,
230
237
  );