pi-hashline-edit-pro 2.6.1 → 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,11 +96,13 @@ 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.
104
+ - Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
105
+ - Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
104
106
 
105
107
  ## Undo
106
108
 
@@ -120,6 +122,14 @@ Enabled by default. After a successful `write` that changes the file, the extens
120
122
  - 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).
121
123
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
122
124
 
125
+ ## Tool result details
126
+
127
+ All three tools return machine-readable metadata in `details` alongside the model-visible text:
128
+
129
+ - `read`: `details.truncation` (set when the output was truncated), `details.snapshotId` (a `v2|path|ino|mtime|ctime|size` fingerprint of the file), `details.nextOffset` (use as the next `offset`), and `details.metrics` with `truncated` and `next_offset`.
130
+ - `replace`: `details.diff` (the post-edit diff; `+HASH│` and ` HASH│` rows carry the current anchors), `details.patch` (a standard unified patch of the changes, for external tools), `details.firstChangedLine`, `details.snapshotId`, `details.classification` (`"noop"` when nothing changed), and `details.metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`.
131
+ - `undo_last_replace`: `details.diff` (the undo diff with the restored anchors), `details.patch` (a standard unified patch of the restored changes), and `details.metrics` (same shape as `replace`).
132
+
123
133
  ## Settings
124
134
 
125
135
  | Command | Description |
@@ -144,7 +154,7 @@ Unique anchors by construction. If a line's base hash collides with an already-a
144
154
 
145
155
  Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
146
156
 
147
- The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows). `replace` verifies every line of the resolved range against that record before writing; a line whose hash was never served means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
157
+ The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
148
158
 
149
159
  Two guarantees make this safe even with duplicated content:
150
160
 
@@ -171,6 +181,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
171
181
  | `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
172
182
  | `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
