pi-hashline-edit-pro 0.18.0 → 0.18.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
@@ -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
 
@@ -156,7 +156,7 @@ The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squin
156
156
 
157
157
  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
158
 
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.
159
+ **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
160
  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
161
  `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
162
 
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.1",
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",
@@ -1,2 +0,0 @@
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.
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/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,21 +21,19 @@ 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
  );
@@ -47,33 +44,85 @@ export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
47
44
 
48
45
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
49
46
 
50
-
51
47
  function canon(line: string): string {
52
48
  return line.replace(/\r/g, "").trimEnd();
53
49
  }
54
50
 
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;
51
+ const BITSET_WORDS = 8192;
52
+
53
+ function getBit(bits: Uint32Array, idx: number): boolean {
54
+ return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
55
+ }
56
+
57
+ function setBit(bits: Uint32Array, idx: number): void {
58
+ bits[idx >>> 5] |= 1 << (idx & 31);
59
+ }
60
+
61
+ function nextZeroBit(bits: Uint32Array, start: number): number {
62
+ const totalWords = bits.length;
63
+ const totalBits = totalWords * 32;
64
+
65
+ if (start >= totalBits) start = 0;
66
+
67
+ const wordIdx = start >>> 5;
68
+ const bitOffset = start & 31;
69
+
70
+ let word = bits[wordIdx];
71
+ for (let b = bitOffset; b < 32; b++) {
72
+ if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
73
+ }
74
+
75
+ for (let w = wordIdx + 1; w < totalWords; w++) {
76
+ word = bits[w];
77
+ if (~word !== 0) {
78
+ for (let b = 0; b < 32; b++) {
79
+ if ((word >>> b & 1) === 0) return w * 32 + b;
80
+ }
81
+ }
82
+ }
83
+
84
+ for (let w = 0; w < wordIdx; w++) {
85
+ word = bits[w];
86
+ if (~word !== 0) {
87
+ for (let b = 0; b < 32; b++) {
88
+ if ((word >>> b & 1) === 0) return w * 32 + b;
89
+ }
90
+ }
91
+ }
92
+
93
+ word = bits[wordIdx];
94
+ for (let b = 0; b < bitOffset; b++) {
95
+ if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
96
+ }
97
+
98
+ throw new Error("Hash space exhausted");
99
+ }
100
+
101
+ function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
102
+ if (!getBit(used, baseIdx)) {
103
+ setBit(used, baseIdx);
104
+ hint.value = baseIdx + 1;
105
+ return HASH_TABLE[baseIdx];
106
+ }
107
+ const start = hint.value > baseIdx + 1 ? hint.value : baseIdx + 1;
108
+ const nextIdx = nextZeroBit(used, start);
109
+ setBit(used, nextIdx);
110
+ hint.value = nextIdx + 1;
111
+ return HASH_TABLE[nextIdx];
65
112
  }
66
113
 
67
114
  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;
115
+ const lines = splitLines(content);
116
+ const hashes = new Array<string>(lines.length);
117
+ const used = new Uint32Array(BITSET_WORDS);
118
+ const hint = { value: 0 };
119
+
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const c = canon(lines[i]!);
122
+ const baseIdx = xxh32(c) >>> 14;
123
+ hashes[i] = assignHash(used, baseIdx, hint);
124
+ }
125
+ return hashes;
77
126
  }
78
127
 
79
128
  export async function lineHashes(
@@ -96,7 +145,7 @@ export async function lineHashes(
96
145
  previous.removedHashes,
97
146
  );
98
147
  if (persist !== false) {
99
- upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
148
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
100
149
  }
101
150
  return newHashes;
102
151
  }
@@ -108,23 +157,41 @@ export async function lineHashes(
108
157
 
109
158
  const newHashes = _lineHashesPure(content);
110
159
  if (persist !== false) {
111
- upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
160
+ upsertSnapshot(hashStore, path, contentChecksum(content), splitLines(content).length, newHashes);
112
161
  }
113
162
  return newHashes;
114
163
  }
115
164
 
165
+ function hashToIndex(hash: string): number {
166
+ let idx = 0;
167
+ for (let j = 0; j < HASH_LEN; j++) {
168
+ const charIdx = ALPH.indexOf(hash[j]!);
169
+ if (charIdx < 0) return -1;
170
+ idx = (idx << ALPH_BITS) | charIdx;
171
+ }
172
+ return idx;
173
+ }
174
+
116
175
  function mapStableHashes(
117
176
  oldContent: string,
118
177
  oldHashes: string[],
119
178
  newContent: string,
120
179
  removedHashes?: Set<string>,
121
180
  ): string[] {
122
- const newLines = newContent.split("\n");
181
+ const newLines = splitLines(newContent);
123
182
  const newHashes = new Array<string>(newLines.length);
124
- const used = new Set<string>();
183
+ const used = new Uint32Array(BITSET_WORDS);
184
+ const hint = { value: 0 };
185
+
186
+ if (removedHashes) {
187
+ for (const hash of removedHashes) {
188
+ const idx = hashToIndex(hash);
189
+ if (idx >= 0) setBit(used, idx);
190
+ }
191
+ }
125
192
 
126
193
  const contentMap = new Map<string, { index: number; hash: string }[]>();
127
- const oldLines = oldContent.split("\n");
194
+ const oldLines = splitLines(oldContent);
128
195
  for (let i = 0; i < oldLines.length; i++) {
129
196
  const line = oldLines[i]!;
130
197
  const entry = { index: i, hash: oldHashes[i]! };
@@ -155,14 +222,18 @@ function mapStableHashes(
155
222
  if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
156
223
  const match = candidates.splice(bestIdx, 1)[0]!;
157
224
  newHashes[i] = match.hash;
158
- used.add(match.hash);
225
+ const matchIdx = hashToIndex(match.hash);
226
+ if (matchIdx >= 0) {
227
+ setBit(used, matchIdx);
228
+ if (matchIdx + 1 > hint.value) hint.value = matchIdx + 1;
229
+ }
159
230
  }
160
231
 
161
232
  for (let i = 0; i < newLines.length; i++) {
162
233
  if (newHashes[i]) continue;
163
234
  const c = canon(newLines[i]!);
164
- const hash = nextUniqueHash(c, used);
165
- newHashes[i] = hash;
235
+ const baseIdx = xxh32(c) >>> 14;
236
+ newHashes[i] = assignHash(used, baseIdx, hint);
166
237
  }
167
238
  return newHashes;
168
239
  }
@@ -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
@@ -148,6 +148,7 @@ export function assertReq(
148
148
  throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
149
149
  }
150
150
  }
151
+
151
152
  export interface ExecPipelineOptions {
152
153
  accessMode?: number;
153
154
  signal?: AbortSignal;
@@ -155,6 +156,46 @@ export interface ExecPipelineOptions {
155
156
  noPersist?: boolean;
156
157
  }
157
158
 
159
+ function collectRemovedHashes(
160
+ resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
161
+ originalHashes: string[],
162
+ ): Set<string> {
163
+ const removedHashes = new Set<string>();
164
+ for (const edit of resolved) {
165
+ const startHash = edit.hash_range_inclusive[0].hash;
166
+ const endHash = edit.hash_range_inclusive[1].hash;
167
+ const startLine = originalHashes.indexOf(startHash);
168
+ const endLine = originalHashes.indexOf(endHash);
169
+ if (startLine >= 0 && endLine >= 0) {
170
+ for (let i = startLine; i <= endLine; i++) {
171
+ removedHashes.add(originalHashes[i]!);
172
+ }
173
+ }
174
+ }
175
+ return removedHashes;
176
+ }
177
+
178
+ function countLineChanges(
179
+ resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }]; content_lines: string[] }[],
180
+ originalHashes: string[],
181
+ noopEdits: { editIndex: number }[] | undefined,
182
+ ): { totalAddedLines: number; totalRemovedLines: number } {
183
+ let totalAddedLines = 0;
184
+ let totalRemovedLines = 0;
185
+ const noopIndices = new Set(noopEdits?.map((n) => n.editIndex) ?? []);
186
+ for (let i = 0; i < resolved.length; i++) {
187
+ if (noopIndices.has(i)) continue;
188
+ const edit = resolved[i]!;
189
+ const startLine = originalHashes.indexOf(edit.hash_range_inclusive[0].hash);
190
+ const endLine = originalHashes.indexOf(edit.hash_range_inclusive[1].hash);
191
+ if (startLine >= 0 && endLine >= 0) {
192
+ totalRemovedLines += endLine - startLine + 1;
193
+ }
194
+ totalAddedLines += edit.content_lines.length;
195
+ }
196
+ return { totalAddedLines, totalRemovedLines };
197
+ }
198
+
158
199
  export async function execPipeline(
159
200
  params: ReqParams,
160
201
  cwd: string,
@@ -187,18 +228,7 @@ export async function execPipeline(
187
228
 
188
229
  const result = anchorResult.content;
189
230
 
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
- }
231
+ const removedHashes = collectRemovedHashes(resolved, originalHashes);
202
232
 
203
233
  const noPersist = options?.noPersist;
204
234
  const resultHashes = await lineHashes(result, absolutePath, {
@@ -209,19 +239,9 @@ export async function execPipeline(
209
239
 
210
240
  const warnings = [...(anchorResult.warnings ?? [])];
211
241
 
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
- }
242
+ const { totalAddedLines, totalRemovedLines } = countLineChanges(
243
+ resolved, originalHashes, anchorResult.noopEdits,
244
+ );
225
245
 
226
246
  return {
227
247
  path,
@@ -289,6 +309,7 @@ export function reuseMarkdown(context: any, content: string, theme: any): Markdo
289
309
  m.setText(content);
290
310
  return m;
291
311
  }
312
+
292
313
  const MODE_CFG = {
293
314
  flat: {
294
315
  desc: " Only one edit per call. The `hash_range_inclusive` and `content_lines` fields sit at the top level of the request object.",
@@ -435,7 +456,6 @@ export function buildToolDef(opts: { flat: boolean; autoRead?: boolean }): ToolD
435
456
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
436
457
  const canonical = normReq(params);
437
458
 
438
-
439
459
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };
440
460
  const path = normalizedParams.path;
441
461
  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");