pi-hashline-edit-pro 4.3.9 → 4.4.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
@@ -89,16 +89,16 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
89
89
 
90
90
  The request is checked before any file I/O, so a bad request never touches the file.
91
91
 
92
- Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), diff-preview rows pasted into the replacement, and a boundary line pasted twice are reported as warnings, while a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines are corrected silently. With Boundary dedup `on`, new lines that re-include a block adjacent to the range are stripped when that block is unique in the file, and the whole run is stripped as one unit, so re-including an unchanged block next to the range never duplicates it. Boundary dedup has three modes in `/hashline-config` and is `off` by default: `on` strips with a warning, `strict` rejects the edit with `[E_BOUNDARY_STRICT]` when any replacement line would be stripped, and `off` applies edits literally.
92
+ Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement are reported as warnings, while a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines are corrected silently.
93
93
  Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
94
94
 
95
95
  Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
96
96
 
97
- An edit that produces identical content reports `No changes made` and leaves the anchors alone. When a noop happened because the boundary anti-duplication cut a line from the replacement, the result states how many lines were removed; sending the same replacement again goes through the same dedup. To apply such lines literally, turn Boundary dedup off in `/hashline-config`.
97
+ An edit that produces identical content reports `No changes made` and leaves the anchors alone.
98
98
 
99
99
  After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
100
100
 
101
- Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call. A member whose boundary dedup cuts it to a noop contributes no hunk to the combined diff: the batch warning names it instead of pointing at `dedup│` rows. The `dedup│` rows in a batch diff cover its applied members only.
101
+ Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call.
102
102
  The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
103
103
  Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`. A call whose anchors resolve nowhere never joins a batch: it runs solo and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, join their file's batch and abort it instead of applying partially. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
104
104
 
@@ -112,7 +112,7 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
112
112
  | `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
113
113
  | `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line, and never embed `\n` inside an element. A lone string is split on newlines, and stringified array text is unwrapped. |
114
114
 
115
- Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
115
+ Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
116
116
 
117
117
  The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
118
118
 
@@ -184,7 +184,7 @@ All five tools return machine-readable metadata in `details` alongside the model
184
184
 
185
185
  | Command | Description |
186
186
  | --- | --- |
187
- | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, strict input, and boundary dedup. Persists across sessions. |
187
+ | `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, and strict input. Persists across sessions. |
188
188
  | `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
189
189
 
190
190
  Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
@@ -197,7 +197,6 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
197
197
  "anchorGrepEnabled": true,
198
198
  "requirePath": false,
199
199
  "strictInput": false,
200
- "boundaryDedupMode": "off",
201
200
  "diffContextLines": 1
202
201
  }
203
202
  ```
