@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 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:` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH:` columns align:
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:function hello() {
41
- 9#3F: console.log("world");
42
- 10#B2:}
40
+ 8#A4function 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:function hello() {
84
- -9 : console.log("world");
85
- +9#B1: console.log("hashline");
86
- 10#B2:}
83
+ 8#A4function hello() {
84
+ -9 console.log("world");
85
+ +9#B1 console.log("hashline");
86
+ 10#B2}
87
87
  ```
88
88
 
89
- - Context lines: ` NN#HH:content` (space prefix)
90
- - Removed lines: `-NN :content` (no hash, aligned colon)
91
- - Added lines: `+NN#HH:content` (hash for new anchors)
89
+ - Context lines: ` NN#HHcontent` (space prefix)
90
+ - Removed lines: `-NN content` (no hash, aligned separator)
91
+ - Added lines: `+NN#HHcontent` (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:` 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jerryan/pi-hashline-edit",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Hashline read/edit tool override for pi-coding-agent",
5
5
  "repository": {
6
6
  "type": "git",
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-prefixed output. Match indentation exactly.
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:content` — copy those anchors verbatim into `edit`.
1
+ Read a UTF-8 text file or a supported image. Text lines are prefixed `LINE#HASHcontent` — 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(3); // align with `#HH:`
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}:${text}`);
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}#${hash}:${text}`);
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}#${hash}:${text}`);
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
- 'LINE#HASH anchor pair [start, end] copied from a recent `read` or `--- Anchors ---` block. Use the same anchor twice for single-line: ["42#A4", "42#A4"].',
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: "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
+ 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
- /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[0-9A-F]{2}:/;
53
- const HASHLINE_PREFIX_PLUS_RE =
54
- /^\+\s*(?:\d+\s*#\s*|#\s*)[0-9A-F]{2}:/;
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#HASH" (e.g. "5#MQ").`;
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#HASH" from read output (e.g. "5#MQ").`;
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 (/^\d+\s*:/.test(core)) {
99
- return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE#HASH" instead of "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(/^(\d+)\s*#\s*([^\s:]+)(?:\s*:.*)?$/);
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(/^(\d+)\s*#\s*$/);
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 "#", use "LINE#HASH" from read output.`;
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 (/^0+\s*#/.test(core)) {
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#HASH" (e.g. "5#MQ").`;
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(/^([0-9]+)\s*#\s*([^\s:]+)(?:\s*:(.*))?$/s);
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}#${m.expected}`).join(", ");
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#HASH lines below; keep both endpoints for range replaces.`,
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, " ")}#${hash}`;
221
+ const prefix = `${String(num).padStart(lineNumberWidth, " ")}${ANCHOR_SEP}${hash}`;
219
222
  out.push(
220
223
  retryLineSet.has(num)
221
- ? `>>> ${prefix}:${content}`
222
- : ` ${prefix}:${content}`,
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#HASH:" or diff "+/-" prefixes. Offending line: ${JSON.stringify(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}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}`
439
- : `replace ${edit.pos.line}#${edit.pos.hash}`;
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}#${edit.pos.hash}`
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}#${edit.pos.hash}`
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}#${edit.pos.hash}`,
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}#${edit.pos.hash}` : "EOF",
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}#${edit.pos.hash}` : "BOF",
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}#${computeLineHash(lineNumber, line)}:${line}`;
994
+ return `${paddedLineNumber}${ANCHOR_SEP}${computeLineHash(lineNumber, line)}${CONTENT_SEP}${line}`;
992
995
  })
993
996
  .join("\n");
994
997
  }