pi-hashline-edit-pro 0.9.2 → 0.9.4

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
@@ -76,14 +76,14 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
76
76
  | `old_range` | Inclusive line range `[start_hash, end_hash]` (required). |
77
77
  | `new_lines` | Replacement content (one string per line). Use `[]` to delete. |
78
78
 
79
- - **Request structure validation.** The request envelope (`path`, `edits`) 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_OP]`.
79
+ - **Request structure validation.** The request envelope (`path`, `edits`) 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
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 `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
81
81
 
82
- All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
82
+ 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.
83
83
 
84
84
  ### Chained edits
85
85
 
86
- After a successful replace, the result text contains an `--- Anchors ---` block with fresh `HASH│content` references for the changed region. These can be used directly in the next `replace` call on the same file without a full re-read, provided the next replace targets the same or nearby lines. For distant changes, use `read` first.
86
+ After a successful replace, the response text is empty (warnings are still shown if present). To get fresh anchors for follow-up edits, call `read` on the file first. This avoids token overhead from re-displaying content the model already knows.
87
87
 
88
88
  ### Auto-read after write
89
89
 
@@ -98,7 +98,7 @@ For large files (>2000 lines), the auto-read output is truncated with a paginati
98
98
 
99
99
  ### Diff for the host
100
100
 
101
- The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is exposed to the host UI via `details.diff`. It is intentionally not in the LLM-visible text. The model only needs the fresh anchors in `text` to chain follow-up edits, and re-emitting the diff would cost extra tokens.
101
+ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details.diff`. It is intentionally not in the LLM-visible text. The model already knows what it changed and can call `read` for fresh anchors when needed.
102
102
 
103
103
  ## Design Decisions
104
104
 
@@ -108,6 +108,7 @@ The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is ex
108
108
  - **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 `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
109
109
  - **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.
110
110
  - **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.
111
+ - **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 includes the surviving line's post-edit hash so the model can reference it in a follow-up edit. 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.
111
112
 
112
113
  ## Hashing
113
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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": {
@@ -59,7 +59,7 @@ Rules:
59
59
  - If `new_lines` matches current content, the replace is classified as `noop` (file unchanged).
60
60
  - The `old_range` 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.
61
61
 
62
- On success, the response contains an `--- Anchors ---` block with fresh HASH anchors for the changed region. Use those for nearby follow-up replaces instead of re-reading.
62
+ On success, the response text is empty (or contains only warnings if present). Call `read` to get fresh anchors for follow-up edits.
63
63
 
64
64
  Error recovery:
65
65
  - `[E_STALE_ANCHOR]` — file changed since last read. Call `read` to get fresh anchors, then copy the HASH and retry.
@@ -10,6 +10,7 @@ import {
10
10
  type ResolvedHashlineEdit,
11
11
  type NoopEdit,
12
12
  type HashlineEdit,
13
+ type BoundaryDuplicationWarning,
13
14
  } from "./resolve";
14
15
  import { countVisibleLines } from "../utils";
15
16
 
