pi-hashline-edit-pro 0.16.4 → 0.16.6

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
@@ -143,7 +143,7 @@ The file is created automatically when any setting is toggled. Both fields are i
143
143
  - **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.
144
144
  - **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.
145
145
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
146
- - **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning is a short header followed by a hashline-anchored window: the duplicated pair plus 2 lines of context before and after, shown as plain `HASH│content` rows with post-edit hashes. Seeing the duplication in context (rather than just being told a hash) is what prompts the model to act on it and since the hashes are current and staleness is per-line, the model can remove the duplicate in one follow-up `replace` without re-reading. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
146
+ - **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
147
147
  - **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
148
148
  - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines.
149
149
  ## Hashing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
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": {
@@ -3,4 +3,4 @@
3
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
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.
5
5
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
- - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` the original is still in the file outside the range.
6
+ - **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
@@ -80,6 +80,18 @@ Right:
80
80
  ```json
81
81
  { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
82
82
 
83
+ ⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
84
+
85
+ Wrong:
86
+ ```json
87
+ { "changes": [{ "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"] }], "path": "src/main.ts" }
88
+
89
+ Right:
90
+ ```json
91
+ { "changes": [{ "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"] }], "path": "src/main.ts" }
92
+
93
+ `content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
94
+
83
95
  Rules:
84
96
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
85
97
  - To delete a range, use `content_lines: []`.
@@ -3,4 +3,4 @@
3
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
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.
5
5
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
- - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` the original is still in the file outside the range.
6
+ - **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
@@ -65,6 +65,18 @@ Right:
65
65
  ```json
66
66
  { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
67
67
 
68
+ ⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
69
+
70
+ Wrong:
71
+ ```json
72
+ { "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"], "path": "src/main.ts" }
73
+
74
+ Right:
75
+ ```json
76
+ { "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"], "path": "src/main.ts" }
77
+
78
+ `content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
79
+
68
80
  Rules:
69
81
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
70
82
  - To delete a range, use `content_lines: []`.
@@ -3,5 +3,5 @@
3
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
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.
5
5
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
- - Two modes are available: bulk (default, uses `changes` array) and flat (top-level `hash_range_inclusive`/`content_lines`). Toggle with `/toggle-replace-mode`.
7
- - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` the original is still in the file outside the range.
6
+ - **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
7
+ - Two modes are available: bulk (default, uses `changes` array) and flat (top-level `hash_range_inclusive`/`content_lines`). Toggle with `/toggle-replace-mode`.
@@ -127,6 +127,18 @@ Right:
127
127
  ```json
