pi-hashline-edit-pro 2.1.1 → 2.1.2

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
@@ -91,7 +91,7 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
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. `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. 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.
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
 
@@ -110,7 +110,7 @@ Notes:
110
110
  Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
111
111
 
112
112
  - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
113
- - After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint; use `read` with `offset` to continue.
113
+ - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
114
114
  - Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
115
115
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
116
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
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",
@@ -3,4 +3,4 @@
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
5
  - `replace`: to add a blank line, end new_content with an explicit empty line (e.g. ending with \n\n).
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.
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.
@@ -1,2 +1,2 @@
1
1
  - `undo_last_replace`: reverts only the most recent replace on the file — any write to the file clears the undo history, so call it immediately after a bad replace.
2
- - `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
2
+ - `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the undo.
@@ -1,4 +1,4 @@
1
- import { abortIf, splitLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
1
+ import { abortIf, splitLines } from "../utils";
2
2
  import { _lineHashesPure, HASH_SEP } from "./hash";
3
3
  import {
4
4
  valEdit,
@@ -49,7 +49,6 @@ type NoopSpan = {
49
49
  loc: string;
50
50
  currentContent: string;
51
51
  };
52
-
53
52
  function assertNotEmpty(originalContent: string, result: string): void {
54
53
  if (originalContent.length > 0 && result.length === 0) {
55
54
  throw new Error(
@@ -164,7 +163,7 @@ export function applyEdit(
164
163
  warnings,
165
164
  );
166
165
 
167
- const { resolved: initialResolved, mismatches, boundaryWarnings } = valEdit(
166
+ const { resolved: initialResolved, mismatches, boundaryDups } = valEdit(
168
167
  prefixFixed,
169
168
  lineIndex.fileLines,
170
169
  fileHashes,
@@ -181,26 +180,20 @@ export function applyEdit(
181
180
 
182
181
  let resolved = initialResolved;
183
182
  let autoFixes: AutoFix[] | undefined;
184
- if (boundaryWarnings.length > 0) {
183
+ if (boundaryDups.length > 0) {
185
184
  autoFixes = [];
186
185
  const correctedEdit: HEdit = {
187
186
  ...prefixFixed,
188
187
  content_lines: [...prefixFixed.content_lines],
189
188
  };
190
- for (const bw of boundaryWarnings) {
191
- if (bw.kind === "trailing") {
192
- const idx = lastNonEmptyIndex(correctedEdit.content_lines);
193
- if (idx >= 0) {
194
- const removed = correctedEdit.content_lines.splice(idx, 1)[0];
195
- autoFixes.push({ kind: "trailing", removedLine: removed });
196
- }
197
- } else {
198
- const idx = firstNonEmptyIndex(correctedEdit.content_lines);
199
- if (idx >= 0) {
200
- const removed = correctedEdit.content_lines.splice(idx, 1)[0];
201
- autoFixes.push({ kind: "leading", removedLine: removed });
202
- }
203
- }
189
+ const dupsByIndex = [...boundaryDups].sort(
190
+ (a, b) => b.replacementLineIndex - a.replacementLineIndex,
191
+ );
192
+ for (const dup of dupsByIndex) {
193
+ const idx = dup.replacementLineIndex;
194
+ if (idx < 0 || idx >= correctedEdit.content_lines.length) continue;
195
+ const removed = correctedEdit.content_lines.splice(idx, 1)[0];
196
+ autoFixes.push({ kind: dup.kind, removedLine: removed, removedLineIndex: idx });
204
197
  }
205
198
  const correctedResult = valEdit(
206
199
  correctedEdit,
@@ -47,7 +47,7 @@ export const HL_PREFIX_MINUS_RE = new RegExp(
47
47
 
48
48
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
49
49
 
50
- function canon(line: string): string {
50
+ export function canon(line: string): string {
51
51
  return line.replace(/\r/g, "").trimEnd();
52
52
  }
53
53
 
@@ -12,6 +12,7 @@ export {
12
12
  lineHashes,
13
13
  _lineHashesPure,
14
14
  initHasher,
15
+ canon,
15
16
  } from "./hash";
16
17
 
17
18
  export {
@@ -26,7 +27,7 @@ export {
26
27
  type RHEdit,
27
28
  type HTEdit,
28
29
  type NEdit,
29
- type BDupWarn,
30
+ type BDup,
30
31
  type AutoFix,
31
32
  resEdit,
32
33
  valEdit,
@@ -34,6 +35,7 @@ export {
34
35
  stripDiffPrefixes,
35
36
  swapReversedRanges,
36
37
  fmtMismatch,
38
+ findNewEdge,
37
39
  } from "./resolve";
38
40
 
39
41
  export {
@@ -1,5 +1,5 @@
1
- import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, clipLine } from "../utils";
2
- import { HASH_CLASS, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hash";
1
+ import { abortIf, rejectUnknownFields, lastNonEmpty, firstNonEmpty, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
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";
5
5
 
@@ -22,16 +22,15 @@ interface HMismatch {
22
22
  context?: RAnchor;
23
23
  }
24
24
 
25
- export interface BDupWarn {
26
- kind: "trailing" | "leading";
27
- survivingLineContent: string;
28
- survivingLineIndex: number;
29
- replacementLineContent: string;
25
+ export interface BDup {
26
+ kind: "trailing" | "leading" | "first-new-after" | "last-new-before";
27
+ replacementLineIndex: number;
30
28
  }
31
29
 
32
30
  export interface AutoFix {
33
- kind: "trailing" | "leading";
34
- removedLine: string;
31
+ kind: "trailing" | "leading" | "first-new-after" | "last-new-before";
32
+ removedLine: string;
33
+ removedLineIndex: number;
35
34
  }
36
35
 
37
36
  export interface NEdit {
@@ -284,24 +283,79 @@ export function swapReversedRanges(
284
283
  return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
285
284
  }
286
285
 
286
+ function edgeRef(
287
+ line: string | undefined,
288
+ index: number,
289
+ ): { index: number; line: string } | undefined {
290
+ return line === undefined ? undefined : { index, line };
291
+ }
292
+
287
293
  function checkBoundaryDup(
288
294
  adjacentLine: string | undefined,
289
- replacementEdge: string | undefined,
290
- kind: "trailing" | "leading",
291
- survivingLineIndex: number,
292
- ): BDupWarn | null {
295
+ replacementEdge: { index: number; line: string } | undefined,
296
+ kind: BDup["kind"],
297
+ canonical = false,
298
+ uniqueInFile?: Map<string, number>,
299
+ ): BDup | null {
293
300
  if (
294
301
  adjacentLine === undefined ||
295
302
  replacementEdge === undefined ||
296
- replacementEdge.length === 0 ||
297
- replacementEdge !== adjacentLine
298
- ) return null;
299
- return {
300
- kind,
301
- survivingLineContent: adjacentLine,
302
- survivingLineIndex,
303
- replacementLineContent: replacementEdge,
304
- };
303
+ replacementEdge.line.length === 0
304
+ ) {
305
+ return null;
306
+ }
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;
313
+ }
314
+ return {
315
+ kind,
316
+ replacementLineIndex: replacementEdge.index,
317
+ };
318
+ }
319
+
320
+ function canonCounts(lines: string[]): Map<string, number> {
321
+ const counts = new Map<string, number>();
322
+ for (const line of lines) {
323
+ const key = canon(line);
324
+ counts.set(key, (counts.get(key) ?? 0) + 1);
325
+ }
326
+ return counts;
327
+ }
328
+
329
+ export function findNewEdge(
330
+ contentLines: string[],
331
+ rangeLines: string[],
332
+ fromEnd: boolean,
333
+ ): { index: number; line: string } | undefined {
334
+ const multiset = canonCounts(rangeLines);
335
+ const step = fromEnd ? -1 : 1;
336
+ const start = fromEnd ? contentLines.length - 1 : 0;
337
+ for (let i = start; i >= 0 && i < contentLines.length; i += step) {
338
+ const line = contentLines[i]!;
339
+ if (line.length === 0) continue;
340
+ const key = canon(line);
341
+ const count = multiset.get(key) ?? 0;
342
+ if (count > 0) {
343
+ multiset.set(key, count - 1);
344
+ } else {
345
+ return { index: i, line };
346
+ }
347
+ }
348
+ return undefined;
349
+ }
350
+
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
+ };
305
359
  }
306
360
 
307
361
  export function valEdit(
@@ -310,10 +364,10 @@ export function valEdit(
310
364
  fileHashes: string[],
311
365
  warnings: string[],
312
366
  signal: AbortSignal | undefined,
313
- ): { resolved: RHEdit | undefined; mismatches: HMismatch[]; boundaryWarnings: BDupWarn[] } {
367
+ ): { resolved: RHEdit | undefined; mismatches: HMismatch[]; boundaryDups: BDup[] } {
314
368
  assertAligned(fileLines, fileHashes, "valEdit");
315
369
  const mismatches: HMismatch[] = [];
316
- const boundaryWarnings: BDupWarn[] = [];
370
+ const boundaryDups: BDup[] = [];
317
371
 
318
372
  const hashIndex = new Map<string, number[]>();
319
373
  for (let i = 0; i < fileHashes.length; i++) {
@@ -343,7 +397,7 @@ export function valEdit(
343
397
  const endMismatch = mismatches.findLast((m) => m.ref === edit.hash_bounds[1]);
344
398
  if (endMismatch && endMismatch.kind === "not_found") endMismatch.context = startResolved;
345
399
  }
346
- return { resolved: undefined, mismatches, boundaryWarnings };
400
+ return { resolved: undefined, mismatches, boundaryDups };
347
401
  }
348
402
  if (startResolved.line > endResolved.line) {
349
403
  throw new Error(
@@ -353,12 +407,19 @@ export function valEdit(
353
407
  const endLine = endResolved.line;
354
408
  const nextLine = fileLines[endLine];
355
409
  const replacementLastLine = lastNonEmpty(edit.content_lines);
356
- const trailing = checkBoundaryDup(nextLine, replacementLastLine, "trailing", endLine);
357
- if (trailing) boundaryWarnings.push(trailing);
410
+ const trailing = checkBoundaryDup(nextLine, edgeRef(replacementLastLine, lastNonEmptyIndex(edit.content_lines)), "trailing");
411
+ if (trailing) boundaryDups.push(trailing);
358
412
  const prevLine = fileLines[startResolved.line - 2];
359
413
  const replacementFirstLine = firstNonEmpty(edit.content_lines);
360
- const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2);
361
- if (leading) boundaryWarnings.push(leading);
414
+ const leading = checkBoundaryDup(prevLine, edgeRef(replacementFirstLine, firstNonEmptyIndex(edit.content_lines)), "leading");
415
+ if (leading) boundaryDups.push(leading);
416
+ const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
417
+ const { firstNew, lastNew } = newEdgeLines(edit.content_lines, rangeLines);
418
+ 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);
362
423
 
363
424
  return {
364
425
  resolved: {
@@ -366,7 +427,7 @@ export function valEdit(
366
427
  hash_bounds: [startResolved, endResolved],
367
428
  },
368
429
  mismatches,
369
- boundaryWarnings,
430
+ boundaryDups,
370
431
  };
371
432
  }
372
433
 
@@ -56,12 +56,14 @@ export function genDiff(
56
56
  newContent: string,
57
57
  contextLines = 2,
58
58
  newContentHashes?: string[],
59
+ oldContentHashes?: string[],
59
60
  ): { diff: string; firstChangedLine: number | undefined } {
60
61
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
61
62
 
62
63
  const parts = Diff.diffLines(oldContent, newContent);
63
64
  const output: string[] = [];
64
65
  let newLineNum = 1;
66
+ let oldLineNum = 1;
65
67
  let lastWasChange = false;
66
68
  let firstChangedLine: number | undefined;
67
69
 
@@ -79,7 +81,9 @@ export function genDiff(
79
81
  output.push(fmtDiffLine("+", displayLines[k]!, hash));
80
82
  newLineNum++;
81
83
  } else {
82
- output.push(fmtDiffLine("-", displayLines[k]!, undefined));
84
+ const hash = oldContentHashes?.[oldLineNum - 1];
85
+ output.push(fmtDiffLine("-", displayLines[k]!, hash));
86
+ oldLineNum++;
83
87
  }
84
88
  }
85
89
  lastWasChange = true;
@@ -107,19 +111,23 @@ export function genDiff(
107
111
  if (skipStart > 0) {
108
112
  output.push(" ...");
109
113
  newLineNum += skipStart;
114
+ oldLineNum += skipStart;
110
115
  }
111
116
  for (const line of linesToShow) {
112
117
  if (isEllipsisMarker(line)) {
113
118
  output.push(" ...");
114
119
  newLineNum += skipMiddle;
120
+ oldLineNum += skipMiddle;
115
121
  continue;
116
122
  }
117
123
  const hash = effectiveNewHashes[newLineNum - 1];
118
124
  output.push(fmtDiffLine(" ", line, hash));
119
125
  newLineNum++;
126
+ oldLineNum++;
120
127
  }
121
128
  } else {
122
129
  newLineNum += displayLines.length;
130
+ oldLineNum += displayLines.length;
123
131
  }
124
132
  lastWasChange = false;
125
133
  }
@@ -123,10 +123,9 @@ export function buildNoop(input: NoopInput): TResult {
123
123
  }
124
124
 
125
125
  export function buildChanged(input: SuccessInput): TResult {
126
- const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
127
-
126
+ const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
128
127
  const resultLines = visLines(result);
129
- const diffResult = genDiff(originalNormalized, result, 1, resultHashes);
128
+ const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
130
129
  const addedLines = editMeta.addedLines;
131
130
  const removedLines = editMeta.removedLines;
132
131
  const warningsBlock = warnBlock(warnings);
@@ -10,7 +10,7 @@ import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./repl
10
10
  import { cntDiff, splitLines, errCode, isRec, normalizeFilePath } from "./utils";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { buildMetrics } from "./replace-response";
13
- import { changedRange } from "./hashline";
13
+ import { changedRange, lineHashes } from "./hashline";
14
14
  export interface UndoEntry {
15
15
  content: string;
16
16
  bom: string;
@@ -159,11 +159,12 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
159
159
 
160
160
  const { text: currentStripped } = stripBOM(currentRaw);
161
161
  const currentNormalized = toLF(currentStripped);
162
- const diffResult = genDiff(undo.content, currentNormalized, 0);
162
+ const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
163
+ const diffResult = genDiff(undo.content, currentNormalized, 0, undefined, undo.hashes);
163
164
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
164
165
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
165
166
  const restoredRange = changedRange(currentNormalized, undo.content);
166
- const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes).diff;
167
+ const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes).diff;
167
168
 
168
169
  await writeAtomic(
169
170
  mutationTargetPath,
package/src/replace.ts CHANGED
@@ -241,19 +241,18 @@ export async function compPreview(
241
241
  try {
242
242
  const normalized = normReq(request);
243
243
  assertReq(normalized);
244
- const { path, originalNormalized, result, resultHashes } = await execPipeline(
244
+ const { path, originalNormalized, result, resultHashes, originalHashes } = await execPipeline(
245
245
  normalized,
246
246
  cwd,
247
247
  { accessMode: constants.R_OK, noPersist: true },
248
248
  );
249
-
250
249
  if (originalNormalized === result) {
251
250
  return {
252
251
  error: `No changes made to ${path}. The edit produced identical content.`,
253
252
  };
254
253
  }
255
254
 
256
- return { diff: genDiff(originalNormalized, result, 4, resultHashes).diff };
255
+ return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
257
256
  } catch (error: unknown) {
258
257
  return { error: error instanceof Error ? error.message : String(error) };
259
258
  }