@jerryan/pi-hashline-edit 0.7.2 → 0.7.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 +12 -12
- package/package.json +1 -1
- package/prompts/edit.md +1 -1
- package/prompts/read.md +1 -1
- package/src/edit-diff.ts +6 -5
- package/src/edit.ts +3 -2
- package/src/hashline.ts +31 -28
package/README.md
CHANGED
|
@@ -34,12 +34,12 @@ pi install /path/to/pi-hashline-edit
|
|
|
34
34
|
|
|
35
35
|
### `read` — tagged line output
|
|
36
36
|
|
|
37
|
-
Text files are returned with a `LINE#HASH
|
|
37
|
+
Text files are returned with a `LINE#HASH│` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH│` columns align:
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
|
-
8#A4
|
|
41
|
-
9#3F
|
|
42
|
-
10#B2
|
|
40
|
+
8#A4│function hello() {
|
|
41
|
+
9#3F│ console.log("world");
|
|
42
|
+
10#B2│}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
- `LINE` — 1-indexed line number.
|
|
@@ -80,22 +80,22 @@ After a successful edit, the response contains a unified diff where context and
|
|
|
80
80
|
Each edit result shows a unified diff with hashline-formatted lines:
|
|
81
81
|
|
|
82
82
|
```text
|
|
83
|
-
8#A4
|
|
84
|
-
-9
|
|
85
|
-
+9#B1
|
|
86
|
-
10#B2
|
|
83
|
+
8#A4│function hello() {
|
|
84
|
+
-9 │ console.log("world");
|
|
85
|
+
+9#B1│ console.log("hashline");
|
|
86
|
+
10#B2│}
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
- Context lines: ` NN#HH
|
|
90
|
-
- Removed lines: `-NN
|
|
91
|
-
- Added lines: `+NN#HH
|
|
89
|
+
- Context lines: ` NN#HH│content` (space prefix)
|
|
90
|
+
- Removed lines: `-NN │content` (no hash, aligned separator)
|
|
91
|
+
- Added lines: `+NN#HH│content` (hash for new anchors)
|
|
92
92
|
- Multiple hunks are shown when edits are far apart.
|
|
93
93
|
|
|
94
94
|
## Design Decisions
|
|
95
95
|
|
|
96
96
|
- **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes a snippet with fresh `LINE#HASH` references for the affected lines for immediate retry.
|
|
97
97
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
98
|
-
- **Strict patch content.** If `lines` contains `LINE#HASH
|
|
98
|
+
- **Strict patch content.** If `lines` contains `LINE#HASH│` display prefixes or diff `+`/`-` markers, the edit is rejected with `[E_INVALID_PATCH]`. The model must send literal file content; the runtime does not silently strip accidental prefixes.
|
|
99
99
|
- **Full-file deletion guardrail.** Edits that would empty a file with more than 50 lines are rejected with `[E_WOULD_EMPTY]`. Small files show the full diff normally; large deletions are almost always mistakes.
|
|
100
100
|
- **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.
|
|
101
101
|
- **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.
|
package/package.json
CHANGED
package/prompts/edit.md
CHANGED
|
@@ -9,7 +9,7 @@ Each edit entry replaces an inclusive anchor range:
|
|
|
9
9
|
- `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read` or diff output.
|
|
10
10
|
Use the same anchor twice for single-line: `["42#A4", "42#A4"]`.
|
|
11
11
|
- `lines` — new content replacing the range (string array). Use `[]` to delete.
|
|
12
|
-
Must be literal file content, not LINE#HASH
|
|
12
|
+
Must be literal file content, not LINE#HASH│-prefixed output. Match indentation exactly.
|
|
13
13
|
|
|
14
14
|
Example:
|
|
15
15
|
```json
|
package/prompts/read.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH
|
|
1
|
+
Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASH│content` — copy those anchors verbatim into `edit`.
|
|
2
2
|
|
|
3
3
|
Use `offset` and `limit` to page through. Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; when truncated, the tail of the output tells you the next `offset`.
|
|
4
4
|
|
package/src/edit-diff.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
FUZZY_DOUBLE_QUOTES_RE,
|
|
6
6
|
FUZZY_SINGLE_QUOTES_RE,
|
|
7
7
|
FUZZY_UNICODE_SPACES_RE,
|
|
8
|
+
ANCHOR_SEP,
|
|
9
|
+
CONTENT_SEP,
|
|
8
10
|
} from "./hashline";
|
|
9
11
|
|
|
10
12
|
// ─── Line ending normalization ──────────────────────────────────────────
|
|
@@ -241,8 +243,7 @@ export function generateDiffString(
|
|
|
241
243
|
newContent.split("\n").length,
|
|
242
244
|
);
|
|
243
245
|
const lineNumWidth = String(maxLineNum).length;
|
|
244
|
-
const hashPad = " ".repeat(
|
|
245
|
-
|
|
246
|
+
const hashPad = " ".repeat(ANCHOR_SEP.length + 2); // align with `${ANCHOR_SEP}HH${CONTENT_SEP}`
|
|
246
247
|
const output: string[] = [];
|
|
247
248
|
|
|
248
249
|
for (let h = 0; h < patch.hunks.length; h++) {
|
|
@@ -262,17 +263,17 @@ export function generateDiffString(
|
|
|
262
263
|
|
|
263
264
|
if (prefix === "-") {
|
|
264
265
|
const padded = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
265
|
-
output.push(`-${padded}${hashPad}
|
|
266
|
+
output.push(`-${padded}${hashPad}${CONTENT_SEP}${text}`);
|
|
266
267
|
oldLineNum++;
|
|
267
268
|
} else if (prefix === "+") {
|
|
268
269
|
const padded = String(newLineNum).padStart(lineNumWidth, " ");
|
|
269
270
|
const hash = computeLineHash(newLineNum, text);
|
|
270
|
-
output.push(`+${padded}
|
|
271
|
+
output.push(`+${padded}${ANCHOR_SEP}${hash}${CONTENT_SEP}${text}`);
|
|
271
272
|
newLineNum++;
|
|
272
273
|
} else {
|
|
273
274
|
const padded = String(newLineNum).padStart(lineNumWidth, " ");
|
|
274
275
|
const hash = computeLineHash(newLineNum, text);
|
|
275
|
-
output.push(` ${padded}
|
|
276
|
+
output.push(` ${padded}${ANCHOR_SEP}${hash}${CONTENT_SEP}${text}`);
|
|
276
277
|
oldLineNum++;
|
|
277
278
|
newLineNum++;
|
|
278
279
|
}
|
package/src/edit.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
applyHashlineEdits,
|
|
18
18
|
resolveEditAnchors,
|
|
19
19
|
type HashlineToolEdit,
|
|
20
|
+
ANCHOR_SEP,
|
|
20
21
|
} from "./hashline";
|
|
21
22
|
import { loadFileKindAndText } from "./file-kind";
|
|
22
23
|
import { resolveToCwd } from "./path-utils";
|
|
@@ -29,7 +30,7 @@ const editEntrySchema = Type.Object(
|
|
|
29
30
|
{
|
|
30
31
|
range: Type.Tuple([Type.String(), Type.String()], {
|
|
31
32
|
description:
|
|
32
|
-
|
|
33
|
+
`LINE${ANCHOR_SEP}HASH anchor pair [start, end] copied from a recent \`read\` or diff output. Use the same anchor twice for single-line: ["42${ANCHOR_SEP}A4", "42${ANCHOR_SEP}A4"].`,
|
|
33
34
|
}),
|
|
34
35
|
lines: Type.Array(Type.String(), {
|
|
35
36
|
description: "New content lines. Use [] to delete.",
|
|
@@ -41,7 +42,7 @@ export const hashlineEditToolSchema = Type.Object(
|
|
|
41
42
|
{
|
|
42
43
|
path: Type.String({ description: "path" }),
|
|
43
44
|
edits: Type.Array(editEntrySchema, {
|
|
44
|
-
description:
|
|
45
|
+
description: `Edits to apply to $path. Each edit replaces the range [start, end] with lines. Use the same anchor twice for single-line; use [] to delete.`,
|
|
45
46
|
}),
|
|
46
47
|
},
|
|
47
48
|
{ additionalProperties: false },
|
package/src/hashline.ts
CHANGED
|
@@ -39,6 +39,9 @@ const DICT = Array.from({ length: 256 }, (_, i) => {
|
|
|
39
39
|
return `${HEX[h]}${HEX[l]}`;
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
export const ANCHOR_SEP = "#";
|
|
43
|
+
export const CONTENT_SEP = "│";
|
|
44
|
+
|
|
42
45
|
// FNV-1a 32-bit constants
|
|
43
46
|
const FNV_OFFSET = 0x811c9dc5;
|
|
44
47
|
const FNV_PRIME = 0x01000193;
|
|
@@ -48,10 +51,10 @@ const FNV_PRIME = 0x01000193;
|
|
|
48
51
|
* payloads. The runtime no longer strips them — the model must send literal
|
|
49
52
|
* file content. Matching any of these triggers `[E_INVALID_PATCH]`.
|
|
50
53
|
*/
|
|
51
|
-
const HASHLINE_PREFIX_RE =
|
|
52
|
-
|
|
53
|
-
const HASHLINE_PREFIX_PLUS_RE =
|
|
54
|
-
|
|
54
|
+
const HASHLINE_PREFIX_RE = new RegExp(
|
|
55
|
+
`^\\s*(?:>>>|>>)?\\s*(?:\\d+\\s*${ANCHOR_SEP}\\s*|${ANCHOR_SEP}\\s*)?[0-9A-F]{2}${CONTENT_SEP}`);
|
|
56
|
+
const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
57
|
+
`^\\+\\s*(?:\\d+\\s*${ANCHOR_SEP}\\s*|${ANCHOR_SEP}\\s*)?[0-9A-F]{2}${CONTENT_SEP}`);
|
|
55
58
|
const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
56
59
|
|
|
57
60
|
export function computeLineHash(idx: number, line: string): string {
|
|
@@ -90,16 +93,16 @@ function diagnoseLineRef(ref: string): string {
|
|
|
90
93
|
const core = ref.replace(/^\s*[>+-]*\s*/, "").trim();
|
|
91
94
|
|
|
92
95
|
if (!core.length) {
|
|
93
|
-
return `[E_BAD_REF] Invalid line reference "${ref}". Expected "LINE
|
|
96
|
+
return `[E_BAD_REF] Invalid line reference "${ref}". Expected "LINE${ANCHOR_SEP}HASH" (e.g. "5${ANCHOR_SEP}MQ").`;
|
|
94
97
|
}
|
|
95
98
|
if (/^\d+\s*$/.test(core)) {
|
|
96
|
-
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash, use "LINE
|
|
99
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash, use "LINE${ANCHOR_SEP}HASH" from read output (e.g. "5${ANCHOR_SEP}MQ").`;
|
|
97
100
|
}
|
|
98
|
-
if (
|
|
99
|
-
return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE
|
|
101
|
+
if (new RegExp(`^\d+\s*[:${CONTENT_SEP}]`).test(core)) {
|
|
102
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE${ANCHOR_SEP}HASH" instead of "LINE:..." or "LINE${CONTENT_SEP}...".`;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
const hashMatch = core.match(
|
|
105
|
+
const hashMatch = core.match(new RegExp(`^(\d+)\s*${ANCHOR_SEP}\s*([^\s${CONTENT_SEP}]+)(?:\s*${CONTENT_SEP}.*)?$`));
|
|
103
106
|
if (hashMatch) {
|
|
104
107
|
const line = Number.parseInt(hashMatch[1]!, 10);
|
|
105
108
|
const hash = hashMatch[2]!;
|
|
@@ -114,16 +117,16 @@ function diagnoseLineRef(ref: string): string {
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
const missingHashMatch = core.match(
|
|
120
|
+
const missingHashMatch = core.match(new RegExp(`^(\d+)\s*${ANCHOR_SEP}\s*$`));
|
|
118
121
|
if (missingHashMatch) {
|
|
119
|
-
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash after "
|
|
122
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash after "${ANCHOR_SEP}", use "LINE${ANCHOR_SEP}HASH" from read output.`;
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
if (
|
|
125
|
+
if (new RegExp(`^0+\s*${ANCHOR_SEP}`).test(core)) {
|
|
123
126
|
return `[E_BAD_REF] Line number must be >= 1, got 0 in "${ref}".`;
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
return `[E_BAD_REF] Invalid line reference "${trimmed || ref}". Expected "LINE
|
|
129
|
+
return `[E_BAD_REF] Invalid line reference "${trimmed || ref}". Expected "LINE${ANCHOR_SEP}HASH" (e.g. "5${ANCHOR_SEP}MQ").`;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
export function parseLineRef(ref: string): { line: number; hash: string } {
|
|
@@ -136,7 +139,7 @@ export function parseLineRef(ref: string): { line: number; hash: string } {
|
|
|
136
139
|
|
|
137
140
|
function parseAnchorRef(ref: string): Anchor {
|
|
138
141
|
const core = ref.replace(/^\s*[>+-]*\s*/, "").trimEnd();
|
|
139
|
-
const match = core.match(
|
|
142
|
+
const match = core.match(new RegExp(`^([0-9]+)\s*${ANCHOR_SEP}\s*([^\s${CONTENT_SEP}]+)(?:\s*${CONTENT_SEP}(.*))?$`, 's'));
|
|
140
143
|
if (!match) {
|
|
141
144
|
throw new Error(diagnoseLineRef(ref));
|
|
142
145
|
}
|
|
@@ -203,9 +206,9 @@ function formatMismatchError(
|
|
|
203
206
|
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
204
207
|
const maxDisplayLine = sorted[sorted.length - 1] ?? 1;
|
|
205
208
|
const lineNumberWidth = String(maxDisplayLine).length;
|
|
206
|
-
const anchorList = uniqueMismatches.map((m) => `${m.line}
|
|
209
|
+
const anchorList = uniqueMismatches.map((m) => `${m.line}${ANCHOR_SEP}${m.expected}`).join(", ");
|
|
207
210
|
const out: string[] = [
|
|
208
|
-
`[E_STALE_ANCHOR] ${uniqueMismatches.length} stale anchor${uniqueMismatches.length > 1 ? "s" : ""}: ${anchorList}. Retry with the >>> LINE
|
|
211
|
+
`[E_STALE_ANCHOR] ${uniqueMismatches.length} stale anchor${uniqueMismatches.length > 1 ? "s" : ""}: ${anchorList}. Retry with the >>> LINE${ANCHOR_SEP}HASH lines below; keep both endpoints for range replaces.`,
|
|
209
212
|
"",
|
|
210
213
|
];
|
|
211
214
|
|
|
@@ -215,11 +218,11 @@ function formatMismatchError(
|
|
|
215
218
|
prev = num;
|
|
216
219
|
const content = fileLines[num - 1];
|
|
217
220
|
const hash = computeLineHash(num, content);
|
|
218
|
-
const prefix = `${String(num).padStart(lineNumberWidth, " ")}
|
|
221
|
+
const prefix = `${String(num).padStart(lineNumberWidth, " ")}${ANCHOR_SEP}${hash}`;
|
|
219
222
|
out.push(
|
|
220
223
|
retryLineSet.has(num)
|
|
221
|
-
? `>>> ${prefix}
|
|
222
|
-
: ` ${prefix}
|
|
224
|
+
? `>>> ${prefix}${CONTENT_SEP}${content}`
|
|
225
|
+
: ` ${prefix}${CONTENT_SEP}${content}`,
|
|
223
226
|
);
|
|
224
227
|
}
|
|
225
228
|
|
|
@@ -242,7 +245,7 @@ function assertNoDisplayPrefixes(lines: string[]): void {
|
|
|
242
245
|
DIFF_MINUS_RE.test(line)
|
|
243
246
|
) {
|
|
244
247
|
throw new Error(
|
|
245
|
-
`[E_INVALID_PATCH] "lines" must contain literal file content, not rendered "LINE
|
|
248
|
+
`[E_INVALID_PATCH] "lines" must contain literal file content, not rendered "LINE${ANCHOR_SEP}HASH${CONTENT_SEP}" or diff "+/-" prefixes. Offending line: ${JSON.stringify(line)}`,
|
|
246
249
|
);
|
|
247
250
|
}
|
|
248
251
|
}
|
|
@@ -435,15 +438,15 @@ function describeEdit(edit: HashlineEdit): string {
|
|
|
435
438
|
switch (edit.op) {
|
|
436
439
|
case "replace":
|
|
437
440
|
return edit.end
|
|
438
|
-
? `replace ${edit.pos.line}
|
|
439
|
-
: `replace ${edit.pos.line}
|
|
441
|
+
? `replace ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}-${edit.end.line}${ANCHOR_SEP}${edit.end.hash}`
|
|
442
|
+
: `replace ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`;
|
|
440
443
|
case "append":
|
|
441
444
|
return edit.pos
|
|
442
|
-
? `append after ${edit.pos.line}
|
|
445
|
+
? `append after ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`
|
|
443
446
|
: "append at EOF";
|
|
444
447
|
case "prepend":
|
|
445
448
|
return edit.pos
|
|
446
|
-
? `prepend before ${edit.pos.line}
|
|
449
|
+
? `prepend before ${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`
|
|
447
450
|
: "prepend at BOF";
|
|
448
451
|
case "replace_text":
|
|
449
452
|
return `replace_text \"${previewText(edit.oldText)}\"`;
|
|
@@ -575,7 +578,7 @@ function resolveEditToSpan(
|
|
|
575
578
|
) {
|
|
576
579
|
noopEdits.push({
|
|
577
580
|
editIndex: index,
|
|
578
|
-
loc: `${edit.pos.line}
|
|
581
|
+
loc: `${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}`,
|
|
579
582
|
currentContent: originalLines.join("\n"),
|
|
580
583
|
});
|
|
581
584
|
return null;
|
|
@@ -627,7 +630,7 @@ function resolveEditToSpan(
|
|
|
627
630
|
if (edit.lines.length === 0) {
|
|
628
631
|
noopEdits.push({
|
|
629
632
|
editIndex: index,
|
|
630
|
-
loc: edit.pos ? `${edit.pos.line}
|
|
633
|
+
loc: edit.pos ? `${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}` : "EOF",
|
|
631
634
|
currentContent: edit.pos ? fileLines[edit.pos.line - 1] ?? "" : "",
|
|
632
635
|
});
|
|
633
636
|
return null;
|
|
@@ -678,7 +681,7 @@ function resolveEditToSpan(
|
|
|
678
681
|
if (edit.lines.length === 0) {
|
|
679
682
|
noopEdits.push({
|
|
680
683
|
editIndex: index,
|
|
681
|
-
loc: edit.pos ? `${edit.pos.line}
|
|
684
|
+
loc: edit.pos ? `${edit.pos.line}${ANCHOR_SEP}${edit.pos.hash}` : "BOF",
|
|
682
685
|
currentContent: edit.pos ? fileLines[edit.pos.line - 1] ?? "" : "",
|
|
683
686
|
});
|
|
684
687
|
return null;
|
|
@@ -988,7 +991,7 @@ export function formatHashlineRegion(
|
|
|
988
991
|
.map((line, index) => {
|
|
989
992
|
const lineNumber = startLine + index;
|
|
990
993
|
const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, " ");
|
|
991
|
-
return `${paddedLineNumber}
|
|
994
|
+
return `${paddedLineNumber}${ANCHOR_SEP}${computeLineHash(lineNumber, line)}${CONTENT_SEP}${line}`;
|
|
992
995
|
})
|
|
993
996
|
.join("\n");
|
|
994
997
|
}
|