@@ -248,7 +249,7 @@ export function applyHashlineEdits(
248
249
  const noopEdits: NoopEdit[] = [];
249
250
  const warnings: string[] = [];
250
251
 
251
- const { resolved, mismatches } = validateAnchorEdits(
252
+ const { resolved, mismatches, boundaryWarnings } = validateAnchorEdits(
252
253
  edits,
253
254
  lineIndex.fileLines,
254
255
  fileHashes,
@@ -277,6 +278,34 @@ export function applyHashlineEdits(
277
278
  assertDoesNotEmptyFile(content, result);
278
279
  const changedRange = computeChangedLineRange(content, result);
279
280
 
281
+ // Reconstruct boundary duplication warnings with post-edit hashes.
282
+ // Use position + occurrence to find the exact surviving line (handles
283
+ // files with multiple identical lines like several `}` closings).
284
+ const resultLines = result.split("\n");
285
+ const resultHashes = computeLineHashes(result);
286
+ for (const bw of boundaryWarnings) {
287
+ let seen = 0;
288
+ let matchIndex = -1;
289
+ for (let i = 0; i < resultLines.length; i++) {
290
+ if (resultLines[i] === bw.survivingLineContent) {
291
+ if (seen === bw.occurrence) { matchIndex = i; break; }
292
+ seen++;
293
+ }
294
+ }
295
+ if (matchIndex >= 0) {
296
+ const hash = resultHashes[matchIndex];
297
+ if (bw.kind === "trailing") {
298
+ warnings.push(
299
+ `Potential boundary duplication: the last line of the replacement (${JSON.stringify(bw.replacementLineContent)}) matches the next surviving line. Surviving line hash: ${hash}`
300
+ );
301
+ } else {
302
+ warnings.push(
303
+ `Potential boundary duplication: the first line of the replacement (${JSON.stringify(bw.replacementLineContent)}) matches the preceding surviving line. Surviving line hash: ${hash}`
304
+ );
305
+ }
306
+ }
307
+ }
308
+
280
309
  return {
281
310
  content: result,
282
311
  firstChangedLine: changedRange?.firstChangedLine,
@@ -26,6 +26,7 @@ export {
26
26
  type ResolvedHashlineEdit,
27
27
  type HashlineToolEdit,
28
28
  type NoopEdit,
29
+ type BoundaryDuplicationWarning,
29
30
  describeEdit,
30
31
  resolveEditAnchors,
31
32
  validateAnchorEdits,
@@ -21,6 +21,15 @@ interface HashMismatch {
21
21
  candidates?: number[];
22
22
  }
23
23
 
24
+ export interface BoundaryDuplicationWarning {
25
+ kind: "trailing" | "leading";
26
+ survivingLineContent: string;
27
+ survivingLineIndex: number;
28
+ occurrence: number;
29
+ replacementLineContent: string;
30
+ editIndex: number;
31
+ }
32
+
24
33
  export interface NoopEdit {
25
34
  editIndex: number;
26
35
  loc: string;
@@ -233,7 +242,7 @@ export function validateAnchorEdits(
233
242
  fileHashes: string[],
234
243
  warnings: string[],
235
244
  signal: AbortSignal | undefined,
236
- ): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[] } {
245
+ ): { resolved: ResolvedHashlineEdit[]; mismatches: HashMismatch[]; boundaryWarnings: BoundaryDuplicationWarning[] } {
237
246
  if (fileHashes.length !== fileLines.length) {
238
247
  throw new Error(
239
248
  `validateAnchorEdits: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
@@ -241,6 +250,7 @@ export function validateAnchorEdits(
241
250
  }
242
251
  const resolved: ResolvedHashlineEdit[] = [];
243
252
  const mismatches: HashMismatch[] = [];
253
+ const boundaryWarnings: BoundaryDuplicationWarning[] = [];
244
254
 
245
255
  const tryResolve = (ref: Anchor): ResolvedAnchor | undefined => {
246
256
  const result = resolveAnchor(ref, fileLines, fileHashes);
@@ -266,36 +276,38 @@ export function validateAnchorEdits(
266
276
  }
267
277
  const endLine = endResolved.line;
268
278
  const nextLine = fileLines[endLine];
269
- const replacementLastLine = edit.new_lines.at(-1)?.trim();
279
+ const replacementLastLine = edit.new_lines.at(-1);
270
280
  if (
271
281
  nextLine !== undefined &&
272
- replacementLastLine &&
273
- /[\p{L}\p{N}]/u.test(replacementLastLine) &&
274
- replacementLastLine === nextLine.trim()
282
+ replacementLastLine !== undefined &&
283
+ replacementLastLine.length > 0 &&
284
+ replacementLastLine === nextLine
275
285
  ) {
276
- const resolvedEdit: ResolvedHashlineEdit = {
277
- old_range: [startResolved, endResolved],
278
- new_lines: edit.new_lines,
279
- };
280
- warnings.push(
281
- `Potential boundary duplication after ${describeEdit(resolvedEdit)}: the replacement ends with a line that matches the next surviving line after trim.`,
282
- );
286
+ boundaryWarnings.push({
287
+ kind: "trailing",
288
+ survivingLineContent: nextLine,
289
+ survivingLineIndex: endLine,
290
+ occurrence: fileLines.slice(0, endLine).filter(l => l === nextLine).length,
291
+ replacementLineContent: replacementLastLine,
292
+ editIndex: resolved.length,
293
+ });
283
294
  }
284
295
  const prevLine = fileLines[startResolved.line - 2];
285
- const replacementFirstLine = edit.new_lines[0]?.trim();
296
+ const replacementFirstLine = edit.new_lines[0];
286
297
  if (
287
298
  prevLine !== undefined &&
288
- replacementFirstLine &&
289
- /[\p{L}\p{N}]/u.test(replacementFirstLine) &&
290
- replacementFirstLine === prevLine.trim()
299
+ replacementFirstLine !== undefined &&
300
+ replacementFirstLine.length > 0 &&
301
+ replacementFirstLine === prevLine
291
302
  ) {
292
- const resolvedEdit: ResolvedHashlineEdit = {
293
- old_range: [startResolved, endResolved],
294
- new_lines: edit.new_lines,
295
- };
296
- warnings.push(
297
- `Potential boundary duplication before ${describeEdit(resolvedEdit)}: the replacement starts with a line that matches the preceding surviving line after trim.`,
298
- );
303
+ boundaryWarnings.push({
304
+ kind: "leading",
305
+ survivingLineContent: prevLine,
306
+ survivingLineIndex: startResolved.line - 2,
307
+ occurrence: fileLines.slice(0, startResolved.line - 2).filter(l => l === prevLine).length,
308
+ replacementLineContent: replacementFirstLine,
309
+ editIndex: resolved.length,
310
+ });
299
311
  }
300
312
  resolved.push({
301
313
  old_range: [startResolved, endResolved],
@@ -303,7 +315,7 @@ export function validateAnchorEdits(
303
315
  });
304
316
  }
305
317
 
306
- return { resolved, mismatches };
318
+ return { resolved, mismatches, boundaryWarnings };
307
319
  }
308
320
 
309
321
  export { maybeWarnSuspiciousUnicodeEscapePlaceholder };