pi-hashline-edit-pro 0.18.0 → 0.18.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
@@ -13,7 +13,7 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
13
13
  This fork makes two changes that compound:
14
14
 
15
15
  1. **3-character hash length** over a 64-char URL-safe base64 alphabet (up from 2 characters in the upstream), expanding the hash space from 256 to 262,144 buckets.
16
- 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
16
+ 2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (32KB, 262,144 bits) using a hint cursor for O(1) amortized lookup. This ensures every line gets a unique anchor, even within a 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
17
17
 
18
18
  ## Installation
19
19
 
@@ -142,7 +142,9 @@ The file is created automatically when any setting is toggled. Both fields are i
142
142
 
143
143
  - **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
144
144
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
145
- - **Strict patch content.** If `content_lines` contains `+HASH│` display prefixes (or `-N ` numbered deletion rows), the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
145
+ - **Strict patch content.** If `content_lines` contains diff-preview rows — `+HASH│` addition prefixes, `-HASH│` or `- │` deletion rows (the padded format the diff preview emits), or `-N ` numbered deletion rows the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
146
+
147
+ - **BOM preservation.** A UTF-8 BOM is stripped for display and hashing but restored on write, so edits (and undo) never silently strip a BOM from a file that has one.
146
148
  - **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
147
149
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
148
150
  - **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
@@ -156,7 +158,7 @@ The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squin
156
158
 
157
159
  Before hashing, each line is normalized: carriage returns are stripped and trailing whitespace is trimmed. This `canon()` normalization prevents insignificant whitespace changes from cascade-triggering hash churn across the file. Two lines that differ only in trailing spaces or `\r` characters produce the same hash, so anchor stability is preserved across editor-save cycles that add or remove trailing whitespace.
158
160
 
159
- **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
161
+ **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the next available hash is assigned from a bitset (32KB, 262,144 bits) using a hint cursor for O(1) amortized lookup. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
160
162
  The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
161
163
  `HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
162
164
 
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { initHasher } from "./src/hashline";
3
3
  import { regReplace, regReplaceFlat } from "./src/replace";
4
- import { regReplaceUndo } from "./src/replace-undo";
4
+ import { regReplaceUndo, clearUndo } from "./src/replace-undo";
5
5
  import { regRead, fmtReadPreview } from "./src/read";
6
6
  import { visLines } from "./src/utils";
7
7
  import { AUTO_READ_MAX } from "./src/constants";
@@ -12,7 +12,8 @@ import {
12
12
  } from "./src/config";
13
13
  import { loadHashStore, pruneMissing } from "./src/hash-store";
14
14
  import { readNormFile } from "./src/file-reader";
15
-
15
+ import { toCwd } from "./src/paths";
16
+ import { resolveTarget } from "./src/fs-write";
16
17
  function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
17
18
  if (mode === "flat") {
18
19
  regReplaceFlat(pi, autoRead);
@@ -73,8 +74,18 @@ export default function (pi: ExtensionAPI): void {
73
74
  });
74
75
 
75
76
  pi.on("tool_result", async (event, ctx) => {
76
- if (!autoRead) return;
77
77
  if (event.isError) return;
78
+ if (event.toolName === "write") {
79
+ const writtenPath = (event.input as Record<string, unknown>)?.path;
80
+ if (typeof writtenPath === "string") {
81
+ try {
82
+ clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
83
+ } catch (error) {
84
+ console.error("Failed to clear undo after write:", error);
85
+ }
86
+ }
87
+ }
88
+ if (!autoRead) return;
78
89
  if (event.toolName !== "write" && event.toolName !== "replace") return;
79
90
 
80
91
  const filePath = (event.input as Record<string, unknown>)?.path;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
6
6
  "main": "index.ts",
@@ -40,6 +40,9 @@
40
40
  "@earendil-works/pi-coding-agent": ">=0.74.0",
41
41
  "@earendil-works/pi-tui": "*"
42
42
  },
43
+ "engines": {
44
+ "node": ">=22.13.0"
45
+ },
43
46
  "scripts": {
44
47
  "test": "vitest run",
45
48
  "test:watch": "vitest",
@@ -1 +1,2 @@
1
- - `read`: call before `replace` when you need fresh HASH anchors for a file.
1
+ - `read`: call before `replace` when you need fresh HASH anchors for a file.
2
+ - `read`: call again after any edit to that file — changed lines get new anchors.
package/prompts/read.md CHANGED
@@ -1,3 +1 @@
1
- Read a text file. Each line returned as HASH│content (3-char URL-safe base64 hash). No line numbers — use the 3-char HASH to reference lines in replace calls.
2
-
3
- Text → HASH│content lines. Images → visual attachments. Binary/directory → rejected. Empty → HASH│ (replace to insert). Pageable with offset/limit. BOM stripped; non-UTF-8 shown as U+FFFD.
1
+ Read a text file; each line returned as HASH│content with a 3-char URL-safe base64 hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
@@ -1,2 +1,2 @@
1
- - `replace`: Provide hash_range_inclusive [start_hash, end_hash] targeting lines by their 3-char hashes (replaces every line from first anchor through last anchor inclusively), and content_lines as a native JSON array of strings for the replacement text.
2
- - `replace`: preserve leading whitespace exactly as it appears after in read output.
1
+ - `replace`: content_lines is a native JSON array of strings never a serialized JSON string; strip the HASH│ prefix from read output and keep leading whitespace exactly as shown after │; no line numbers or diff markers.
2
+ - `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file; on [E_STALE_ANCHOR], re-read the file and retry with fresh anchors.
@@ -1,2 +1 @@
1
- - `undo_last_replace`: call with the file path to revert the last replace on that file.
2
- - `undo_last_replace`: only the most recent replace per file is tracked.
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.
package/src/constants.ts CHANGED
@@ -6,7 +6,7 @@ export const MAX_HASH_LINES = 1_000_000;
6
6
  export const MAX_HASH_RETRIES = 262_144;
7
7
 
8
8
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
9
- export const HASH_STORE_VERSION = 2;
9
+ export const HASH_STORE_VERSION = 3;
10
10
  export const CONTENT_LINES_NOT_STRING_MSG =
11
11
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
12
12
  + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
package/src/file-kind.ts CHANGED
@@ -81,7 +81,7 @@ export async function loadFileKindAndText(
81
81
  }
82
82
 
83
83
 
84
- const decoder = new TextDecoder("utf-8", { fatal: false });
84
+ const decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
85
85
  let hadUtf8DecodeErrors = false;
86
86
  const parts: string[] = [];
87
87
 
package/src/hash-store.ts CHANGED
@@ -2,10 +2,9 @@ import { existsSync } from "fs";
2
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
- import { errCode } from "./utils";
5
+ import { errCode, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
7
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
8
-
9
8
  type SqlParams = (string | number)[];
10
9
 
11
10
  interface Prepared {
@@ -37,7 +36,7 @@ function isValidSnapshot(value: unknown): value is LegacySnapshot {
37
36
  }
38
37
 
39
38
  let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
40
-
39
+ let exitHandlerRegistered = false;
41
40
  function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
42
41
  const db = new DatabaseSync(storePath, {
43
42
  timeout: HASH_STORE_BUSY_TIMEOUT,
@@ -92,11 +91,27 @@ export async function loadHashStore(): Promise<HashStore> {
92
91
  }
93
92
 
94
93
  cachedDb = { path: storePath, db, stmts };
94
+
95
+ if (!exitHandlerRegistered) {
96
+ exitHandlerRegistered = true;
97
+ process.once("exit", () => shutdownHashStore());
98
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
99
+ process.once(sig, () => {
100
+ shutdownHashStore();
101
+ process.kill(process.pid, sig);
102
+ });
103
+ }
104
+ }
105
+
95
106
  return { stmts, engine: "node:sqlite" };
96
107
  }
97
108
 
98
109
  export function shutdownHashStore(): void {
99
110
  if (cachedDb) {
111
+ try {
112
+ cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
113
+ } catch {
114
+ }
100
115
  cachedDb.db.close();
101
116
  cachedDb = null;
102
117
  }
@@ -145,12 +160,11 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
145
160
  rows.push([
146
161
  key,
147
162
  contentChecksum(value.content),
148
- value.content.split("\n").length,
163
+ splitLines(value.content).length,
149
164
  JSON.stringify(value.hashes),
150
165
  Date.now(),
151
166
  ]);
152
167
  }
153
-
154
168
  if (rows.length > 0) {
155
169
  db.exec("BEGIN IMMEDIATE");
156
170
  try {
@@ -178,7 +192,7 @@ export function getSnapshot(
178
192
  content: string,
179
193
  ): string[] | undefined {
180
194
  const checksum = contentChecksum(content);
181
- const lineCount = content.split("\n").length;
195
+ const lineCount = splitLines(content).length;
182
196
  const row = store.stmts.get(path, checksum, lineCount);
183
197
  return row ? (JSON.parse(row.hashes as string) as string[]) : undefined;
184
198
  }
@@ -1,4 +1,4 @@
1
- import { abortIf, visLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
1
+ import { abortIf, splitLines, lastNonEmptyIndex, firstNonEmptyIndex } from "../utils";
2
2
  import { _lineHashesPure, HASH_SEP } from "./hash";
3
3
  import {
4
4
  valEdits,
@@ -18,22 +18,22 @@ type LIdx = {
18
18
  };
19
19
 
20
20
  export function buildIdx(content: string): LIdx {
21
- const fileLines = content.split("\n");
22
- const lineStarts: number[] = [];
23
- let offset = 0;
24
-
25
- for (let index = 0; index < fileLines.length; index++) {
26
- lineStarts.push(offset);
27
- offset += fileLines[index]!.length;
28
- if (index < fileLines.length - 1) {
29
- offset += 1;
30
- }
31
- }
21
+ const fileLines = splitLines(content);
22
+ const lineStarts: number[] = [];
23
+ let offset = 0;
24
+
25
+ for (let index = 0; index < fileLines.length; index++) {
26
+ lineStarts.push(offset);
27
+ offset += fileLines[index]!.length;
28
+ if (index < fileLines.length - 1) {
29
+ offset += 1;
30
+ }
31
+ }
32
32
 
33
- return {
34
- fileLines,
35
- lineStarts,
36
- };
33
+ return {
34
+ fileLines,
35
+ lineStarts,
36
+ };
37
37
  };
38
38
 
39
39
  type RESpan = {
@@ -124,16 +124,26 @@ function resToSpan(
124
124
  };
125
125
  }
126
126
 
127
+ if (content.endsWith("\n")) {
128
+ return {
129
+ kind: "replace",
130
+ index,
131
+ label,
132
+ start: lineStarts[startLine - 1]!,
133
+ end: content.length,
134
+ replacement: "",
135
+ };
136
+ }
137
+
127
138
  return {
128
139
  kind: "replace",
129
140
  index,
130
141
  label,
131
142
  start: Math.max(0, lineStarts[startLine - 1]! - 1),
132
- end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
143
+ end: content.length,
133
144
  replacement: "",
134
145
  };
135
146
  }
136
-
137
147
  function assertNoConflict(spans: RESpan[]): void {
138
148
  for (let leftIndex = 0; leftIndex < spans.length; leftIndex++) {
139
149
  const left = spans[leftIndex]!;
@@ -375,14 +385,14 @@ export function changedRange(
375
385
  if (original.length === 0) {
376
386
  return {
377
387
  firstChangedLine: 1,
378
- lastChangedLine: visLines(result).length,
388
+ lastChangedLine: splitLines(result).length,
379
389
  };
380
390
  }
381
391
 
382
392
  if (result.startsWith(original) && original.endsWith("\n")) {
383
393
  return {
384
- firstChangedLine: visLines(original).length + 1,
385
- lastChangedLine: visLines(result).length,
394
+ firstChangedLine: splitLines(original).length + 1,
395
+ lastChangedLine: splitLines(result).length,
386
396
  };
387
397
  }
388
398
 
@@ -415,7 +425,7 @@ export function changedRange(
415
425
  const firstChangedLine = idxToLine(firstDiff + 1, result);
416
426
  let lastChangedLine: number;
417
427
  if (lastRes < firstDiff) {
418
- lastChangedLine = result.length === 0 ? 1 : visLines(result).length;
428
+ lastChangedLine = result.length === 0 ? 1 : splitLines(result).length;
419
429
  } else if (
420
430
  firstDiff === 0 &&
421
431
  original.length > 0 &&
@@ -1,4 +1,4 @@
1
- import { MAX_HASH_RETRIES } from "../constants";
1
+ import { splitLines } from "../utils";
2
2
  import {
3
3
  loadHashStore,
4
4
  type HashStore,
@@ -6,7 +6,6 @@ import {
6
6
  upsertSnapshot,
7
7
  } from "../hash-store";
8
8
  import { xxh32, contentChecksum, initHasher } from "./hasher";
9
-
10
9
  export { initHasher };
11
10
 
12
11
  export const HASH_LEN = 3;
@@ -22,58 +21,111 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
22
21
  const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
23
22
  export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
24
23
 
25
- function h2s(h: number): string {
26
- const totalBits = HASH_LEN * ALPH_BITS;
27
- const shift = 32 - totalBits;
28
- const n = h >>> shift;
29
- let out = "";
30
- for (let j = 0; j < HASH_LEN; j++) {
31
- out +=
32
- ALPH[
33
- (n >>> ((HASH_LEN - 1 - j) * ALPH_BITS)) &
34
- ALPH_MASK
35
- ]!;
36
- }
37
- return out;
24
+ function idxToHash(idx: number): string {
25
+ let out = "";
26
+ for (let j = 0; j < HASH_LEN; j++) {
27
+ out += ALPH[(idx >>> ((HASH_LEN - 1 - j) * ALPH_BITS)) & ALPH_MASK]!;
28
+ }
29
+ return out;
38
30
  }
39
31
 
32
+ const HASH_TABLE: string[] = Array.from(
33
+ { length: 262_144 },
34
+ (_, i) => idxToHash(i),
35
+ );
36
+
40
37
  export const HL_PREFIX_RE = new RegExp(
41
38
  `^\\s*(?:>>>|>>)?\\s*${HASH_CLASS}│`,
42
39
  );
43
40
  export const HL_PREFIX_PLUS_RE = new RegExp(
44
41
  `^\\+\\s*${HASH_CLASS}│`,
45
42
  );
43
+ export const HL_PREFIX_MINUS_RE = new RegExp(
44
+ `^-(?:\\s*${HASH_CLASS}│| {${ANCHOR_LEN}}│)`,
45
+ );
46
46
  export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
47
47
 
48
48
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
49
49
 
50
-
51
50
  function canon(line: string): string {
52
51
  return line.replace(/\r/g, "").trimEnd();
53
52
  }
54
53
 
55
- function nextUniqueHash(content: string, used: Set<string>): string {
56
- let retry = 0;
57
- let hash = h2s(xxh32(content));
58
- while (used.has(hash)) {
59
- retry++;
60
- if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
61
- hash = h2s(xxh32(`${content}:R${retry}`));
62
- }
63
- used.add(hash);
64
- return hash;
54
+ const BITSET_WORDS = 8192;
55
+
56
+ function getBit(bits: Uint32Array, idx: number): boolean {
57
+ return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
58
+ }
59
+
60
+ function setBit(bits: Uint32Array, idx: number): void {
61
+ bits[idx >>> 5] |= 1 << (idx & 31);
62
+ }
63
+
64
+ function nextZeroBit(bits: Uint32Array, start: number): number {
65
+ const totalWords = bits.length;
66
+ const totalBits = totalWords * 32;
67
+
68
+ if (start >= totalBits) start = 0;
69
+
70
+ const wordIdx = start >>> 5;
71
+ const bitOffset = start & 31;
72
+
73
+ let word = bits[wordIdx];
74
+ for (let b = bitOffset; b < 32; b++) {
75
+ if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
76
+ }
77
+
78
+ for (let w = wordIdx + 1; w < totalWords; w++) {
79
+ word = bits[w];
80
+ if (~word !== 0) {
81
+ for (let b = 0; b < 32; b++) {
82
+ if ((word >>> b & 1) === 0) return w * 32 + b;
83
+ }
84
+ }
85
+ }
86
+
87
+ for (let w = 0; w < wordIdx; w++) {
88
+ word = bits[w];
89
+ if (~word !== 0) {
90
+ for (let b = 0; b < 32; b++) {
91
+ if ((word >>> b & 1) === 0) return w * 32 + b;
92
+ }
93
+ }
94
+ }
95
+
96
+ word = bits[wordIdx];
97
+ for (let b = 0; b < bitOffset; b++) {
98
+ if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
99
+ }
100
+
101
+ throw new Error("Hash space exhausted");
102
+ }
103
+
104
+ function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
105
+ if (!getBit(used, baseIdx)) {
106
+ setBit(used, baseIdx);
107
+ hint.value = baseIdx + 1;
108
+ return HASH_TABLE[baseIdx];
109
+ }
110
+ const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
111
+ const nextIdx = nextZeroBit(used, start);
112
+ setBit(used, nextIdx);
113
+ hint.value = nextIdx + 1;
114
+ return HASH_TABLE[nextIdx];
65
115
  }
66
116
 
67
117
  export function _lineHashesPure(content: string): string[] {
68
- const lines = content.split("\n");
69
- const hashes = new Array<string>(lines.length);
70
- const assigned = new Set<string>();
71
- for (let i = 0; i < lines.length; i++) {
72
- const c = canon(lines[i]!);
73
- const hash = nextUniqueHash(c, assigned);
74
- hashes[i] = hash;
75
- }
76
- return hashes;
118
+ const lines = splitLines(content);
119
+ const hashes = new Array<string>(lines.length);
120
+ const used = new Uint32Array(BITSET_WORDS);
121
+ const hint = { value: 0 };
122
+
123
+ for (let i = 0; i < lines.length; i++) {
124
+ const c = canon(lines[i]!);
125
+ const baseIdx = xxh32(c) >>> 14;
126
+ hashes[i] = assignHash(used, baseIdx, hint);
127
+ }
128
+ return hashes;
77
129
  }
78
130
 
79
131
  export async function lineHashes(
@@ -96,7 +148,7 @@ export async function lineHashes(
96
148
  previous.removedHashes,
97
149
  );
98
150
  if (persist !== false) {
99
- upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
151
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
100
152
  }
101
153
  return newHashes;
102
154
  }
@@ -108,23 +160,41 @@ export async function lineHashes(
108
160
 
109
161
  const newHashes = _lineHashesPure(content);
110
162
  if (persist !== false) {
111
- upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
163
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
112
164
  }
113
165
  return newHashes;
114
166
  }
115
167
 
168
+ function hashToIndex(hash: string): number {
169
+ let idx = 0;
170
+ for (let j = 0; j < HASH_LEN; j++) {
171
+ const charIdx = ALPH.indexOf(hash[j]!);
172
+ if (charIdx < 0) return -1;
173
+ idx = (idx << ALPH_BITS) | charIdx;
174
+ }
175
+ return idx;
176
+ }
177
+
116
178
  function mapStableHashes(
117
179
  oldContent: string,
118
180
  oldHashes: string[],
119
181
  newContent: string,
120
182
  removedHashes?: Set<string>,
121
183
  ): string[] {
122
- const newLines = newContent.split("\n");
184
+ const newLines = splitLines(newContent);
123
185
  const newHashes = new Array<string>(newLines.length);
124
- const used = new Set<string>();
186
+ const used = new Uint32Array(BITSET_WORDS);
187
+ const hint = { value: 0 };
188
+
189
+ if (removedHashes) {
190
+ for (const hash of removedHashes) {
191
+ const idx = hashToIndex(hash);
192
+ if (idx >= 0) setBit(used, idx);
193
+ }
194
+ }
125
195
 
126
196
  const contentMap = new Map<string, { index: number; hash: string }[]>();
127
- const oldLines = oldContent.split("\n");
197
+ const oldLines = splitLines(oldContent);
128
198
  for (let i = 0; i < oldLines.length; i++) {
129
199
  const line = oldLines[i]!;
130
200
  const entry = { index: i, hash: oldHashes[i]! };
@@ -155,14 +225,18 @@ function mapStableHashes(
155
225
  if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
156
226
  const match = candidates.splice(bestIdx, 1)[0]!;
157
227
  newHashes[i] = match.hash;
158
- used.add(match.hash);
228
+ const matchIdx = hashToIndex(match.hash);
229
+ if (matchIdx >= 0) {
230
+ setBit(used, matchIdx);
231
+ if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
232
+ }
159
233
  }
160
234
 
161
235
  for (let i = 0; i < newLines.length; i++) {
162
236
  if (newHashes[i]) continue;
163
237
  const c = canon(newLines[i]!);
164
- const hash = nextUniqueHash(c, used);
165
- newHashes[i] = hash;
238
+ const baseIdx = xxh32(c) >>> 14;
239
+ newHashes[i] = assignHash(used, baseIdx, hint);
166
240
  }
167
241
  return newHashes;
168
242
  }
@@ -5,6 +5,7 @@ export {
5
5
  HASH_CLASS,
6
6
  HL_PREFIX_RE,
7
7
  HL_PREFIX_PLUS_RE,
8
+ HL_PREFIX_MINUS_RE,
8
9
  DIFF_MINUS_RE,
9
10
  HL_BARE_PREFIX_RE,
10
11
  lineHashes,
@@ -2,6 +2,7 @@ import {
2
2
  ANCHOR_LEN,
3
3
  ALPH_RE,
4
4
  HL_PREFIX_PLUS_RE,
5
+ HL_PREFIX_MINUS_RE,
5
6
  DIFF_MINUS_RE,
6
7
  } from "./hash";
7
8
  import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
@@ -46,10 +47,11 @@ function assertNoPrefixes(lines: string[]): void {
46
47
  if (!line.length) continue;
47
48
  if (
48
49
  HL_PREFIX_PLUS_RE.test(line) ||
50
+ HL_PREFIX_MINUS_RE.test(line) ||
49
51
  DIFF_MINUS_RE.test(line)
50
52
  ) {
51
53
  throw new Error(
52
- `[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like the diff preview's +HASH│ row: ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
54
+ `[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like a diff preview row (e.g. +HASH│ or -HASH│): ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
53
55
  );
54
56
  }
55
57
  }
package/src/read.ts CHANGED
@@ -2,8 +2,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  createReadTool,
4
4
  formatSize,
5
- DEFAULT_MAX_BYTES,
6
- DEFAULT_MAX_LINES,
7
5
  truncateHead,
8
6
  type TruncationResult,
9
7
  } from "@earendil-works/pi-coding-agent";
@@ -18,10 +16,7 @@ import { visLines } from "./utils";
18
16
  import { loadP, loadGuide } from "./prompts";
19
17
  import { valAccess } from "./validation";
20
18
 
21
- const R_DESC = loadP("../prompts/read.md", {
22
- DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
23
- DEFAULT_MAX_BYTES: formatSize(DEFAULT_MAX_BYTES),
24
- });
19
+ const R_DESC = loadP("../prompts/read.md");
25
20
 
26
21
  const R_SNIPPET = loadP("../prompts/read-snippet.md");
27
22
  const R_GUIDE = loadGuide("../prompts/read-guidelines.md");
@@ -7,7 +7,7 @@ import { contentChecksum } from "./hashline/hasher";
7
7
  import { resolveTarget, writeAtomic } from "./fs-write";
8
8
  import { toCwd } from "./paths";
9
9
  import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
10
- import { cntDiff } from "./utils";
10
+ import { cntDiff, splitLines } from "./utils";
11
11
  import { loadP, loadGuide } from "./prompts";
12
12
  import { buildMetrics } from "./replace-response";
13
13
  export interface UndoEntry {
@@ -84,7 +84,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
84
84
  );
85
85
 
86
86
  const store = await loadHashStore();
87
- upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), undo.content.split("\n").length, undo.hashes);
87
+ upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
88
88
 
89
89
  clearUndo(mutationTargetPath);
90
90
 
package/src/replace.ts CHANGED
@@ -68,7 +68,7 @@ const changeItemSchema = Type.Object(
68
68
 
69
69
  export const editToolSchema = Type.Object(
70
70
  {
71
- changes: Type.Array(changeItemSchema, { description: "Array of edits. Each edit pairs content_lines (literal file content, one string per line) with hash_range_inclusive (inclusive [start_hash, end_hash] — pair of 3-char hashes from read output)." }),
71
+ changes: Type.Array(changeItemSchema, { description: "Array of edits applied atomically against the same pre-edit snapshot." }),
72
72
  path: Type.String({ description: "Path to edit" }),
73
73
  },
74
74
  { additionalProperties: false },
@@ -117,6 +117,19 @@ interface PipelineResult {
117
117
 
118
118
  const ROOT_KS = new Set(["path", "changes", "content_lines", "hash_range_inclusive"]);
119
119
 
120
+ const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"];
121
+
122
+ export function assertNoLegacyKeys(request: unknown): void {
123
+ if (!isRec(request)) return;
124
+ for (const legacyKey of LEGACY_KS) {
125
+ if (has(request, legacyKey)) {
126
+ throw new Error(
127
+ `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
128
+ );
129
+ }
130
+ }
131
+ }
132
+
120
133
  export function assertReq(
121
134
  request: unknown,
122
135
  flat?: boolean
@@ -125,13 +138,7 @@ export function assertReq(
125
138
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
126
139
  }
127
140
 
128
- for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
129
- if (has(request, legacyKey)) {
130
- throw new Error(
131
- `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
132
- );
133
- }
134
- }
141
+ assertNoLegacyKeys(request);
135
142
 
136
143
  rejectUnknownFields(request, ROOT_KS, "Edit request");
137
144
 
@@ -148,6 +155,7 @@ export function assertReq(
148
155
  throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
149
156
  }
150
157
  }
158
+
151
159
  export interface ExecPipelineOptions {
152
160
  accessMode?: number;
153
161
  signal?: AbortSignal;
@@ -155,6 +163,46 @@ export interface ExecPipelineOptions {
155
163
  noPersist?: boolean;
156
164
  }
157
165
 
166
+ function collectRemovedHashes(
167
+ resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
168
+ originalHashes: string[],
169
+ ): Set<string> {
170
+ const removedHashes = new Set<string>();
171
+ for (const edit of resolved) {
172
+ const startHash = edit.hash_range_inclusive[0].hash;
173
+ const endHash = edit.hash_range_inclusive[1].hash;
174
+ const startLine = originalHashes.indexOf(startHash);
175
+ const endLine = originalHashes.indexOf(endHash);
176
+ if (startLine >= 0 && endLine >= 0) {
177
+ for (let i = startLine; i <= endLine; i++) {
178
+ removedHashes.add(originalHashes[i]!);
179
+ }
180
+ }
181
+ }
182
+ return removedHashes;
183
+ }
184
+
185
+ function countLineChanges(
186
+ resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }]; content_lines: string[] }[],
187
+ originalHashes: string[],
188
+ noopEdits: { editIndex: number }[] | undefined,
189
+ ): { totalAddedLines: number; totalRemovedLines: number } {
190
+ let totalAddedLines = 0;
191
+ let totalRemovedLines = 0;
192
+ const noopIndices = new Set(noopEdits?.map((n) => n.editIndex) ?? []);
193
+ for (let i = 0; i < resolved.length; i++) {
194
+ if (noopIndices.has(i)) continue;
195
+ const edit = resolved[i]!;
196
+ const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
197
+ const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
198
+ if (startLine >= 0 && endLine >= 0) {
199
+ totalRemovedLines += endLine - startLine + 1;
200
+ }
201
+ totalAddedLines += edit.content_lines.length;
202
+ }
203
+ return { totalAddedLines, totalRemovedLines };
204
+ }
205
+
158
206
  export async function execPipeline(
159
207
  params: ReqParams,
160
208
  cwd: string,
@@ -187,18 +235,7 @@ export async function execPipeline(
187
235
 
188
236
  const result = anchorResult.content;
189
237
 
190
- const removedHashes = new Set<string>();
191
- for (const edit of resolved) {
192
- const startHash = edit.hash_range_inclusive[0].hash;
193
- const endHash = edit.hash_range_inclusive[1].hash;
194
- const startLine = originalHashes.indexOf(startHash);
195
- const endLine = originalHashes.indexOf(endHash);
196
- if (startLine >= 0 && endLine >= 0) {
197
- for (let i = startLine; i <= endLine; i++) {
198
- removedHashes.add(originalHashes[i]!);
199
- }
200
- }
201
- }
238
+ const removedHashes = collectRemovedHashes(resolved, originalHashes);
202
239
 
203
240
  const noPersist = options?.noPersist;
204
241
  const resultHashes = await lineHashes(result, absolutePath, {
@@ -209,19 +246,9 @@ export async function execPipeline(
209
246
 
210
247
  const warnings = [...(anchorResult.warnings ?? [])];
211
248
 
212
- let totalAddedLines = 0;
213
- let totalRemovedLines = 0;
214
- const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
215
- for (let i = 0; i < resolved.length; i++) {
216
- if (noopIndices.has(i)) continue;
217
- const edit = resolved[i]!;
218
- const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
219
- const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
220
- if (startLine >= 0 && endLine >= 0) {
221
- totalRemovedLines += endLine - startLine + 1;
222
- }
223
- totalAddedLines += edit.content_lines.length;
224
- }
249
+ const { totalAddedLines, totalRemovedLines } = countLineChanges(
250
+ resolved, originalHashes, anchorResult.noopEdits,
251
+ );
225
252
 
226
253
  return {
227
254
  path,
@@ -249,6 +276,11 @@ export async function compPreview(
249
276
  ): Promise<RPreview> {
250
277
  try {
251
278
  const normalized = normReq(request);
279
+ if (flat && isRec(request) && Array.isArray(request.changes)) {
280
+ return {
281
+ error: `[E_BAD_SHAPE] Flat mode does not accept a "changes" array. Send content_lines and hash_range_inclusive at the top level (one edit per call), or use bulk mode for multiple edits per call.`
282
+ };
283
+ }
252
284
  assertReq(normalized, flat);
253
285
  const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
254
286
  normalized,
@@ -289,27 +321,12 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
289
321
  m.setText(content);
290
322
  return m;
291
323
  }
324
+
292
325
  const MODE_CFG = {
293
326
  flat: {
294
- desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
295
- examples: [
296
- "", "Single line:", "{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"], \"path\": \"src/main.ts\" }",
297
- ].join("\n"),
298
- rules: "",
299
- requestStructure: [
300
- "Flat mode:", "```json", "{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"], \"path\": \"...\" }", "```",
301
- ].join("\n"),
302
327
  prefix: "performing one edit per call",
303
328
  },
304
329
  bulk: {
305
- desc: "\n\nPut all operations on one file in a single `replace` call. Stack every region into the `changes` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot.",
306
- examples: [
307
- "", "Single line:", "{ \"changes\": [{ \"content_lines\": [\"const x = 1;\"], \"hash_range_inclusive\": [\"MQX\", \"MQX\"] }], \"path\": \"src/main.ts\" }", "", "Range replace:", "{ \"changes\": [{ \"content_lines\": [\"function greet() {\", \" return 1;\", \"}\"], \"hash_range_inclusive\": [\"ZPM\", \"VRW\"] }], \"path\": \"src/main.ts\" }",
308
- ].join("\n"),
309
- rules: "- Multiple edits in one call must not overlap. Overlapping ranges are rejected with [E_EDIT_CONFLICT].",
310
- requestStructure: [
311
- "Bulk mode (default):", "```json", "{ \"changes\": [{ \"content_lines\": [...], \"hash_range_inclusive\": [\"aB3\", \"xY7\"] }], \"path\": \"...\" }", "```",
312
- ].join("\n"),
313
330
  prefix: "batching all changes to a file in one call",
314
331
  },
315
332
  } as const;
@@ -334,6 +351,7 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
334
351
  promptGuidelines: E_GUIDE,
335
352
  prepareArguments: opts.flat
336
353
  ? (args: unknown) => {
354
+ assertNoLegacyKeys(args);
337
355
  if (!isRec(args)) return args as any;
338
356
  const record = { ...args };
339
357
  normalizeFilePath(record);
@@ -342,8 +360,10 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
342
360
  }
343
361
  return record;
344
362
  }
345
- : (args: unknown) =>
346
- normReq(args) as ReqParams,
363
+ : (args: unknown) => {
364
+ assertNoLegacyKeys(args);
365
+ return normReq(args) as ReqParams;
366
+ },
347
367
  renderShell: "default",
348
368
  renderCall(args, theme, context) {
349
369
  const previewInput = getPreviewInput(args);
@@ -435,7 +455,6 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
435
455
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
436
456
  const canonical = normReq(params);
437
457
 
438
-
439
458
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };
440
459
  const path = normalizedParams.path;
441
460
  const absolutePath = toCwd(path, ctx.cwd);
package/src/utils.ts CHANGED
@@ -6,6 +6,12 @@ export function has(record: Record<string, unknown>, key: string): boolean {
6
6
  return Object.hasOwn(record, key);
7
7
  }
8
8
 
9
+ export function splitLines(text: string): string[] {
10
+ if (text.length === 0) return [""];
11
+ const lines = text.split("\n");
12
+ return text.endsWith("\n") ? lines.slice(0, -1) : lines;
13
+ }
14
+
9
15
  export function visLines(text: string): string[] {
10
16
  if (text.length === 0) return [];
11
17
  const lines = text.split("\n");