pi-hashline-edit-pro 2.6.2 → 2.6.3

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
@@ -96,8 +96,8 @@ One edit per call, with `remove_from`, `remove_to`, and `replacement_lines` at t
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_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
- - An edit that produces identical content reports `No changes made` and leaves the anchors alone.
99
+ - Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix (including a truncated or expanded prefix of up to 6 characters, e.g. `L3│` or `ab12│`) 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
+ - An edit that produces identical content reports `No changes made` and leaves the anchors alone. When such a noop happened because a boundary anti-duplication cut removed lines from the replacement (the cut blocked a line that duplicates the block next to the range from being added), the exact same replacement sent once more runs with the edge anti-duplication turned off for that single call and is applied literally — the duplicated lines are kept, and the result carries a `[E_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to that exact payload; any applied edit clears it.
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.
103
103
  - Do not issue multiple replace calls on the same file in one message; parallel edits split attention across the post-edit diffs and removed lines are easy to miss. Verify each diff before the next edit on that file.
@@ -181,6 +181,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
181
181
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
182
182
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
183
183
  | `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
184
+ | `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
184
185
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
185
186
 
186
187
  ## Troubleshooting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.6.2",
3
+ "version": "2.6.3",
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",
@@ -0,0 +1,26 @@
1
+ const boundaryBypassTracker = new Map<string, string>();
2
+
3
+ export function noopPayloadKey(
4
+ absolutePath: string,
5
+ removeFrom: string,
6
+ removeTo: string,
7
+ replacementLines: string[],
8
+ ): string {
9
+ return JSON.stringify([absolutePath, removeFrom, removeTo, replacementLines]);
10
+ }
11
+
12
+ export function markBoundaryNoop(absolutePath: string, payload: string): void {
13
+ boundaryBypassTracker.set(absolutePath, payload);
14
+ }
15
+
16
+ export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
17
+ if (boundaryBypassTracker.get(absolutePath) === payload) {
18
+ boundaryBypassTracker.delete(absolutePath);
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ export function clearBoundaryBypass(absolutePath: string): void {
25
+ boundaryBypassTracker.delete(absolutePath);
26
+ }
@@ -9,4 +9,6 @@ export const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
9
9
 
10
10
  export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
11
11
 
12
+ export const HASH_RUN = `[${ALPH_SAFE}]{1,${HASH_LEN * 2}}`;
13
+
12
14
  export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
@@ -147,6 +147,7 @@ export function applyEdit(
147
147
  precomputedHashes?: string[],
148
148
  filePath?: string,
149
149
  servedHashes?: ReadonlySet<string>,
150
+ skipBoundaryDedup?: boolean,
150
151
  ): {
151
152
  content: string;
152
153
  firstChangedLine: number | undefined;
@@ -188,7 +189,7 @@ export function applyEdit(
188
189
 
189
190
  let resolved = initialResolved;
190
191
  let autoFixes: AutoFix[] | undefined;
191
- if (boundaryDups.length > 0) {
192
+ if (boundaryDups.length > 0 && !skipBoundaryDedup) {
192
193
  autoFixes = [];
193
194
  const correctedEdit: HEdit = {
194
195
  ...prefixFixed,
@@ -241,6 +242,7 @@ export function applyEdit(
241
242
  firstChangedLine: undefined,
242
243
  lastChangedLine: undefined,
243
244
  ...(warnings.length ? { warnings } : {}),
245
+ ...(autoFixes ? { autoFixes } : {}),
244
246
  noopEdit: { loc: spanResult.loc, currentContent: spanResult.currentContent },
245
247
  };
246
248
  }
@@ -6,8 +6,8 @@ import {
6
6
  upsertSnapshot,
7
7
  } from "../hash-store";
8
8
  import { xxh32, contentChecksum, initHasher } from "./hasher";
9
- import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS } from "./alphabet";
10
- export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS };
9
+ import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS, HASH_RUN } from "./alphabet";
10
+ export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS, HASH_RUN };
11
11
 
12
12
  export const ANCHOR_LEN = HASH_LEN;
13
13
 
@@ -39,13 +39,13 @@ function hashAt(idx: number): string {
39
39
  }
40
40
 
41
41
  export const HL_PREFIX_PLUS_RE = new RegExp(
42
- `^\\+${HASH_CLASS}│`,
42
+ `^\\+${HASH_RUN}│`,
43
43
  );
44
44
  export const HL_PREFIX_MINUS_RE = new RegExp(
45
- `^-(?:${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
45
+ `^-(?:${HASH_RUN}│| {${ANCHOR_LEN}}│)`,
46
46
  );
47
47
 
48
- export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
48
+ export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
49
49
 
50
50
  export function canon(line: string): string {
51
51
  return line.replace(/\r/g, "").trimEnd();
@@ -3,6 +3,7 @@ export {
3
3
  ANCHOR_LEN,
4
4
  HASH_SEP,
5
5
  HASH_CLASS,
6
+ HASH_RUN,
6
7
  HASH_SPACE,
7
8
  HASH_PROBE_STRIDE,
8
9
  MAX_HASH_LINES,
@@ -1,5 +1,5 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
- import { HASH_CLASS, HASH_SEP, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
2
+ import { HASH_SEP, HASH_RUN, 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_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
5
 
@@ -162,7 +162,7 @@ function assertItem(edit: Record<string, unknown>): void {
162
162
  }
163
163
  }
164
164
 
165
- const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_CLASS})│`);
165
+ const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
166
166
 
167
167
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
168
168
  assertItem(edit as Record<string, unknown>);
package/src/replace.ts CHANGED
@@ -47,6 +47,7 @@ import { loadP, loadGuide } from "./prompts";
47
47
  import { saveUndo } from "./replace-undo";
48
48
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
49
49
  import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
50
+ import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
50
51
 
51
52
  const replacementLinesSchema = Type.Array(
52
53
  Type.String({
@@ -107,6 +108,7 @@ interface PipelineResult {
107
108
  resultHashes: string[];
108
109
  totalAddedLines: number;
109
110
  totalRemovedLines: number;
111
+ hadBoundaryDedup: boolean;
110
112
  }
111
113
 
112
114
  const PREVIEW_DEBOUNCE_MS = 150;
@@ -179,6 +181,7 @@ export interface ExecPipelineOptions {
179
181
  signal?: AbortSignal;
180
182
  store?: HashStore;
181
183
  noPersist?: boolean;
184
+ skipBoundaryDedup?: boolean;
182
185
  }
183
186
 
184
187
  function collectRemovedHashes(
@@ -252,6 +255,7 @@ export async function execPipeline(
252
255
  originalHashes,
253
256
  path,
254
257
  served,
258
+ options?.skipBoundaryDedup,
255
259
  );
256
260
  } catch (error) {
257
261
  if (options?.noPersist !== true) {
@@ -298,6 +302,7 @@ export async function execPipeline(
298
302
  originalHashes,
299
303
  totalAddedLines,
300
304
  totalRemovedLines,
305
+ hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
301
306
  };
302
307
  }
303
308
 
@@ -477,6 +482,8 @@ export function buildToolDef(): ToolDef {
477
482
  const path = normalizedParams.path;
478
483
  const absolutePath = toCwd(path, ctx.cwd);
479
484
  const mutationTargetPath = await resolveTarget(absolutePath);
485
+ const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
486
+ const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
480
487
  return withFileMutationQueue(mutationTargetPath, async () => {
481
488
  abortIf(signal);
482
489
 
@@ -492,21 +499,28 @@ export function buildToolDef(): ToolDef {
492
499
  firstChangedLine,
493
500
  lastChangedLine,
494
501
  resultHashes,
502
+ hadBoundaryDedup,
495
503
  totalAddedLines,
496
504
  totalRemovedLines,
497
505
  } = await execPipeline(
498
506
  normalizedParams,
499
507
  ctx.cwd,
500
- { accessMode: constants.R_OK | constants.W_OK, signal },
508
+ { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
501
509
  );
502
510
 
503
511
  if (resolution) {
504
512
  warnings.unshift(resolution.warning);
505
513
  }
514
+ if (boundaryBypass && originalNormalized !== result) {
515
+ warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call. Boundary dedup is now restored.");
516
+ }
506
517
 
507
518
  const editsAttempted = 1;
508
519
  if (originalNormalized === result) {
509
520
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
521
+ if (hadBoundaryDedup) {
522
+ markBoundaryNoop(mutationTargetPath, noopPayload);
523
+ }
510
524
  return buildNoop({
511
525
  path,
512
526
  noopEdit,
@@ -550,6 +564,7 @@ export function buildToolDef(): ToolDef {
550
564
  await undo.restore();
551
565
  throw error;
552
566
  }
567
+ clearBoundaryBypass(mutationTargetPath);
553
568
  const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
554
569
 
555
570
  const editMeta: RMeta = {