128
128
  { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
129
129
 
130
+ ⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
131
+
132
+ Wrong:
133
+ ```json
134
+ { "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"] }
135
+
136
+ Right:
137
+ ```json
138
+ { "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"] }
139
+
140
+ `content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
141
+
130
142
  Rules:
131
143
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
132
144
  - To delete a range, use `content_lines: []`.
@@ -10,6 +10,7 @@ import {
10
10
  type NEdit,
11
11
  type HEdit,
12
12
  type BDupWarn,
13
+ type AutoFix,
13
14
  } from "./resolve";
14
15
  import { cntLines } from "../utils";
15
16
 
@@ -262,7 +263,7 @@ export function applyEdits(
262
263
  lastChangedLine: number | undefined;
263
264
  warnings?: string[];
264
265
  noopEdits?: NEdit[];
265
- boundaryWarnings?: BDupWarn[];
266
+ autoFixes?: AutoFix[];
266
267
  } {
267
268
  abortIf(signal);
268
269
  if (!edits.length)
@@ -272,13 +273,12 @@ export function applyEdits(
272
273
  lastChangedLine: undefined,
273
274
  };
274
275
 
275
-
276
276
  const lineIndex = buildIdx(content);
277
277
  const fileHashes = precomputedHashes ?? _lineHashesPure(content);
278
278
  const noopEdits: NEdit[] = [];
279
279
  const warnings: string[] = [];
280
280
 
281
- const { resolved, mismatches, boundaryWarnings } = valEdits(
281
+ const { resolved: initialResolved, mismatches, boundaryWarnings } = valEdits(
282
282
  edits,
283
283
  lineIndex.fileLines,
284
284
  fileHashes,
@@ -294,6 +294,47 @@ export function applyEdits(
294
294
  assertNoBarePrefix(edits, lineIndex.fileLines, fileHashes);
295
295
  warnUnicodeEsc(edits, warnings);
296
296
 
297
+ // Auto-fix boundary duplications: strip the offending line from content_lines
298
+ let resolved = initialResolved;
299
+ let autoFixes: AutoFix[] | undefined;
300
+ if (boundaryWarnings.length > 0) {
301
+ autoFixes = [];
302
+ // Deep-copy edits so we don't mutate the originals
303
+ const correctedEdits: import("./resolve").HEdit[] = edits.map(e => ({
304
+ ...e,
305
+ content_lines: [...e.content_lines],
306
+ }));
307
+ for (const bw of boundaryWarnings) {
308
+ const edit = correctedEdits[bw.editIndex];
309
+ if (!edit) continue;
310
+ if (bw.kind === "trailing") {
311
+ const removed = edit.content_lines.pop();
312
+ if (removed !== undefined) {
313
+ autoFixes.push({ kind: "trailing", editIndex: bw.editIndex, removedLine: removed });
314
+ }
315
+ } else {
316
+ const removed = edit.content_lines.shift();
317
+ if (removed !== undefined) {
318
+ autoFixes.push({ kind: "leading", editIndex: bw.editIndex, removedLine: removed });
319
+ }
320
+ }
321
+ }
322
+ // Re-validate with corrected edits
323
+ const correctedResult = valEdits(
324
+ correctedEdits,
325
+ lineIndex.fileLines,
326
+ fileHashes,
327
+ warnings,
328
+ signal,
329
+ );
330
+ if (correctedResult.mismatches.length) {
331
+ throw new Error(
332
+ fmtMismatch(correctedResult.mismatches, lineIndex.fileLines, fileHashes, filePath),
333
+ );
334
+ }
335
+ resolved = correctedResult.resolved;
336
+ }
337
+
297
338
  const orderedSpans = resSpans(
298
339
  resolved,
299
340
  content,
@@ -312,7 +353,7 @@ export function applyEdits(
312
353
  lastChangedLine: range?.lastChangedLine,
313
354
  ...(warnings.length ? { warnings } : {}),
314
355
  ...(noopEdits.length ? { noopEdits } : {}),
315
- ...(boundaryWarnings.length ? { boundaryWarnings } : {}),
356
+ ...(autoFixes ? { autoFixes } : {}),
316
357
  };
317
358
  }
318
359
 
@@ -25,13 +25,13 @@ export {
25
25
  type HTEdit,
26
26
  type NEdit,
27
27
  type BDupWarn,
28
+ type AutoFix,
28
29
  descEdit,
29
30
  resEdits,
30
31
  valEdits,
31
32
  assertNoBarePrefix,
32
33
  fmtMismatch,
33
34
  } from "./resolve";
34
-
35
35
  export {
36
36
  buildIdx,
37
37
  applyEdits,
@@ -56,13 +56,13 @@ function assertNoPrefixes(lines: string[]): void {
56
56
  }
57
57
 
58
58
  export function parseText(edit: string[] | string | null): string[] {
59
- if (edit === null) return [];
60
- const lines =
61
- typeof edit === "string"
62
- ? (edit.endsWith("\n") ? edit.slice(0, -1) : edit)
63
- .replaceAll("\r", "")
64
- .split("\n")
65
- : edit;
66
- assertNoPrefixes(lines);
67
- return lines;
59
+ if (edit === null) return [];
60
+ if (typeof edit === "string") {
61
+ throw new Error(
62
+ `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
63
+ + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
64
+ );
65
+ }
66
+ assertNoPrefixes(edit);
67
+ return edit;
68
68
  }
@@ -30,6 +30,13 @@ export interface BDupWarn {
30
30
  editIndex: number;
31
31
  }
32
32
 
