pi-hashline-edit-pro 0.15.1 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -124,7 +124,7 @@ export function _lineHashesPure(content: string): string[] {
124
124
  export async function lineHashes(
125
125
  content: string,
126
126
  path?: string,
127
- previous?: { content: string; hashes: string[] },
127
+ previous?: { content: string; hashes: string[]; removedHashes?: Set<string> },
128
128
  ): Promise<string[]> {
129
129
  if (!path) {
130
130
  return _lineHashesPure(content);
@@ -137,6 +137,7 @@ export async function lineHashes(
137
137
  const newHashes = mapStableHashes(
138
138
  previous.content, previous.hashes,
139
139
  content,
140
+ previous.removedHashes,
140
141
  );
141
142
  store.snapshots[path] = { content, hashes: newHashes };
142
143
  await saveHashStore(store);
@@ -157,42 +158,63 @@ export async function lineHashes(
157
158
  }
158
159
 
159
160
  /**
160
- * Diff old content against new content, preserving hashes for unchanged
161
- * lines. New/changed lines allocate fresh hashes with collision avoidance.
161
+ * Maps old hashes to new positions using hash-aware content matching.
162
+ * Unlike Diff.diffLines (which is content-only and cannot distinguish
163
+ * identical lines at different positions), this algorithm uses the
164
+ * removedHashes set to disambiguate: when a line appears multiple times
165
+ * in the old content and one occurrence was targeted by the edit,
166
+ * the surviving occurrence is matched to the non-removed hash.
167
+ * New/changed lines allocate fresh hashes with collision avoidance.
162
168
  */
163
169
  function mapStableHashes(
164
170
  oldContent: string,
165
171
  oldHashes: string[],
166
172
  newContent: string,
173
+ removedHashes?: Set<string>,
167
174
  ): string[] {
168
175
  const newLines = newContent.split("\n");
169
176
  const newHashes = new Array<string>(newLines.length);
170
177
  const used = new Set<string>();
171
178
 
172
- const parts = Diff.diffLines(oldContent, newContent);
173
- let oldIdx = 0;
174
- let newIdx = 0;
175
-
176
- for (const part of parts) {
177
- const raw = part.value.split("\n");
178
- if (raw[raw.length - 1] === "") raw.pop();
179
- const count = raw.length;
180
- if (count === 0) continue;
181
-
182
- if (part.added) {
183
- newIdx += count;
184
- } else if (part.removed) {
185
- oldIdx += count;
179
+ // Build a map from line content to list of (index, hash) for the old content.
180
+ // We process occurrences left-to-right so that matching preserves order.
181
+ const contentMap = new Map<string, { index: number; hash: string }[]>();
182
+ const oldLines = oldContent.split("\n");
183
+ for (let i = 0; i < oldLines.length; i++) {
184
+ const line = oldLines[i]!;
185
+ const entry = { index: i, hash: oldHashes[i]! };
186
+ const list = contentMap.get(line);
187
+ if (list) {
188
+ list.push(entry);
186
189
  } else {
187
- // unchanged — copy hashes
188
- for (let k = 0; k < count; k++) {
189
- const h = oldHashes[oldIdx]!;
190
- newHashes[newIdx] = h;
191
- used.add(h);
192
- oldIdx++;
193
- newIdx++;
190
+ contentMap.set(line, [entry]);
191
+ }
192
+ }
193
+
194
+ // Match each new line to an old occurrence by content.
195
+ // For lines with duplicate content, prefer occurrences whose hash
196
+ // was NOT targeted by the edit (those are the survivors).
197
+ for (let i = 0; i < newLines.length; i++) {
198
+ const line = newLines[i]!;
199
+ const candidates = contentMap.get(line);
200
+ if (!candidates || candidates.length === 0) continue;
201
+
202
+ // Find the best match: prefer a non-removed occurrence.
203
+ // If all remaining occurrences are removed, use the first one anyway
204
+ // (it will get a fresh hash in the fill step since its hash is in used+removed).
205
+ let bestIdx = 0;
206
+ if (removedHashes && removedHashes.size > 0) {
207
+ for (let j = 0; j < candidates.length; j++) {
208
+ if (!removedHashes.has(candidates[j]!.hash)) {
209
+ bestIdx = j;
210
+ break;
211
+ }
194
212
  }
195
213
  }
214
+
215
+ const match = candidates.splice(bestIdx, 1)[0]!;
216
+ newHashes[i] = match.hash;
217
+ used.add(match.hash);
196
218
  }
197
219
 
198
220
  // Fill remaining (new/changed) lines with fresh hashes
@@ -45,10 +45,25 @@ export function genDiff(
45
45
  newContent: string,
46
46
  contextLines = 2,
47
47
  newContentHashes?: string[],
48
+ oldHashes?: string[],
48
49
  ): { diff: string; firstChangedLine: number | undefined } {
49
- const parts = Diff.diffLines(oldContent, newContent);
50
- const output: string[] = [];
50
+ // Annotate each line with its hash so Diff.diffLines can distinguish
51
+ // identical lines at different positions. The \0 separator cannot appear
52
+ // in valid text file content.
53
+ const oldLines = oldContent.split("\n");
54
+ const effectiveOldHashes = oldHashes ?? _lineHashesPure(oldContent);
55
+ const annotatedOld = oldLines
56
+ .map((line, i) => `${line}\0${effectiveOldHashes[i] ?? ""}`)
57
+ .join("\n");
58
+
59
+ const newLines = newContent.split("\n");
51
60
  const effectiveNewHashes = newContentHashes ?? _lineHashesPure(newContent);
61
+ const annotatedNew = newLines
62
+ .map((line, i) => `${line}\0${effectiveNewHashes[i] ?? ""}`)
63
+ .join("\n");
64
+
65
+ const parts = Diff.diffLines(annotatedOld, annotatedNew);
66
+ const output: string[] = [];
52
67
  let oldLineNum = 1;
53
68
  let newLineNum = 1;
54
69
  let lastWasChange = false;
@@ -58,18 +73,21 @@ export function genDiff(
58
73
  const part = parts[i]!;
59
74
  const raw = part.value.split("\n");
60
75
  if (raw[raw.length - 1] === "") raw.pop();
76
+ // Strip \0hash annotation from each line for display
77
+ const displayLines = raw.map((l) => {
78
+ const nullIdx = l.indexOf("\0");
79
+ return nullIdx >= 0 ? l.slice(0, nullIdx) : l;
80
+ });
61
81
 
62
82
  if (part.added || part.removed) {
63
83
  if (firstChangedLine === undefined) firstChangedLine = newLineNum;
64
- for (const line of raw) {
84
+ for (let k = 0; k < displayLines.length; k++) {
65
85
  if (part.added) {
66
86
  const hash = effectiveNewHashes[newLineNum - 1];
67
- output.push(fmtDiffLine("+", line, hash));
87
+ output.push(fmtDiffLine("+", displayLines[k]!, hash));
68
88
  newLineNum++;
69
89
  } else {
70
- output.push(
71
- fmtDiffLine("-", line, undefined),
72
- );
90
+ output.push(fmtDiffLine("-", displayLines[k]!, undefined));
73
91
  oldLineNum++;
74
92
  }
75
93
  }
@@ -80,18 +98,18 @@ export function genDiff(
80
98
  const nextPartIsChange =
81
99
  i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed);
82
100
  if (lastWasChange || nextPartIsChange) {
83
- let linesToShow = raw;
101
+ let linesToShow = displayLines;
84
102
  let skipStart = 0;
85
103
  let skipEnd = 0;
86
104
  let skipMiddle = 0;
87
105
 
88
106
  if (!lastWasChange) {
89
- skipStart = Math.max(0, raw.length - contextLines);
90
- linesToShow = raw.slice(skipStart);
91
- } else if (nextPartIsChange && raw.length > contextLines * 2) {
92
- const tail = raw.slice(-contextLines);
93
- linesToShow = [...raw.slice(0, contextLines), "__ELLIPSIS__", ...tail];
94
- skipMiddle = raw.length - contextLines * 2;
107
+ skipStart = Math.max(0, displayLines.length - contextLines);
108
+ linesToShow = displayLines.slice(skipStart);
109
+ } else if (nextPartIsChange && displayLines.length > contextLines * 2) {
110
+ const tail = displayLines.slice(-contextLines);
111
+ linesToShow = [...displayLines.slice(0, contextLines), "__ELLIPSIS__", ...tail];
112
+ skipMiddle = displayLines.length - contextLines * 2;
95
113
  } else if (linesToShow.length > contextLines) {
96
114
  skipEnd = linesToShow.length - contextLines;
97
115
  linesToShow = linesToShow.slice(0, contextLines);
@@ -111,13 +129,12 @@ export function genDiff(
111
129
  }
112
130
  const hash = effectiveNewHashes[newLineNum - 1];
113
131
  output.push(fmtDiffLine(" ", line, hash));
114
-
115
132
  oldLineNum++;
116
133
  newLineNum++;
117
134
  }
118
135
  } else {
119
- oldLineNum += raw.length;
120
- newLineNum += raw.length;
136
+ oldLineNum += displayLines.length;
137
+ newLineNum += displayLines.length;
121
138
  }
122
139
  lastWasChange = false;
123
140
  }
@@ -238,6 +238,7 @@ export function buildToolDef(): ToolDef {
238
238
 
239
239
  const {
240
240
  originalNormalized,
241
+ originalHashes,
241
242
  result,
242
243
  bom,
243
244
  originalEnding,
@@ -294,13 +295,13 @@ export function buildToolDef(): ToolDef {
294
295
  const successInput = {
295
296
  path,
296
297
  originalNormalized,
298
+ originalHashes,
297
299
  result,
298
300
  resultHashes,
299
301
  warnings,
300
302
  snapshotId: updatedSnapshotId,
301
303
  editMeta,
302
304
  };
303
-
304
305
  return buildChanged(successInput);
305
306
  });
306
307
  },
@@ -40,13 +40,14 @@ export interface NoopInput {
40
40
  }
41
41
 
42
42
  export interface SuccessInput {
43
- path: string;
44
- originalNormalized: string;
45
- result: string;
46
- resultHashes: string[];
47
- warnings: string[] | undefined;
48
- snapshotId: string;
49
- editMeta: RMeta;
43
+ path: string;
44
+ originalNormalized: string;
45
+ originalHashes: string[];
46
+ result: string;
47
+ resultHashes: string[];
48
+ warnings: string[] | undefined;
49
+ snapshotId: string;
50
+ editMeta: RMeta;
50
51
  }
51
52
 
52
53
  function cntDiff(diff: string, marker: "+" | "-"): number {
@@ -139,39 +140,39 @@ export function buildNoop(input: NoopInput): TResult {
139
140
  }
140
141
 
141
142
  export function buildChanged(input: SuccessInput): TResult {
142
- const { path, result, warnings, snapshotId, originalNormalized, editMeta, resultHashes } = input;
143
-
144
- const resultLines = visLines(result);
145
- const diffResult = genDiff(originalNormalized, result, 2, resultHashes);
146
- const addedLines = cntDiff(diffResult.diff, "+");
147
- const removedLines = cntDiff(diffResult.diff, "-");
148
- const warningsBlock = warnBlock(warnings);
149
- const successPrefix = `Successfully replaced in ${path}.`;
150
- const text = resultLines.length === 0
151
- ? "File is empty. Use replace to insert content."
152
- : warningsBlock
153
- ? `${successPrefix}${warningsBlock}`
154
- : successPrefix;
155
-
156
- const metrics = buildM({
157
- classification: "applied",
158
- editsAttempted: editMeta.editsAttempted,
159
- noopEditsCount: editMeta.noopEditsCount,
160
- warningsCount: warnings?.length ?? 0,
161
- firstChangedLine: editMeta.firstChangedLine,
162
- lastChangedLine: editMeta.lastChangedLine,
163
- addedLines,
164
- removedLines,
165
- });
166
-
167
- return {
168
- content: [{ type: "text", text }],
169
- details: {
170
- diff: diffResult.diff,
171
- firstChangedLine:
172
- editMeta.firstChangedLine ?? diffResult.firstChangedLine,
173
- snapshotId,
174
- metrics,
175
- },
176
- };
143
+ const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
144
+
145
+ const resultLines = visLines(result);
146
+ const diffResult = genDiff(originalNormalized, result, 2, resultHashes, originalHashes);
147
+ const addedLines = cntDiff(diffResult.diff, "+");
148
+ const removedLines = cntDiff(diffResult.diff, "-");
149
+ const warningsBlock = warnBlock(warnings);
150
+ const successPrefix = `Successfully replaced in ${path}.`;
151
+ const text = resultLines.length === 0
152
+ ? "File is empty. Use replace to insert content."
153
+ : warningsBlock
154
+ ? `${successPrefix}${warningsBlock}`
155
+ : successPrefix;
156
+
157
+ const metrics = buildM({
158
+ classification: "applied",
159
+ editsAttempted: editMeta.editsAttempted,
160
+ noopEditsCount: editMeta.noopEditsCount,
161
+ warningsCount: warnings?.length ?? 0,
162
+ firstChangedLine: editMeta.firstChangedLine,
163
+ lastChangedLine: editMeta.lastChangedLine,
164
+ addedLines,
165
+ removedLines,
166
+ });
167
+
168
+ return {
169
+ content: [{ type: "text", text }],
170
+ details: {
171
+ diff: diffResult.diff,
172
+ firstChangedLine:
173
+ editMeta.firstChangedLine ?? diffResult.firstChangedLine,
174
+ snapshotId,
175
+ metrics,
176
+ },
177
+ };
177
178
  }
package/src/replace.ts CHANGED
@@ -165,10 +165,18 @@ export async function execPipeline(
165
165
 
166
166
  const result = anchorResult.content;
167
167
 
168
- // Compute stable result hashes using diff-based preservation
168
+ // Collect hashes targeted by the edit for hash-aware diff disambiguation
169
+ const removedHashes = new Set<string>();
170
+ for (const edit of resolved) {
171
+ removedHashes.add(edit.hash_range_inclusive[0].hash);
172
+ removedHashes.add(edit.hash_range_inclusive[1].hash);
173
+ }
174
+
175
+ // Compute stable result hashes using hash-aware preservation
169
176
  const resultHashes = await lineHashes(result, absolutePath, {
170
177
  content: originalNormalized,
171
178
  hashes: originalHashes,
179
+ removedHashes,
172
180
  });
173
181
 
174
182
  // Format boundary warnings using stable result hashes
@@ -220,7 +228,7 @@ export async function compPreview(
220
228
  try {
221
229
  const normalized = normReq(request);
222
230
  assertReq(normalized);
223
- const { path, originalNormalized, result, resultHashes } = await execPipeline(
231
+ const { path, originalNormalized, originalHashes, result, resultHashes } = await execPipeline(
224
232
  normalized,
225
233
  cwd,
226
234
  constants.R_OK,
@@ -232,7 +240,7 @@ export async function compPreview(
232
240
  };
233
241
  }
234
242
 
235
- return { diff: genDiff(originalNormalized, result, 4, resultHashes).diff };
243
+ return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
236
244
  } catch (error: unknown) {
237
245
  return { error: error instanceof Error ? error.message : String(error) };
238
246
  }
@@ -383,6 +391,7 @@ export function buildToolDef(): ToolDef {
383
391
 
384
392
  const {
385
393
  originalNormalized,
394
+ originalHashes,
386
395
  result,
387
396
  bom,
388
397
  originalEnding,
@@ -441,13 +450,13 @@ export function buildToolDef(): ToolDef {
441
450
  const successInput = {
442
451
  path,
443
452
  originalNormalized,
453
+ originalHashes,
444
454
  result,
445
455
  resultHashes,
446
456
  warnings,
447
457
  snapshotId: updatedSnapshotId,
448
458
  editMeta,
449
459
  };
450
-
451
460
  return buildChanged(successInput);
452
461
  });
453
462
  },