@@ -245,7 +244,6 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
245
244
  | `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
246
245
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
247
246
  | `[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. |
248
- | `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
249
247
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
250
248
  | `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
251
249
  | `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
package/index.ts CHANGED
@@ -20,7 +20,6 @@ import {
20
20
  toggleAnchorGrep,
21
21
  toggleRequirePath,
22
22
  toggleStrictInput,
23
- cycleBoundaryDedupMode,
24
23
  adjustDiffContextLines,
25
24
  setAutoReadAllIgnoreFromText,
26
25
  } from "./src/config";
@@ -126,7 +125,7 @@ export default function (pi: ExtensionAPI): void {
126
125
  }));
127
126
 
128
127
  pi.registerCommand("hashline-config", {
129
- description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input, dedup)",
128
+ description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input)",
130
129
  handler: async (_args, ctx) => {
131
130
  if (!ctx.hasUI) {
132
131
  ctx.ui.notify("/hashline-config requires interactive mode", "error");
@@ -149,7 +148,6 @@ export default function (pi: ExtensionAPI): void {
149
148
  }
150
149
  else if (key === "requirePath") await toggleRequirePath();
151
150
  else if (key === "strictInput") await toggleStrictInput();
152
- else await cycleBoundaryDedupMode();
153
151
  await refreshEditTools();
154
152
  },
155
153
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.9",
3
+ "version": "4.4.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
package/prompts/insert.md CHANGED
@@ -1 +1,3 @@
1
- Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Lines are added literally, even when they duplicate neighbors. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
1
+ Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes, no embedded newlines. Inserted lines are written exactly as given; nothing else in the file changes.
2
+
3
+ Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
package/prompts/read.md CHANGED
@@ -1 +1 @@
1
- Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only), the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
1
+ Read a text file and return it as `anchor│content` rows, one per line: a 4-character anchor (letters only, case-sensitive), the `│` separator, then the line content. Target lines in replace and insert by anchor, never by content or line number. Page long files with `offset` and `limit`; when the output says truncated, continue with the hinted `offset/limit`. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
@@ -1,5 +1,6 @@
1
- - `replace`: edit with `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
2
- - `replace`: `replacement_lines` `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
3
- - `replace`: never anchor on `-anchor│` rows of the post-edit diff, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
4
- - `replace`: same-file same-message batches must target disjoint ranges; an overlap or member failure aborts only that file's batch unwritten. Unresolvable calls stay solo and don't abort.
5
- - `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically (not editable, never use `dedup` as an anchor).
1
+ - `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
2
+ - `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
3
+ - `replace`: `[E_STALE_ANCHOR]` means the anchor is not owned in this session: call `read` on the file for fresh anchors. `[E_RANGE_STALE]` means a line in the range changed since it was shown, or was never shown: retry with the fresh anchors the error returns.
4
+ - `replace`: `[E_BATCH_OVERLAP]` means two batch calls share lines: retry with disjoint ranges. `[E_OP_ABORTED]` means another batch call failed or the file changed: fix that call, then retry, or call `read` for fresh anchors.
5
+ - `replace`: use `replace`/`insert`, not `sed -i` or heredocs — anchor edits are verified against what was shown and undoable.
6
+ - `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
@@ -1 +1,5 @@
1
- Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
1
+ Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes and no embedded newlines; `[]` deletes the range.
2
+
3
+ Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
4
+
5
+ Example: read served `Hasu│old` and `arvm│old2`. Call { "remove_from": "Hasu", "remove_to": "arvm", "replacement_lines": ["new"] }. The post-edit diff shows `-Hasu│old`, `-arvm│old2`, `+Qwer│new`: the `-` rows are dead anchors now; the `+` and ` ` rows are live anchors for the next edit.
@@ -568,6 +568,24 @@ export function ownerOf(anchor: string): OwnedAnchor | undefined {
568
568
  return current()?.owned.get(anchor);
569
569
  }
570
570
 
571
+ export interface CaseFoldedOwner {
572
+ anchor: string;
573
+ path: string;
574
+ }
575
+
576
+ export function ownersDifferingOnlyByCase(anchor: string, paths?: ReadonlySet<string>): CaseFoldedOwner[] {
577
+ const state = current();
578
+ if (!state) return [];
579
+ const lower = anchor.toLowerCase();
580
+ const matches: CaseFoldedOwner[] = [];
581
+ for (const [owned, entry] of state.owned) {
582
+ if (owned === anchor || owned.toLowerCase() !== lower) continue;
583
+ if (paths && paths.size > 0 && !paths.has(entry.path)) continue;
584
+ matches.push({ anchor: owned, path: entry.path });
585
+ }
586
+ return matches;
587
+ }
588
+
571
589
  export function ownersForPath(path: string): Map<string, string> {
572
590
  const claims = new Map<string, string>();
573
591
  const state = current();
@@ -27,7 +27,7 @@ const MAX_REPORTED_OMISSIONS = 50;
27
27
  export const AUTO_READ_ALL_CHUNK_BYTES = 48 * 1024;
28
28
 
29
29
  const HEADER =
30
- "[hashline auto-read-all] Every non-ignored project file is attached below with live hashline anchors. Do not call read for files in [files complete: ...]. Edit directly from the attachment with replace and insert.";
30
+ "[hashline auto-read-all] Every non-ignored project file is attached below as `=== path ===` then `anchor│content` rows with live anchors.\nEdit directly from the attachment with replace and insert; do not call read for files in [files complete: ...].\nFiles listed as omitted or not attached can be read normally.\nAnchors are case-sensitive and stay valid until their line is edited.";
31
31
 
32
32
  const IMAGE_EXTENSIONS = new Set([
33
33
  ".avif",
package/src/batch.ts CHANGED
@@ -16,7 +16,6 @@ import {
16
16
  type HEdit,
17
17
  type PlannedEdit,
18
18
  } from "./hashline";
19
- import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
20
19
  import { adoptAnchors, servedForPath } from "./anchor-registry";
21
20
  import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
22
21
  import { assertInsertReq, assertReq, normReq } from "./payload-contract";
@@ -62,9 +61,6 @@ export interface BatchPiece {
62
61
  toHash: string;
63
62
  newLines: string[];
64
63
  warnings: string[];
65
- autoFixes: number;
66
- dedupAbove: string[];
67
- dedupBelow: string[];
68
64
  noop: boolean;
69
65
  foldedLines: number;
70
66
  }
@@ -79,8 +75,6 @@ export interface BatchMemberInput {
79
75
  signal?: AbortSignal;
80
76
  hedit: HEdit;
81
77
  extraWarnings: string[];
82
- skipBoundaryDedup: boolean;
83
- strictBoundaryDedup: boolean;
84
78
  foldedLines?: number;
85
79
  }
86
80
 
@@ -333,8 +327,7 @@ function formatBatchPiece(piece: BatchPiece): string {
333
327
  }
334
328
 
335
329
  function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId: string | undefined): TResult {
336
- const grossAdded = Math.max(0, piece.newLines.length - piece.autoFixes);
337
- const added = piece.kind === "insert" ? Math.max(0, grossAdded - piece.foldedLines) : grossAdded;
330
+ const added = piece.kind === "insert" ? Math.max(0, piece.newLines.length - piece.foldedLines) : piece.newLines.length;
338
331
  const metrics: RMetrics = {
339
332
  edits_attempted: 1,
340
333
  edits_noop: piece.noop ? 1 : 0,
@@ -486,20 +479,12 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
486
479
  planned = planEdit(base.content, input.hedit, base.hashes, {
487
480
  filePath: displayPath,
488
481
  servedHashes: runtime.served,
489
- skipBoundaryDedup: input.skipBoundaryDedup,
490
- strictBoundaryDedup: input.strictBoundaryDedup,
491
482
  signal: input.signal,
492
483
  baseFileLines: base.baseLines,
493
484
  });
494
485
  } catch (error) {
495
486
  if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
496
487
  else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
497
- else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
498
- const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
499
- noteBatchFailure(input.member, indexed);
500
- discardBatchState(runtime);
501
- throw indexed;
502
- }
503
488
  noteBatchFailure(input.member, error);
504
489
  discardBatchState(runtime);
505
490
  throw error;
@@ -510,8 +495,6 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
510
495
  const baseLines = base.baseLines;
511
496
  const originalSlice = baseLines.slice(start - 1, end);
512
497
  const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
513
- const fixes = planned.autoFixes ?? [];
514
- const dedupRows = dedupRowsFromFixes(fixes);
515
498
  const piece: BatchPiece = {
516
499
  order: input.member.order,
517
500
  kind: input.kind,
@@ -522,9 +505,6 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
522
505
  toHash: input.hedit.hash_bounds[1].hash,
523
506
  newLines: [...newLines],
524
507
  warnings: [...input.extraWarnings, ...planned.warnings],
525
- autoFixes: fixes.length,
526
- dedupAbove: dedupRows.above,
527
- dedupBelow: dedupRows.below,
528
508
  noop,
529
509
  foldedLines: input.foldedLines ?? 0,
530
510
  };
@@ -585,23 +565,12 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
585
565
  order: Math.min(before.order, after.order),
586
566
  newLines: [...before.newLines, ...after.newLines.slice(1)],
587
567
  warnings: [...before.warnings, ...after.warnings],
588
- autoFixes: before.autoFixes + after.autoFixes,
589
- dedupAbove: [...before.dedupAbove, ...after.dedupAbove],
590
- dedupBelow: [...before.dedupBelow, ...after.dedupBelow],
591
568
  foldedLines: before.foldedLines + after.foldedLines - 1,
592
569
  });
593
570
  }
594
571
  return merged;
595
572
  }
596
573
 
597
- function dedupCutNoops(pieces: BatchPiece[]): BatchPiece[] {
598
- return pieces.filter((piece) => piece.noop && piece.autoFixes > 0).sort((a, b) => a.order - b.order);
599
- }
600
-
601
- function dedupCutNoopWarning(noops: BatchPiece[]): string {
602
- return boundaryDedupNoopWarning(noops.map((piece) => piece.order), noops.reduce((sum, piece) => sum + piece.autoFixes, 0));
603
- }
604
-
605
574
  async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
606
575
  const runtime = batches.get(member.batchKey);
607
576
  if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
@@ -624,9 +593,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
624
593
  }
625
594
  const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
626
595
  if (appliedPieces.length === 0) {
627
- const dedupNoops = dedupCutNoops(runtime.pieces);
628
596
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
629
- return combinedNoop(paths.displayPath, member, runtime, snapshotId, dedupNoops);
597
+ return combinedNoop(paths.displayPath, member, runtime, snapshotId);
630
598
  }
631
599
  const effectivePieces = mergeInsertPairs(appliedPieces);
632
600
  const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
@@ -641,10 +609,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
641
609
  const composed = composeBatchLines(base.content, effectivePieces);
642
610
  const warnings = [...runtime.warnings];
643
611
  if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
644
- const dedupTotal = effectivePieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
645
- if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
646
- const dedupNoops = dedupCutNoops(runtime.pieces);
647
- if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
648
612
  try {
649
613
  await throwIfStrictInput(dedupeWarnings(warnings));
650
614
  assertNotEmpty(base.content, composed);
@@ -658,7 +622,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
658
622
  }
659
623
  if (composed === base.content) {
660
624
  const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
661
- return combinedNoop(paths.displayPath, member, runtime, snapshotId, dedupNoops);
625
+ return combinedNoop(paths.displayPath, member, runtime, snapshotId);
662
626
  }
663
627
  abortIf(signal);
664
628
  let currentRaw: string;
@@ -706,7 +670,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
706
670
  throw error;
707
671
  }
708
672
  const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
709
- const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length, dedupAbove: piece.dedupAbove, dedupBelow: piece.dedupBelow }));
673
+ const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
710
674
  let resultHashes: string[];
711
675
  try {
712
676
  resultHashes = await lineHashes(composed, runtime.target, {
@@ -723,11 +687,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
723
687
  let removed = 0;
724
688
  for (const piece of appliedPieces) {
725
689
  removed += piece.end - piece.start + 1;
726
- const gross = Math.max(0, piece.newLines.length - piece.autoFixes);
727
- added += piece.kind === "insert" ? Math.max(0, gross - piece.foldedLines) : gross;
690
+ added += piece.kind === "insert" ? Math.max(0, piece.newLines.length - piece.foldedLines) : piece.newLines.length;
728
691
  }
729
- const dedupAboveRows = ordered.flatMap((piece) => piece.dedupAbove);
730
- const dedupBelowRows = ordered.flatMap((piece) => piece.dedupBelow);
731
692
  const header = batchHeader(member);
732
693
  const changed = buildChanged(
733
694
  {
@@ -746,8 +707,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
746
707
  addedLines: added,
747
708
  removedLines: removed,
748
709
  },
749
- boundaryDedupAbove: dedupAboveRows,
750
- boundaryDedupBelow: dedupBelowRows,
751
710
  spans,
752
711
  },
753
712
  batchVerb(runtime),
@@ -766,10 +725,9 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
766
725
  return changed;
767
726
  }
768
727
 
769
- async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined, dedupNoops: BatchPiece[]): Promise<TResult> {
728
+ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
770
729
  const executed = runtime.applied + runtime.noops;
771
730
  const warnings = [...runtime.warnings];
772
- if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
773
731
  const noop = buildNoop(
774
732
  {
775
733
  path,
@@ -782,7 +740,6 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
782
740
  removedLines: 0,
783
741
  },
784
742
  warnings: dedupeWarnings(warnings),
785
- boundaryRemovedLines: 0,
786
743
  },
787
744
  "Batch",
788
745
  );
package/src/commit.ts CHANGED
@@ -1,15 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { PipelineResult } from "./replace";
3
3
  import { abortIf, assertByteLimit, errCode, splitLines } from "./utils";
4
- import { DEDUP_ANCHOR } from "./constants";
5
- import { HASH_SEP } from "./hashline";
6
4
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
7
5
  import { saveUndo } from "./replace-undo";
8
6
  import { getDiffContextLines } from "./config";
9
7
  import { safeSnapId } from "./file-reader";
10
8
  import { writeAtomic } from "./fs-write";
11
9
  import { servedHashesFromDiff, serveRows } from "./served";
12
- import { lineHashes, type AutoFix } from "./hashline";
10
+ import { lineHashes } from "./hashline";
13
11
  import { spanForEdit } from "./replace";
14
12
  import { restoreEndings, stripBOM, toLF } from "./normalize";
15
13
  export interface CommitMeta {
@@ -24,27 +22,6 @@ export interface CommitMeta {
24
22
  foldedAnchorLines?: number;
25
23
  }
26
24
 
27
- export function boundaryDedupWarning(count: number): string {
28
- const noun = count === 1 ? "1 line" : `${count} lines`;
29
- const row = count === 1 ? "row" : "rows";
30
- return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
31
- }
32
-
33
- export function boundaryDedupNoopWarning(orders: number[], removedLines: number): string {
34
- const noun = removedLines === 1 ? "1 line" : `${removedLines} lines`;
35
- if (orders.length === 1) {
36
- return `Boundary dedup: edit #${orders[0]!} produced no change (${noun} not added again).`;
37
- }
38
- return `Boundary dedup: edits ${orders.map((order) => `#${order}`).join(", ")} produced no change (${noun} not added again).`;
39
- }
40
-
41
- export function dedupRowsFromFixes(fixes: AutoFix[]): { removedTexts: string[]; above: string[]; below: string[] } {
42
- const sorted = [...fixes].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
43
- const above = sorted.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before").map((fix) => fix.removedLine);
44
- const below = sorted.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after").map((fix) => fix.removedLine);
45
- return { removedTexts: sorted.map((fix) => fix.removedLine), above, below };
46
- }
47
-
48
25
  export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
49
26
  const { path, absolutePath, mutationTargetPath, signal } = meta;
50
27
  const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
@@ -64,7 +41,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
64
41
  removedLines: 0,
65
42
  },
66
43
  warnings,
67
- boundaryRemovedLines: pipe.boundaryRemovedLines,
68
44
  },
69
45
  meta.noopNoun,
70
46
  );
@@ -78,9 +54,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
78
54
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
79
55
  );
80
56
  }
81
- if (pipe.boundaryRemovedLineTexts.length > 0) {
82
- warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts.length));
83
- }
84
57
 
85
58
  abortIf(signal);
86
59
  let currentRaw: string | undefined;
@@ -154,8 +127,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
154
127
  warnings,
155
128
  snapshotId: updatedSnapshotId,
156
129
  editMeta,
157
- boundaryDedupAbove: pipe.boundaryDedupAbove,
158
- boundaryDedupBelow: pipe.boundaryDedupBelow,
159
130
  ...(span ? { spans: [span] } : {}),
160
131
  };
161
132
  const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
package/src/config-ui.ts CHANGED
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { readConfig, type Config } from "./config";
4
4
 
5
- export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
5
+ export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "diffContextLines";
6
6
 
7
7
  export interface ConfigRow {
8
8
  key: ConfigToggleKey;
@@ -25,7 +25,6 @@ export function configRows(config: Config): ConfigRow[] {
25
25
  { key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
26
26
  { key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
27
27
  { key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
28
- { key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "off") !== "off", mode: config.boundaryDedupMode ?? "off", cycle: ["on", "strict", "off"] },
29
28
  ];
30
29
  }
31
30
 
package/src/config.ts CHANGED
@@ -3,7 +3,6 @@ import { dirname } from "node:path";
3
3
  import { configPath } from "./paths";
4
4
  import { errCode, isRec } from "./utils";
5
5
  import { writeAtomic } from "./fs-write";
6
- export type BoundaryDedupMode = "on" | "off" | "strict";
7
6
  export type AutoReadAllMode = "off" | "on" | "git";
8
7
  const AUTO_READ_ALL_MODES: AutoReadAllMode[] = ["off", "on", "git"];
9
8
 
@@ -18,7 +17,6 @@ export interface Config {
18
17
  autoReadAllIgnore?: string[];
19
18
  requirePath?: boolean;
20
19
  strictInput?: boolean;
21
- boundaryDedupMode?: BoundaryDedupMode;
22
20
  diffContextLines?: number;
23
21
  }
24
22
 
@@ -29,19 +27,9 @@ const DEFAULT_CONFIG: Config = {
29
27
  autoReadAllIgnore: [],
30
28
  requirePath: false,
31
29
  strictInput: false,
32
- boundaryDedupMode: "off",
33
30
  diffContextLines: DEFAULT_DIFF_CONTEXT_LINES
34
31
  };
35
32
 
36
- const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
37
-
38
- function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
39
- if (mode === "on" || mode === "strict" || mode === "off") return mode;
40
- if (legacy === true) return "on";
41
- if (legacy === false) return "off";
42
- return DEFAULT_CONFIG.boundaryDedupMode ?? "off";
43
- }
44
-
45
33
  function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
46
34
  if (value === "off" || value === "on" || value === "git") return value;
47
35
  if (value === true) return "on";
@@ -87,8 +75,6 @@ function parseConfig(content: string): Config {
87
75
  const autoReadAll = parsed.autoReadAll;
88
76
  const requirePath = parsed.requirePath;
89
77
  const strictInput = parsed.strictInput;
90
- const boundaryDedupMode = parsed.boundaryDedupMode;
91
- const legacyBoundaryDedup = parsed.boundaryDedupEnabled;
92
78
  const diffContextLines = parsed.diffContextLines;
93
79
  const autoReadAllIgnore = parsed.autoReadAllIgnore;
94
80
  return {
@@ -97,7 +83,6 @@ function parseConfig(content: string): Config {
97
83
  autoReadAll: parseAutoReadAllMode(autoReadAll),
98
84
  requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
99
85
  strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
100
- boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
101
86
  diffContextLines: normalizeDiffContextLines(diffContextLines),
102
87
  autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
103
88
  };
@@ -226,15 +211,6 @@ export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
226
211
  }
227
212
  export const toggleRequirePath = (): Promise<boolean> => toggleFlag("requirePath");
228
213
  export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput");
229
- export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
230
- let next: BoundaryDedupMode = "off";
231
- await updateConfig((c) => {
232
- const current = c.boundaryDedupMode ?? "off";
233
- next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "off";
234
- c.boundaryDedupMode = next;
235
- });
236
- return next;
237
- }
238
214
  export async function getDiffContextLines(): Promise<number> {
239
215
  return normalizeDiffContextLines((await readConfig()).diffContextLines);
240
216
  }
package/src/constants.ts CHANGED
@@ -19,8 +19,6 @@ export const NUL_CONTENT_MSG =
19
19
  export const ANCHOR_POOL_EXHAUSTED_PREFIX =
20
20
  "[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
21
21
 
22
- export const DEDUP_ANCHOR = "dedup";
23
-
24
22
  export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
25
23
  export const AUTO_READ_ALL_MAX_FILES = 500;
26
24
  export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
@@ -1,9 +1,9 @@
1
1
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
2
  import { resolveInCwd } from "./fs-write";
3
3
  import { abortIf, makePrepareArguments } from "./utils";
4
- import { ownerOf } from "./anchor-registry";
4
+ import { ownerOf, ownersDifferingOnlyByCase, type OwnedAnchor } from "./anchor-registry";
5
5
  import { parseHashRef, stripAnchorRow } from "./hashline";
6
- import { readConfig, type BoundaryDedupMode } from "./config";
6
+ import { readConfig } from "./config";
7
7
  import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
8
8
  import type { ReplaceDetails } from "./replace";
9
9
  export const editPrepare = makePrepareArguments();
@@ -11,7 +11,6 @@ export const editPrepare = makePrepareArguments();
11
11
  export interface EditToolFlags {
12
12
  requirePath: boolean;
13
13
  strictInput: boolean;
14
- boundaryDedupMode: BoundaryDedupMode;
15
14
  autoRead: boolean;
16
15
  autoReadAllActive: boolean;
17
16
  }
@@ -19,7 +18,6 @@ export interface EditToolFlags {
19
18
  export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
20
19
  requirePath: false,
21
20
  strictInput: false,
22
- boundaryDedupMode: "off",
23
21
  autoRead: true,
24
22
  autoReadAllActive: false
25
23
  };
@@ -29,7 +27,6 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
29
27
  return {
30
28
  requirePath: config.requirePath === true,
31
29
  strictInput: config.strictInput === true,
32
- boundaryDedupMode: config.boundaryDedupMode ?? "off",
33
30
  autoRead: config.autoRead !== false,
34
31
  autoReadAllActive: (config.autoReadAll ?? "off") !== "off"
35
32
  };
@@ -40,7 +37,7 @@ export function withReplacePrompts(base: { description: string; snippet: string;
40
37
  const snippetParts = [base.snippet];
41
38
  let guidelines = [...base.guidelines];
42
39
  if (!flags.autoRead) {
43
- description = description.replace(" Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading.", "");
40
+ description = description.replace(/\n\nExample:[\s\S]*$/, "");
44
41
  guidelines = guidelines.filter((guideline) => !guideline.includes("post-edit diff"));
45
42
  description = description.replace("and the last call shows the combined diff,", "and the last call shows the combined result,");
46
43
  }
@@ -57,16 +54,6 @@ export function withReplacePrompts(base: { description: string; snippet: string;
57
54
  descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
58
55
  guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
59
56
  }
60
- if (flags.boundaryDedupMode !== "on") {
61
- guidelines = guidelines.filter((guideline) => !guideline.includes("deduplicated automatically"));
62
- }
63
- if (flags.boundaryDedupMode === "off") {
64
- descriptionParts.push("Boundary dedup is off: edits apply literally.");
65
- guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
66
- } else if (flags.boundaryDedupMode === "strict") {
67
- descriptionParts.push("Boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
68
- guidelines.push("`replace`: boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
69
- }
70
57
  return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
71
58
  }
72
59
 
@@ -108,14 +95,27 @@ export function withUndoPrompts(base: { description: string; snippet: string; gu
108
95
  const guidelines = base.guidelines.map((guideline) => guideline.includes("bad diff") ? "`undo_last_change`: only the last `replace`/`insert` per file is undoable; a `write` clears it, so undo right after a bad edit — review what you're restoring." : guideline);
109
96
  return { description: base.description, snippet: base.snippet, guidelines };
110
97
  }
98
+
99
+ function staleAnchorMessage(ref: string, hash: string, owners: Array<OwnedAnchor | undefined>): string {
100
+ const knownPaths = new Set<string>();
101
+ for (const owner of owners) {
102
+ if (owner) knownPaths.add(owner.path);
103
+ }
104
+ const folded = ownersDifferingOnlyByCase(hash, knownPaths);
105
+ const hint =
106
+ folded.length > 0
107
+ ? ` Anchors are case-sensitive; ${folded.map((match) => `"${match.anchor}"`).join(", ")} differs only in case.`
108
+ : "";
109
+ return `[E_STALE_ANCHOR] "${ref}" is not owned in this session.${hint} Call read() on the target file first.`;
110
+ }
111
+
111
112
  export function resolveEditTarget(removeFrom: string, removeTo?: string): string {
112
113
  const refs = [removeFrom, removeTo].filter((value): value is string => typeof value === "string");
113
- const owners = refs.map((ref) => ownerOf(parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash));
114
+ const hashes = refs.map((ref) => parseHashRef(stripAnchorRow(ref.trim(), "anchor entry")).hash);
115
+ const owners = hashes.map((hash) => ownerOf(hash));
114
116
  const missing = owners.findIndex((owner) => !owner);
115
117
  if (missing >= 0) {
116
- throw new Error(
117
- `[E_STALE_ANCHOR] "${refs[missing]!}" is not owned in this session. Call read() on the target file first.`,
118
- );
118
+ throw new Error(staleAnchorMessage(refs[missing]!, hashes[missing]!, owners));
119
119
  }
120
120
  const paths = new Set(owners.map((owner) => owner!.path));
121
121
  if (paths.size > 1) {
@@ -172,10 +172,6 @@ export async function throwIfStrictInput(warnings: string[]): Promise<void> {
172
172
  }
173
173
  }
174
174
 
175
- export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
176
- return (await readConfig()).boundaryDedupMode ?? "off";
177
- }
178
-
179
175
  export function editRenderCallWrapper(
180
176
  preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
181
177
  getInput?: (args: unknown) => { path?: string } | null,