33
+ export interface AutoFix {
34
+ kind: "trailing" | "leading";
35
+ editIndex: number;
36
+ removedLine: string;
37
+ }
38
+
39
+
33
40
  export interface NEdit {
34
41
  editIndex: number;
35
42
  loc: string;
@@ -148,6 +155,13 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
148
155
  throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "content_lines" field. Provide the replacement lines (use [] to delete).`);
149
156
  }
150
157
  if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
158
+ const val = edit.content_lines;
159
+ if (typeof val === "string") {
160
+ throw new Error(
161
+ `[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a native JSON array of strings, not a JSON string.`
162
+ + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
163
+ );
164
+ }
151
165
  throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
152
166
  }
153
167
  if (!isStrPair(edit.hash_range_inclusive)) {
@@ -1,5 +1,17 @@
1
1
  import { isRec, has } from "./utils";
2
2
 
3
+ function assertContentLinesNotString(
4
+ value: unknown,
5
+ label: string,
6
+ ): void {
7
+ if (typeof value === "string") {
8
+ throw new Error(
9
+ `[E_BAD_SHAPE] ${label}: "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"].`
11
+ );
12
+ }
13
+ }
14
+
3
15
  export function normalizeFilePath(record: Record<string, unknown>): void {
4
16
  if (typeof record.path !== "string" && typeof record.file_path === "string") {
5
17
  record.path = record.file_path;
@@ -31,9 +43,24 @@ export function normReq(input: unknown): unknown {
31
43
 
32
44
  normalizeFilePath(record);
33
45
 
46
+ // Early validation: reject string-typed content_lines at the top level
47
+ if (has(record, "content_lines") && typeof record.content_lines === "string") {
48
+ assertContentLinesNotString(record.content_lines, "Top-level");
49
+ }
50
+
34
51
  normalizeField(record, "changes", "changes");
35
52
  normalizeField(record, "edits", "changes");
36
53
 
54
+ // Validate items in the changes array before wrapping flat format
55
+ if (Array.isArray(record.changes)) {
56
+ for (let i = 0; i < record.changes.length; i++) {
57
+ const item = record.changes[i];
58
+ if (isRec(item) && has(item, "content_lines") && typeof item.content_lines === "string") {
59
+ assertContentLinesNotString(item.content_lines, `changes[${i}]`);
60
+ }
61
+ }
62
+ }
63
+
37
64
  if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
38
65
  const hri = record.hash_range_inclusive;
39
66
  const cl = record.content_lines;
package/src/replace.ts CHANGED
@@ -18,7 +18,6 @@ import { resolveTarget, writeAtomic } from "./fs-write";
18
18
  import {
19
19
  applyEdits,
20
20
  lineHashes,
21
- fmtBoundaryWarning,
22
21
  resEdits,
23
22
  type HTEdit,
24
23
  } from "./hashline";
@@ -195,30 +194,7 @@ export async function execPipeline(
195
194
  removedHashes,
196
195
  });
197
196
 
198
- const resultLines = result.split("\n");
199
197
  const warnings = [...(anchorResult.warnings ?? [])];
200
- for (const bw of anchorResult.boundaryWarnings ?? []) {
201
- let seen = 0;
202
- let matchIndex = -1;
203
- for (let i = 0; i < resultLines.length; i++) {
204
- if (resultLines[i] === bw.survivingLineContent) {
205
- if (seen === bw.occurrence) { matchIndex = i; break; }
206
- seen++;
207
- }
208
- }
209
- if (matchIndex >= 0) {
210
- warnings.push(
211
- fmtBoundaryWarning({
212
- kind: bw.kind,
213
- survivingContent: bw.survivingLineContent,
214
- matchIndex,
215
- resultLines,
216
- resultHashes,
217
- }),
218
- );
219
- }
220
- }
221
-
222
198
  return {
223
199
  path,
224
200
  toolEdits,
@@ -297,7 +273,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
297
273
  AUTO_READ_GUIDANCE: readGuidance,
298
274
  });
299
275
 
300
- const parameters = opts.flat ? flatEditToolSchema : editToolSchema;
276
+ const parameters = editToolSchema;
301
277
 
302
278
  return {
303
279
  name: "replace",
@@ -311,7 +287,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
311
287
  if (!isRec(args)) return args as any;
312
288
  const record = { ...args };
313
289
  normalizeFilePath(record);
314
- return record as any;
290
+ return normReq(record) as any;
315
291
  }
316
292
  : (args: unknown) =>
317
293
  normReq(args) as ReqParams,
@@ -404,17 +380,7 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
404
380
  },
405
381
 
406
382
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
407
- const canonical = opts.flat
408
- ? normReq({
409
- content_lines: (params as any).content_lines,
410
- hash_range_inclusive: (params as any).hash_range_inclusive,
411
- path: (params as any).path,
412
- changes: [{
413
- content_lines: (params as any).content_lines,
414
- hash_range_inclusive: (params as any).hash_range_inclusive,
415
- }],
416
- })
417
- : normReq(params);
383
+ const canonical = normReq(params);
418
384
 
419
385
 
420
386
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };