pi-hashline-edit-pro 2.1.2 → 2.2.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 +2 -2
- package/package.json +10 -3
- package/prompts/replace-guidelines.md +1 -1
- package/src/hashline/apply.ts +9 -1
- package/src/hashline/parse.ts +2 -3
- package/src/hashline/resolve.ts +75 -52
- package/src/replace.ts +1 -1
package/README.md
CHANGED
|
@@ -86,12 +86,12 @@ One edit per call, with `hash_bounds` and `new_content` at the top level:
|
|
|
86
86
|
| Field | Description |
|
|
87
87
|
| --- | --- |
|
|
88
88
|
| `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
|
|
89
|
-
| `new_content` | Replacement content as a single string with `\n` line separators; a trailing
|
|
89
|
+
| `new_content` | Replacement content as a single string with `\n` line separators; every `\n` separates lines, so a trailing `\n` adds a final empty line — mirror the replaced range's lines exactly, blank lines included (a replacement that is only blank lines is written as one `\n` per blank line). Use `""` to delete the range. |
|
|
90
90
|
|
|
91
91
|
Notes:
|
|
92
92
|
|
|
93
93
|
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
94
|
-
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice.
|
|
94
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix in `new_content` or `hash_bounds`, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. New lines that duplicate unique lines adjacent to the range are stripped automatically — consecutive duplicates are stripped as a run, so re-including a whole unchanged block next to the range never duplicates it. `file_path` works as an alias for `path` in all three tools.
|
|
95
95
|
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
96
96
|
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
97
97
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
"extension",
|
|
17
17
|
"hashline",
|
|
18
18
|
"hash-anchored",
|
|
19
|
-
"
|
|
19
|
+
"hash",
|
|
20
|
+
"anchors",
|
|
21
|
+
"read",
|
|
22
|
+
"replace",
|
|
23
|
+
"edit",
|
|
24
|
+
"undo",
|
|
25
|
+
"file-editing",
|
|
26
|
+
"stable"
|
|
20
27
|
],
|
|
21
28
|
"license": "MIT",
|
|
22
29
|
"files": [
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
- `replace`: minimize the replaced range — anchor only the lines that actually change, so few unchanged lines must be reproduced byte-exact.
|
|
3
3
|
- `replace`: to replace a single line, repeat its hash in both positions of hash_bounds: ["<HASH>", "<HASH>"] — never extend the range to neighboring lines for a one-line edit.
|
|
4
4
|
- `replace`: when copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
5
|
-
- `replace`:
|
|
5
|
+
- `replace`: every `\n` in new_content separates lines, so a trailing `\n` adds a final empty line. Mirror the replaced range's lines exactly: a range that ends on a blank line must end new_content with `\n` (e.g. `"code\n"`), and a replacement whose last line is not blank must not end with `\n`. To add a blank line after a line, end new_content with an explicit empty line after it (e.g. `"X\n"` adds a blank after X). A replacement that is only blank lines is written as one `\n` per blank line (e.g. `"\n"` is a single blank line).
|
|
6
6
|
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file — `+HASH│` and ` HASH│` rows carry current hashes and unchanged lines keep their previous hashes, so you can anchor follow-up edits on the diff without re-reading. `-HASH│` rows show removed lines with their old hashes; those hashes are stale after the edit.
|
package/src/hashline/apply.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type NEdit,
|
|
12
12
|
type HEdit,
|
|
13
13
|
type AutoFix,
|
|
14
|
+
type BDup,
|
|
14
15
|
} from "./resolve";
|
|
15
16
|
|
|
16
17
|
type LIdx = {
|
|
@@ -186,7 +187,14 @@ export function applyEdit(
|
|
|
186
187
|
...prefixFixed,
|
|
187
188
|
content_lines: [...prefixFixed.content_lines],
|
|
188
189
|
};
|
|
189
|
-
const
|
|
190
|
+
const seen = new Set<number>();
|
|
191
|
+
const uniqueDups: BDup[] = [];
|
|
192
|
+
for (const dup of boundaryDups) {
|
|
193
|
+
if (seen.has(dup.replacementLineIndex)) continue;
|
|
194
|
+
seen.add(dup.replacementLineIndex);
|
|
195
|
+
uniqueDups.push(dup);
|
|
196
|
+
}
|
|
197
|
+
const dupsByIndex = uniqueDups.sort(
|
|
190
198
|
(a, b) => b.replacementLineIndex - a.replacementLineIndex,
|
|
191
199
|
);
|
|
192
200
|
for (const dup of dupsByIndex) {
|
package/src/hashline/parse.ts
CHANGED
|
@@ -45,7 +45,6 @@ export function parseText(edit: string): string[] {
|
|
|
45
45
|
}
|
|
46
46
|
const normalized = edit.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
47
47
|
if (normalized === "") return [];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return lines;
|
|
48
|
+
if (/^\n+$/.test(normalized)) return new Array(normalized.length).fill("");
|
|
49
|
+
return normalized.split("\n");
|
|
51
50
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf, rejectUnknownFields,
|
|
1
|
+
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
2
|
import { HASH_CLASS, 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_STRING_MSG } from "../constants";
|
|
@@ -283,38 +283,78 @@ export function swapReversedRanges(
|
|
|
283
283
|
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
286
|
+
function trailingDups(
|
|
287
|
+
contentLines: string[],
|
|
288
|
+
fileLines: string[],
|
|
289
|
+
endLine: number,
|
|
290
|
+
): BDup[] {
|
|
291
|
+
const start = lastNonEmptyIndex(contentLines);
|
|
292
|
+
if (start < 0) return [];
|
|
293
|
+
const dups: BDup[] = [];
|
|
294
|
+
const maxK = Math.min(start + 1, fileLines.length - endLine);
|
|
295
|
+
for (let k = 0; k < maxK; k++) {
|
|
296
|
+
if (contentLines[start - k] !== fileLines[endLine + k]) break;
|
|
297
|
+
dups.push({ kind: "trailing", replacementLineIndex: start - k });
|
|
298
|
+
}
|
|
299
|
+
return dups;
|
|
291
300
|
}
|
|
292
301
|
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return null;
|
|
302
|
+
function leadingDups(
|
|
303
|
+
contentLines: string[],
|
|
304
|
+
fileLines: string[],
|
|
305
|
+
startLine: number,
|
|
306
|
+
): BDup[] {
|
|
307
|
+
const start = firstNonEmptyIndex(contentLines);
|
|
308
|
+
if (start < 0) return [];
|
|
309
|
+
const dups: BDup[] = [];
|
|
310
|
+
const maxK = Math.min(contentLines.length - start, startLine - 1);
|
|
311
|
+
for (let k = 0; k < maxK; k++) {
|
|
312
|
+
if (contentLines[start + k] !== fileLines[startLine - 2 - k]) break;
|
|
313
|
+
dups.push({ kind: "leading", replacementLineIndex: start + k });
|
|
306
314
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
315
|
+
return dups;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function firstNewAfterDups(
|
|
319
|
+
contentLines: string[],
|
|
320
|
+
rangeLines: string[],
|
|
321
|
+
fileLines: string[],
|
|
322
|
+
endLine: number,
|
|
323
|
+
fileCounts: Map<string, number>,
|
|
324
|
+
): BDup[] {
|
|
325
|
+
const firstNew = findNewEdge(contentLines, rangeLines, false);
|
|
326
|
+
if (!firstNew) return [];
|
|
327
|
+
const dups: BDup[] = [];
|
|
328
|
+
const maxK = Math.min(contentLines.length - firstNew.index, fileLines.length - endLine);
|
|
329
|
+
for (let k = 0; k < maxK; k++) {
|
|
330
|
+
const newLine = contentLines[firstNew.index + k]!;
|
|
331
|
+
const fileLine = fileLines[endLine + k]!;
|
|
332
|
+
if (canon(newLine) !== canon(fileLine)) break;
|
|
333
|
+
if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
|
|
334
|
+
dups.push({ kind: "first-new-after", replacementLineIndex: firstNew.index + k });
|
|
313
335
|
}
|
|
314
|
-
return
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
336
|
+
return dups;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function lastNewBeforeDups(
|
|
340
|
+
contentLines: string[],
|
|
341
|
+
rangeLines: string[],
|
|
342
|
+
fileLines: string[],
|
|
343
|
+
startLine: number,
|
|
344
|
+
fileCounts: Map<string, number>,
|
|
345
|
+
): BDup[] {
|
|
346
|
+
const lastNew = findNewEdge(contentLines, rangeLines, true);
|
|
347
|
+
if (!lastNew) return [];
|
|
348
|
+
const dups: BDup[] = [];
|
|
349
|
+
const maxK = Math.min(lastNew.index + 1, startLine - 1);
|
|
350
|
+
for (let k = 0; k < maxK; k++) {
|
|
351
|
+
const newLine = contentLines[lastNew.index - k]!;
|
|
352
|
+
const fileLine = fileLines[startLine - 2 - k]!;
|
|
353
|
+
if (canon(newLine) !== canon(fileLine)) break;
|
|
354
|
+
if ((fileCounts.get(canon(fileLine)) ?? 0) !== 1) break;
|
|
355
|
+
dups.push({ kind: "last-new-before", replacementLineIndex: lastNew.index - k });
|
|
356
|
+
}
|
|
357
|
+
return dups;
|
|
318
358
|
}
|
|
319
359
|
|
|
320
360
|
function canonCounts(lines: string[]): Map<string, number> {
|
|
@@ -348,16 +388,6 @@ export function findNewEdge(
|
|
|
348
388
|
return undefined;
|
|
349
389
|
}
|
|
350
390
|
|
|
351
|
-
function newEdgeLines(
|
|
352
|
-
contentLines: string[],
|
|
353
|
-
rangeLines: string[],
|
|
354
|
-
): { firstNew: { index: number; line: string } | undefined; lastNew: { index: number; line: string } | undefined } {
|
|
355
|
-
return {
|
|
356
|
-
firstNew: findNewEdge(contentLines, rangeLines, false),
|
|
357
|
-
lastNew: findNewEdge(contentLines, rangeLines, true),
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
|
|
361
391
|
export function valEdit(
|
|
362
392
|
edit: HEdit,
|
|
363
393
|
fileLines: string[],
|
|
@@ -405,21 +435,14 @@ export function valEdit(
|
|
|
405
435
|
);
|
|
406
436
|
}
|
|
407
437
|
const endLine = endResolved.line;
|
|
408
|
-
const nextLine = fileLines[endLine];
|
|
409
|
-
const replacementLastLine = lastNonEmpty(edit.content_lines);
|
|
410
|
-
const trailing = checkBoundaryDup(nextLine, edgeRef(replacementLastLine, lastNonEmptyIndex(edit.content_lines)), "trailing");
|
|
411
|
-
if (trailing) boundaryDups.push(trailing);
|
|
412
|
-
const prevLine = fileLines[startResolved.line - 2];
|
|
413
|
-
const replacementFirstLine = firstNonEmpty(edit.content_lines);
|
|
414
|
-
const leading = checkBoundaryDup(prevLine, edgeRef(replacementFirstLine, firstNonEmptyIndex(edit.content_lines)), "leading");
|
|
415
|
-
if (leading) boundaryDups.push(leading);
|
|
416
438
|
const rangeLines = fileLines.slice(startResolved.line - 1, endLine);
|
|
417
|
-
const { firstNew, lastNew } = newEdgeLines(edit.content_lines, rangeLines);
|
|
418
439
|
const fileCounts = canonCounts(fileLines);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
440
|
+
boundaryDups.push(
|
|
441
|
+
...trailingDups(edit.content_lines, fileLines, endLine),
|
|
442
|
+
...leadingDups(edit.content_lines, fileLines, startResolved.line),
|
|
443
|
+
...firstNewAfterDups(edit.content_lines, rangeLines, fileLines, endLine, fileCounts),
|
|
444
|
+
...lastNewBeforeDups(edit.content_lines, rangeLines, fileLines, startResolved.line, fileCounts),
|
|
445
|
+
);
|
|
423
446
|
|
|
424
447
|
return {
|
|
425
448
|
resolved: {
|
package/src/replace.ts
CHANGED
|
@@ -47,7 +47,7 @@ import { loadHashStore, type HashStore } from "./hash-store";
|
|
|
47
47
|
|
|
48
48
|
const newContentSchema = Type.String({
|
|
49
49
|
description:
|
|
50
|
-
"Replacement content as a single string with \\n line separators; a trailing
|
|
50
|
+
"Replacement content as a single string with \\n line separators; every \\n separates lines, so a trailing \\n adds a final empty line. Mirror the replaced range's lines exactly, blank lines included. A replacement that is only blank lines is written as one \\n per blank line. Use \"\" to delete the range."
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
const hashBoundsSchema = Type.Array(
|