173
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. |
174
185
  | `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
175
186
 
176
187
  ## Troubleshooting
package/index.ts CHANGED
@@ -88,7 +88,7 @@ export default function (pi: ExtensionAPI): void {
88
88
  DEFAULT_MAX_BYTES,
89
89
  AUTO_READ_MAX,
90
90
  );
91
- await recordServedSafe(absolutePath, preview.servedHashes, "auto-read");
91
+ await recordServedSafe(absolutePath, preview.servedHashes, "auto-read", new Set(fileHashes));
92
92
  return {
93
93
  content: [
94
94
  ...(event.content ?? []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.6.1",
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",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "scripts": {
55
55
  "test": "vitest run",
56
+ "test:unit": "vitest run --config vitest.unit.config.ts",
56
57
  "test:watch": "vitest",
57
58
  "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
58
59
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.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
+ }
package/src/hash-store.ts CHANGED
@@ -1,13 +1,67 @@
1
1
  import { existsSync } from "fs";
2
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
3
- import { DatabaseSync } from "node:sqlite";
4
3
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
4
  import { errCode, isRec, splitLines } from "./utils";
6
5
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
6
  import { HASH_RE } from "./hashline/alphabet";
8
7
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
8
+
9
9
  type SqlParams = (string | number)[];
10
10
 
11
+ interface RawStatement {
12
+ get(...params: SqlParams): unknown;
13
+ all(...params: SqlParams): unknown;
14
+ run(...params: SqlParams): unknown;
15
+ }
16
+ interface RawDb {
17
+ exec(sql: string): void;
18
+ prepare(sql: string): RawStatement;
19
+ close(): void;
20
+ readonly isOpen: boolean;
21
+ }
22
+ export type SqliteEngine = "node:sqlite" | "bun:sqlite";
23
+
24
+ interface BunDbLike {
25
+ exec(sql: string): void;
26
+ prepare(sql: string): {
27
+ get(...params: SqlParams): unknown;
28
+ all(...params: SqlParams): unknown[];
29
+ run(...params: SqlParams): unknown;
30
+ };
31
+ close(): void;
32
+ }
33
+
34
+ let openDbFn: (path: string) => RawDb;
35
+ let sqliteEngine: SqliteEngine;
36
+
37
+ if (typeof process !== "undefined" && (process.versions as Record<string, string | undefined>).bun) {
38
+ const specifier = "bun:sqlite";
39
+ const mod = await import(specifier) as { Database: new (path: string) => BunDbLike };
40
+ sqliteEngine = "bun:sqlite";
41
+ openDbFn = (path) => {
42
+ const db = new mod.Database(path);
43
+ db.exec(`PRAGMA busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
44
+ let closed = false;
45
+ return {
46
+ exec: (sql) => db.exec(sql),
47
+ prepare: (sql) => {
48
+ const stmt = db.prepare(sql);
49
+ return {
50
+ get: (...p) => stmt.get(...p) ?? undefined,
51
+ all: (...p) => stmt.all(...p),
52
+ run: (...p) => stmt.run(...p),
53
+ };
54
+ },
55
+ close: () => { if (!closed) { closed = true; db.close(); } },
56
+ get isOpen() { return !closed; },
57
+ };
58
+ };
59
+ } else {
60
+ const { DatabaseSync } = await import("node:sqlite");
61
+ sqliteEngine = "node:sqlite";
62
+ openDbFn = (path) => new DatabaseSync(path, { timeout: HASH_STORE_BUSY_TIMEOUT }) as unknown as RawDb;
63
+ }
64
+
11
65
  interface Prepared {
12
66
  get: (...params: SqlParams) => Record<string, unknown> | undefined;
13
67
  allPaths: (...params: SqlParams) => Record<string, unknown>[];
@@ -24,7 +78,7 @@ interface Prepared {
24
78
 
25
79
  export interface HashStore {
26
80
  readonly stmts: Prepared;
27
- readonly engine: "node:sqlite";
81
+ readonly engine: SqliteEngine;
28
82
  }
29
83
 
30
84
  export interface UndoRecord {
@@ -116,11 +170,11 @@ function withBusyRetry<T>(fn: () => T): T {
116
170
  throw lastError;
117
171
  }
118
172
 
119
- function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prepared } {
173
+ function openDbWithBusyRetry(storePath: string): { db: RawDb; stmts: Prepared } {
120
174
  return withBusyRetry(() => openDb(storePath));
121
175
  }
122
176
 
123
- let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
177
+ let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
124
178
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
125
179
  let exitHandlerRegistered = false;
126
180
  interface SnapshotCacheEntry {
@@ -130,10 +184,8 @@ interface SnapshotCacheEntry {
130
184
  }
131
185
  const snapshotCache = new Map<string, SnapshotCacheEntry>();
132
186
  export const SNAPSHOT_CACHE_LIMIT = 256;
133
- function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
134
- const db = new DatabaseSync(storePath, {
135
- timeout: HASH_STORE_BUSY_TIMEOUT,
136
- });
187
+ function openDb(storePath: string): { db: RawDb; stmts: Prepared } {
188
+ const db = openDbFn(storePath);
137
189
  try {
138
190
  return buildStore(db);
139
191
  } catch (error) {
@@ -145,8 +197,8 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
145
197
  }
146
198
 
147
199
  function buildStore(
148
- db: DatabaseSync,
149
- ): { db: DatabaseSync; stmts: Prepared } {
200
+ db: RawDb,
201
+ ): { db: RawDb; stmts: Prepared } {
150
202
  db.exec("PRAGMA journal_mode = WAL");
151
203
  db.exec("PRAGMA synchronous = NORMAL");
152
204
  db.exec(
@@ -229,7 +281,7 @@ function buildStore(
229
281
  return { db, stmts };
230
282
  }
231
283
 
232
- function isHealthy(db: DatabaseSync): boolean {
284
+ function isHealthy(db: RawDb): boolean {
233
285
  try {
234
286
  const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
235
287
  return row?.quick_check === "ok";
@@ -252,7 +304,7 @@ async function quarantineStore(storePath: string): Promise<void> {
252
304
  }
253
305
  }
254
306
 
255
- function shutdownDb(db: DatabaseSync): void {
307
+ function shutdownDb(db: RawDb): void {
256
308
  try {
257
309
  db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
258
310
  } catch {
@@ -267,7 +319,7 @@ async function openStore(storePath: string): Promise<HashStore> {
267
319
  await mkdir(hashStoreDir(), { recursive: true });
268
320
 
269
321
  let existed = existsSync(storePath);
270
- let opened: { db: DatabaseSync; stmts: Prepared };
322
+ let opened: { db: RawDb; stmts: Prepared };
271
323
  try {
272
324
  opened = openDbWithBusyRetry(storePath);
273
325
  } catch (error) {
@@ -305,13 +357,13 @@ async function openStore(storePath: string): Promise<HashStore> {
305
357
  }
306
358
  }
307
359
 
308
- return { stmts, engine: "node:sqlite" };
360
+ return { stmts, engine: sqliteEngine };
309
361
  }
310
362
 
311
363
  export function loadHashStore(): Promise<HashStore> {
312
364
  const storePath = hashStorePath();
313
365
  if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
314
- return Promise.resolve({ stmts: cachedDb.stmts, engine: "node:sqlite" });
366
+ return Promise.resolve({ stmts: cachedDb.stmts, engine: sqliteEngine });
315
367
  }
316
368
  if (opening && opening.path === storePath) {
317
369
  return opening.promise;
@@ -347,7 +399,7 @@ function withStore(fn: () => void): void {
347
399
  });
348
400
  }
349
401
 
350
- async function migrateLegacy(db: DatabaseSync): Promise<void> {
402
+ async function migrateLegacy(db: RawDb): Promise<void> {
351
403
  const legacyPath = legacyHashStorePath();
352
404
  let content: string;
353
405
  try {
@@ -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/read.ts CHANGED
@@ -216,7 +216,7 @@ export function regRead(pi: ExtensionAPI): void {
216
216
  fileHashes,
217
217
  resolvedPath,
218
218
  );
219
- await recordServedSafe(resolvedPath, preview.servedHashes, "read");
219
+ await recordServedSafe(resolvedPath, preview.servedHashes, "read", new Set(fileHashes));
220
220
  const snapshotId = await safeSnapId(absolutePath, "read");
221
221
  const previewText =
222
222
  hadUtf8DecodeErrors
@@ -139,3 +139,14 @@ export function genDiff(
139
139
 
140
140
  return { diff: output.join("\n"), firstChangedLine };
141
141
  }
142
+
143
+ export function genPatch(
144
+ path: string,
145
+ oldContent: string,
146
+ newContent: string,
147
+ ): string {
148
+ return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
149
+ context: 4,
150
+ headerOptions: Diff.FILE_HEADERS_ONLY,
151
+ });
152
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ReplaceDetails } from "./replace";
2
- import { genDiff } from "./replace-diff";
2
+ import { genDiff, genPatch } from "./replace-diff";
3
3
  import { visLines, clipLine } from "./utils";
4
4
 
5
5
  type TResult = {
@@ -114,6 +114,7 @@ export function buildNoop(input: NoopInput): TResult {
114
114
  content: [{ type: "text", text }],
115
115
  details: {
116
116
  diff: "",
117
+ patch: "",
117
118
  firstChangedLine: undefined,
118
119
  snapshotId,
119
120
  classification: "noop" as const,
@@ -154,6 +155,7 @@ export function buildChanged(input: SuccessInput): TResult {
154
155
  content: [{ type: "text", text }],
155
156
  details: {
156
157
  diff: diffResult.diff,
158
+ patch: genPatch(path, originalNormalized, result),
157
159
  firstChangedLine:
158
160
  editMeta.firstChangedLine ?? diffResult.firstChangedLine,
159
161
  snapshotId,
@@ -7,7 +7,7 @@ import { recordServedDiff } from "./served";
7
7
  import { contentChecksum } from "./hashline/hasher";
8
8
  import { resolveTarget, writeAtomic } from "./fs-write";
9
9
  import { toCwd } from "./paths";
10
- import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
10
+ import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
11
11
  import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
@@ -170,7 +170,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
170
170
  try {
171
171
  const store = await loadHashStore();
172
172
  upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
173
- recordServedDiff(store, mutationTargetPath, undoDiff);
173
+ recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
174
174
  } catch (error) {
175
175
  console.error("Failed to restore hash store snapshot after undo:", error);
176
176
  }
@@ -198,6 +198,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
198
198
  ],
199
199
  details: {
200
200
  diff: undoDiff,
201
+ patch: genPatch(path, currentNormalized, undo.content),
201
202
  metrics: buildMetrics({
202
203
  classification: "applied",
203
204
  editsAttempted: 1,
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({
@@ -85,6 +86,7 @@ export type ReqParams = {
85
86
 
86
87
  export type ReplaceDetails = {
87
88
  diff: string;
89
+ patch?: string;
88
90
  firstChangedLine?: number;
89
91
  snapshotId?: string;
90
92
  classification?: "noop";
@@ -106,6 +108,7 @@ interface PipelineResult {
106
108
  resultHashes: string[];
107
109
  totalAddedLines: number;
108
110
  totalRemovedLines: number;
111
+ hadBoundaryDedup: boolean;
109
112
  }
110
113
 
111
114
  const PREVIEW_DEBOUNCE_MS = 150;
@@ -178,6 +181,7 @@ export interface ExecPipelineOptions {
178
181
  signal?: AbortSignal;
179
182
  store?: HashStore;
180
183
  noPersist?: boolean;
184
+ skipBoundaryDedup?: boolean;
181
185
  }
182
186
 
183
187
  function collectRemovedHashes(
@@ -251,13 +255,14 @@ export async function execPipeline(
251
255
  originalHashes,
252
256
  path,
253
257
  served,
258
+ options?.skipBoundaryDedup,
254
259
  );
255
260
  } catch (error) {
256
261
  if (options?.noPersist !== true) {
257
262
  if (error instanceof RangeStaleError) {
258
- await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback");
263
+ await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(originalHashes));
259
264
  } else if (error instanceof AnchorMismatchError) {
260
- await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback");
265
+ await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(originalHashes));
261
266
  }
262
267
  }
263
268
  throw error;
@@ -297,6 +302,7 @@ export async function execPipeline(
297
302
  originalHashes,
298
303
  totalAddedLines,
299
304
  totalRemovedLines,
305
+ hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
300
306
  };
301
307
  }
302
308
 
@@ -476,6 +482,8 @@ export function buildToolDef(): ToolDef {
476
482
  const path = normalizedParams.path;
477
483
  const absolutePath = toCwd(path, ctx.cwd);
478
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);
479
487
  return withFileMutationQueue(mutationTargetPath, async () => {
480
488
  abortIf(signal);
481
489
 
@@ -491,21 +499,28 @@ export function buildToolDef(): ToolDef {
491
499
  firstChangedLine,
492
500
  lastChangedLine,
493
501
  resultHashes,
502
+ hadBoundaryDedup,
494
503
  totalAddedLines,
495
504
  totalRemovedLines,
496
505
  } = await execPipeline(
497
506
  normalizedParams,
498
507
  ctx.cwd,
499
- { accessMode: constants.R_OK | constants.W_OK, signal },
508
+ { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
500
509
  );
501
510
 
502
511
  if (resolution) {
503
512
  warnings.unshift(resolution.warning);
504
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
+ }
505
517
 
506
518
  const editsAttempted = 1;
507
519
  if (originalNormalized === result) {
508
520
  const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
521
+ if (hadBoundaryDedup) {
522
+ markBoundaryNoop(mutationTargetPath, noopPayload);
523
+ }
509
524
  return buildNoop({
510
525
  path,
511
526
  noopEdit,
@@ -549,6 +564,7 @@ export function buildToolDef(): ToolDef {
549
564
  await undo.restore();
550
565
  throw error;
551
566
  }
567
+ clearBoundaryBypass(mutationTargetPath);
552
568
  const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
553
569
 
554
570
  const editMeta: RMeta = {
@@ -572,7 +588,7 @@ export function buildToolDef(): ToolDef {
572
588
  };
573
589
  const changed = buildChanged(successInput);
574
590
  if (changed.details.diff) {
575
- await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff");
591
+ await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(resultHashes));
576
592
  }
577
593
  return changed;
578
594
  });
package/src/served.ts CHANGED
@@ -20,22 +20,41 @@ export function getServed(store: HashStore, path: string): Set<string> | undefin
20
20
  return new Set(parsed);
21
21
  }
22
22
 
23
- export function recordServed(store: HashStore, path: string, hashes: string[]): void {
24
- if (hashes.length === 0) return;
25
- const existing = getServed(store, path) ?? new Set<string>();
23
+ export function recordServed(
24
+ store: HashStore,
25
+ path: string,
26
+ hashes: string[],
27
+ scope?: ReadonlySet<string>,
28
+ ): void {
29
+ const existing = getServed(store, path);
30
+ if (!existing && hashes.length === 0) return;
31
+ const set = existing ?? new Set<string>();
26
32
  let changed = false;
33
+ if (scope) {
34
+ for (const hash of set) {
35
+ if (!scope.has(hash)) {
36
+ set.delete(hash);
37
+ changed = true;
38
+ }
39
+ }
40
+ }
27
41
  for (const hash of hashes) {
28
- if (!existing.has(hash)) {
29
- existing.add(hash);
42
+ if (!set.has(hash)) {
43
+ set.add(hash);
30
44
  changed = true;
31
45
  }
32
46
  }
33
47
  if (!changed) return;
34
- store.stmts.servedUpsert(path, JSON.stringify([...existing]), Date.now());
48
+ store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
35
49
  }
36
50
 
37
- export function recordServedDiff(store: HashStore, path: string, diff: string): void {
38
- recordServed(store, path, servedHashesFromDiff(diff));
51
+ export function recordServedDiff(
52
+ store: HashStore,
53
+ path: string,
54
+ diff: string,
55
+ scope?: ReadonlySet<string>,
56
+ ): void {
57
+ recordServed(store, path, servedHashesFromDiff(diff), scope);
39
58
  }
40
59
 
41
60
  export function clearServed(store: HashStore, path: string): void {
@@ -46,11 +65,12 @@ export async function recordServedSafe(
46
65
  path: string,
47
66
  hashes: string[],
48
67
  context: string,
68
+ scope?: ReadonlySet<string>,
49
69
  ): Promise<void> {
50
- if (hashes.length === 0) return;
70
+ if (hashes.length === 0 && !scope) return;
51
71
  try {
52
72
  const store = await loadHashStore();
53
- recordServed(store, path, hashes);
73
+ recordServed(store, path, hashes, scope);
54
74
  } catch (error) {
55
75
  console.error(`Failed to record served state (${context}):`, error);
56
76
  }
@@ -60,7 +80,8 @@ export async function recordServedDiffSafe(
60
80
  path: string,
61
81
  diff: string,
62
82
  context: string,
83
+ scope?: ReadonlySet<string>,
63
84
  ): Promise<void> {
64
85
  if (!diff) return;
65
- await recordServedSafe(path, servedHashesFromDiff(diff), context);
86
+ await recordServedSafe(path, servedHashesFromDiff(diff), context, scope);
66
87
  }