pi-hashline-edit-pro 2.1.2 → 2.2.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
@@ -86,12 +86,12 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
86
86
  | Field | Description |
87
87
  | --- | --- |
88
88
  | `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
89
- | `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. |
89
+ | `new_content` | Replacement content as a single string with `\n` line separators; every `\n` separates lines, so a trailing `\n` adds a final empty line — mirror the replaced range's 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. |
90
90
 
91
91
  Notes:
92
92
 
93
93
  - The request is checked before any file I/O, so a bad request never touches the file.
94
- - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. A new line that duplicates a unique line adjacent to the range is stripped automatically. `file_path` works as an alias for `path` in all three tools.
94
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that duplicate unique lines adjacent to the range are stripped automatically — consecutive duplicates are stripped as a run, so re-including a whole unchanged block next to the range never duplicates it. `file_path` works as an alias for `path` in all three tools.
95
95
  - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
96
96
  - After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
97
97
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
- "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
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",
7
7
  "repository": {
8
8
  "type": "git",
@@ -16,7 +16,14 @@
16
16
  "extension",
17
17
  "hashline",
18
18
  "hash-anchored",
19
- "strict"
19
+ "hash",
20
+ "anchors",
21
+ "read",
22
+ "replace",
23
+ "edit",
24
+ "undo",
25
+ "file-editing",
26
+ "stable"
20
27
  ],
21
28
  "license": "MIT",
22
29
  "files": [
@@ -2,5 +2,5 @@
2
2
  - `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
3
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
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).
5
+ - `replace`: every `\n` in new_content separates lines, so a trailing `\n` adds a final empty line. Mirror the replaced range's lines exactly: a range that ends on a blank line must end new_content 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 new_content 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 (e.g. `"\n"` is a single blank line).
6
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. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the edit.
@@ -11,6 +11,7 @@ import {
11
11
  type NEdit,
12
12
  type HEdit,
13
13
  type AutoFix,
14
+ type BDup,
14
15
  } from "./resolve";
15
16
 
16
17
  type LIdx = {
@@ -186,7 +187,14 @@ export function applyEdit(
186
187
  ...prefixFixed,
187
188
  content_lines: [...prefixFixed.content_lines],
188
189
  };
189
- const dupsByIndex = [...boundaryDups].sort(
190
+ const seen = new Set<number>();
191
+ const uniqueDups: BDup[] = [];
192
+ for (const dup of boundaryDups) {
193
+ if (seen.has(dup.replacementLineIndex)) continue;
194
+ seen.add(dup.replacementLineIndex);
195
+ uniqueDups.push(dup);
196
+ }
197
+ const dupsByIndex = uniqueDups.sort(
190
198
  (a, b) => b.replacementLineIndex - a.replacementLineIndex,
191
199
  );
192
200
  for (const dup of dupsByIndex) {
@@ -45,7 +45,6 @@ export function parseText(edit: string): string[] {
45
45
  }
46
46
  const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
47
47
  if (normalized === "") return [];
48
- const lines = normalized.split("\n");
49
- if (normalized.endsWith("\n")) lines.pop();
50
- return lines;
48
+ if (/^\n+$/.test(normalized)) return new Array(normalized.length).fill("");
49
+ return normalized.split("\n");
51
50
  }
@@ -1,4 +1,4 @@
1
- import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
1
+ import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
2
  import { HASH_CLASS, 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
4
  import { NEW_CONTENT_NOT_STRING_MSG } from "../constants";
@@ -283,38 +283,78 @@ export function swapReversedRanges(
283
283
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
284
284
  }
285
285
 
286
- function edgeRef(
287
- line: string | undefined,
288
- index: number,
289
- ): { index: number; line: string } | undefined {
290
- return line === undefined ? undefined : { index, line };
286
+ function trailingDups(
287
+ contentLines: string[],
288
+ fileLines: string[],
289
+ endLine: number,
290
+ ): BDup[] {
291
+ const start = lastNonEmptyIndex(contentLines);
292
+ if (start < 0) return [];
293
+ const dups: BDup[] = [];
294
+ const maxK = Math.min(start + 1, fileLines.length - endLine);
295
+ for (let k = 0; k < maxK; k++) {
296
+ if (contentLines[start - k] !== fileLines[endLine + k]) break;
297
+ dups.push({ kind: "trailing", replacementLineIndex: start - k });
298
+ }
299
+ return dups;
291
300
  }
292
301
 
293
- function checkBoundaryDup(
294
- adjacentLine: string | undefined,
295
- replacementEdge: { index: number; line: string } | undefined,
296
- kind: BDup["kind"],
297
- canonical = false,
298
- uniqueInFile?: Map<string, number>,
299
- ): BDup | null {
300
- if (
301
- adjacentLine === undefined ||
302
- replacementEdge === undefined ||
303
- replacementEdge.line.length === 0
304
- ) {
305
- return null;
302
+ function leadingDups(
303
+ contentLines: string[],
304
+ fileLines: string[],
305
+ startLine: number,
306
+ ): BDup[] {
307
+ const start = firstNonEmptyIndex(contentLines);
308
+ if (start < 0) return [];
309
+ const dups: BDup[] = [];
310
+ const maxK = Math.min(contentLines.length - start, startLine - 1);
311
+ for (let k = 0; k < maxK; k++) {
312
+ if (contentLines[start + k] !== fileLines[startLine - 2 - k]) break;
313
+ dups.push({ kind: "leading", replacementLineIndex: start + k });
306
314
  }
307
- const matches = canonical
308
- ? canon(adjacentLine) === canon(replacementEdge.line)
309
- : adjacentLine === replacementEdge.line;
310
- if (!matches) return null;
311
- if (uniqueInFile && (uniqueInFile.get(canon(adjacentLine)) ?? 0) !== 1) {
312
- return null;
315
+ return dups;
316
+ }
317
+
318
+ function firstNewAfterDups(
319
+ contentLines: string[],
320
+ rangeLines: string[],
321
+ fileLines: string[],
322
+ endLine: number,
323
+ fileCounts: Map<string, number>,
324
+ ): BDup[] {
325
+ const firstNew = findNewEdge(contentLines, rangeLines, false);
326
+ if (!firstNew) return [];
327
+ const dups: BDup[] = [];
328
+ const maxK = Math.min(contentLines.length - firstNew.index, fileLines.length - endLine);
329
+ for (let k = 0; k < maxK; k++) {
330
+ const newLine = contentLines[firstNew.index + k]!;
331
+ const fileLine = fileLines[endLine + k]!;
332
+ if (canon(newLine) !== canon(fileLine)) break;
333
+ if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
334
+ dups.push({ kind: "first-new-after", replacementLineIndex: firstNew.index + k });
313
335
  }
314
- return {
315
- kind,
316
- replacementLineIndex: replacementEdge.index,
317
- };
336
+ return dups;
337
+ }
338
+
339
+ function lastNewBeforeDups(
340
+ contentLines: string[],
341
+ rangeLines: string[],
342
+ fileLines: string[],
343
+ startLine: number,
344
+ fileCounts: Map<string, number>,
345
+ ): BDup[] {
346
+ const lastNew = findNewEdge(contentLines, rangeLines, true);
347
+ if (!lastNew) return [];
348
+ const dups: BDup[] = [];
349
+ const maxK = Math.min(lastNew.index + 1, startLine - 1);
350
+ for (let k = 0; k < maxK; k++) {
351
+ const newLine = contentLines[lastNew.index - k]!;
352
+ const fileLine = fileLines[startLine - 2 - k]!;
353
+ if (canon(newLine) !== canon(fileLine)) break;
354
+ if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
355
+ dups.push({ kind: "last-new-before", replacementLineIndex: lastNew.index - k });
356
+ }
357
+ return dups;
318
358
  }
319
359
 
320
360
  function canonCounts(lines: string[]): Map<string, number> {
@@ -348,16 +388,6 @@ export function findNewEdge(
348
388
  return undefined;
349
389
  }
350
390
 
351
- function newEdgeLines(
352
- contentLines: string[],
353
- rangeLines: string[],
354
- ): { firstNew: { index: number; line: string } | undefined; lastNew: { index: number; line: string } | undefined } {
355
- return {
356
- firstNew: findNewEdge(contentLines, rangeLines, false),
357
- lastNew: findNewEdge(contentLines, rangeLines, true),
358
- };
359
- }
360
-
361
391
  export function valEdit(
362
392
  edit: HEdit,
363
393
  fileLines: string[],
@@ -405,21 +435,14 @@ export function valEdit(
405
435
  );
406
436
  }
407
437
  const endLine = endResolved.line;
408
- const nextLine = fileLines[endLine];
409
- const replacementLastLine = lastNonEmpty(edit.content_lines);
410
- const trailing = checkBoundaryDup(nextLine, edgeRef(replacementLastLine, lastNonEmptyIndex(edit.content_lines)), "trailing");
411
- if (trailing) boundaryDups.push(trailing);
412
- const prevLine = fileLines[startResolved.line - 2];
413
- const replacementFirstLine = firstNonEmpty(edit.content_lines);
414
- const leading = checkBoundaryDup(prevLine, edgeRef(replacementFirstLine, firstNonEmptyIndex(edit.content_lines)), "leading");
415
- if (leading) boundaryDups.push(leading);
416
438
  const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
417
- const { firstNew, lastNew } = newEdgeLines(edit.content_lines, rangeLines);
418
439
  const fileCounts = canonCounts(fileLines);
419
- const firstNewAfter = checkBoundaryDup(nextLine, firstNew, "first-new-after", true, fileCounts);
420
- if (firstNewAfter) boundaryDups.push(firstNewAfter);
421
- const lastNewBefore = checkBoundaryDup(prevLine, lastNew, "last-new-before", true, fileCounts);
422
- if (lastNewBefore) boundaryDups.push(lastNewBefore);
440
+ boundaryDups.push(
441
+ ...trailingDups(edit.content_lines, fileLines, endLine),
442
+ ...leadingDups(edit.content_lines, fileLines, startResolved.line),
443
+ ...firstNewAfterDups(edit.content_lines, rangeLines, fileLines, endLine, fileCounts),
444
+ ...lastNewBeforeDups(edit.content_lines, rangeLines, fileLines, startResolved.line, fileCounts),
445
+ );
423
446
 
424
447
  return {
425
448
  resolved: {
package/src/replace.ts CHANGED
@@ -47,7 +47,7 @@ import { loadHashStore, type HashStore } from "./hash-store";
47
47
 
48
48
  const newContentSchema = Type.String({
49
49
  description:
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."
50
+ "Replacement content as a single string with \\n line separators; every \\n separates lines, so a trailing \\n adds a final empty line. Mirror the replaced range's 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."
51
51
  });
52
52
 
53
53
  const hashBoundsSchema = Type.Array(