pi-hashline-edit-pro 0.11.2 → 0.11.4

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
@@ -66,19 +66,14 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
66
66
  {
67
67
  "path": "src/main.ts",
68
68
  "edits": [
69
- { "old_range": ["ve7", "ve7"], "new_lines": [" console.log('hashline');"] }
69
+ { "hash_range_incl": ["ve7", "ve7"], "new_lines": [" console.log('hashline');"] }
70
70
  ]
71
71
  }
72
72
  ```
73
-
74
- | Field | Purpose |
75
- |---|---|
76
- | `old_range` | Inclusive line range `[start_hash, end_hash]` (required). |
77
- | `new_lines` | Replacement content (one string per line). Use `[]` to delete. |
73
+ | `hash_range_incl` | Inclusive line range `[start_hash, end_hash]` (required). |
78
74
 
79
75
  - **Request structure validation.** The request envelope (`path`, `edits`) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
80
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
81
-
76
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_incl: ["<START>", "<END>"], new_lines: [...]}`.
82
77
  All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
83
78
 
84
79
  ### Chained edits
@@ -105,7 +100,7 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
105
100
  - **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH from each line into the next replace call.
106
101
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
107
102
  - **Strict patch content.** If `new_lines` contains `+HASH│` display prefixes (or `-N ` diff rows), the edit is rejected with `[E_INVALID_PATCH]`. Bare `HASH│` content (the first 4 chars of a `new_lines` entry looking like 3 base64 chars + `│`) is also rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
108
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{old_range: ["<START>", "<END>"], new_lines: [...]}`.
103
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_incl: ["<START>", "<END>"], new_lines: [...]}`.
109
104
  - **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.
110
105
  - **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.
111
106
  - **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning includes the surviving line's post-edit hash so the model can reference it in a follow-up edit. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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": {
package/prompts/read.md CHANGED
@@ -12,6 +12,7 @@ File kinds:
12
12
  - Text files are returned as `HASH│content` lines.
13
13
  - Images (JPEG, PNG, GIF, WebP) are returned as visual attachments.
14
14
  - Binary files and directories are rejected with a descriptive error.
15
+ - Empty files are returned as a single empty-line hash (`HASH│`). Use replace on that hash to insert content.
15
16
 
16
17
  Non-UTF-8 bytes:
17
- - Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens.
18
+ - UTF-8 byte-order marks (BOM) are stripped. Editing a file with a BOM rewrites it without the BOM.
@@ -7,10 +7,6 @@ How to use:
7
7
  1. Call `read` to get HASH anchors:
8
8
  ```
9
9
  read({ path: "src/main.ts" })
10
- // Returns:
11
- // MQX│const x = 1;
12
- // ZPM│const y = 2;
13
- // VRW│const z = 3;
14
10
  ```
15
11
 
16
12
  2. Copy the 3-character HASH (before `│`) into `hash_range_incl`:
@@ -48,6 +44,22 @@ Examples:
48
44
  ] }
49
45
  ```
50
46
 
47
+ 4. Append after the last line (include the old last line so the new line is added after it):
48
+
49
+ ```json
50
+ { "path": "src/main.ts", "edits": [
51
+ { "hash_range_incl": ["ZPM", "ZPM"], "new_lines": ["old last line", "new line"] }
52
+ ] }
53
+ ```
54
+
55
+ 5. Seed content into an empty file (replace the single empty-line hash returned by read):
56
+
57
+ ```json
58
+ { "path": "src/main.ts", "edits": [
59
+ { "hash_range_incl": ["aB3", "aB3"], "new_lines": ["first line", "second line"] }
60
+ ] }
61
+ ```
62
+
51
63
  ⚠️ Common mistake: do not copy the `HASH│` prefix into `new_lines`.
52
64
 
53
65
  Wrong:
@@ -92,7 +104,7 @@ Error recovery:
92
104
  - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
93
105
  - `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
94
106
  - `[E_BAD_SHAPE]` — malformed request or edit item (missing fields, wrong types, unknown fields).
95
- - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_range` format detected. Use `{hash_range_incl, new_lines}` instead.
107
+ - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_incl, new_lines}` instead.
96
108
  - `[E_EDIT_CONFLICT]` — two edits overlap on the same line range. Make edits non-overlapping.
97
109
  - `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
98
110
  - `[E_BARE_HASH_PREFIX]` — a `new_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_incl` uses hashes, `new_lines` does not.
package/src/fs-write.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  open,
6
6
  readlink,
7
7
  rename,
8
+ rm,
8
9
  stat,
9
10
  writeFile,
10
11
  } from "fs/promises";
@@ -102,5 +103,14 @@ export async function writeAtomic(
102
103
  await tempHandle.close();
103
104
  }
104
105
 
105
- await rename(tempPath, targetPath);
106
+ try {
107
+ await rename(tempPath, targetPath);
108
+ } catch (error: unknown) {
109
+ try {
110
+ await rm(tempPath, { force: true });
111
+ } catch {
112
+ // best-effort cleanup
113
+ }
114
+ throw error;
115
+ }
106
116
  }
@@ -216,7 +216,8 @@ export function applyEdits(
216
216
  edits: import("./resolve").HEdit[],
217
217
  signal?: AbortSignal,
218
218
  precomputedHashes?: string[],
219
- ): {
219
+ filePath?: string,
220
+ ): {
220
221
  content: string;
221
222
  firstChangedLine: number | undefined;
222
223
  lastChangedLine: number | undefined;
@@ -252,12 +253,11 @@ export function applyEdits(
252
253
  );
253
254
  if (mismatches.length) {
254
255
  throw new Error(
255
- fmtMismatch(mismatches, lineIndex.fileLines, fileHashes),
256
+ fmtMismatch(mismatches, lineIndex.fileLines, fileHashes, filePath),
256
257
  );
257
258
  }
258
259
 
259
- const barePrefixWarnings = assertNoBarePrefix(edits, lineIndex.fileLines, fileHashes);
260
- warnings.push(...barePrefixWarnings);
260
+ assertNoBarePrefix(edits, lineIndex.fileLines, fileHashes);
261
261
  warnUnicodeEsc(edits, warnings);
262
262
 
263
263
  const orderedSpans = resSpans(
@@ -62,6 +62,17 @@ function canon(line: string): string {
62
62
  return line.replace(/\r/g, "").trimEnd();
63
63
  }
64
64
 
65
+ /**
66
+ * Compute a unique 3-character hash for every line of `content`.
67
+ *
68
+ * Each line is hashed with xxHash32 over its canonical form (trailing
69
+ * whitespace and CR characters stripped). If the base hash collides with a
70
+ * hash already assigned to an earlier line, a retry counter (`:R{retry}`)
71
+ * is appended to the canonical content and the hash is recomputed until a
72
+ * unique anchor is found. This perfect-hashing step guarantees every line
73
+ * in a file receives a distinct anchor, even when multiple lines contain
74
+ * identical text (e.g. repeated `}` or `import` statements).
75
+ */
65
76
  export function lineHashes(content: string): string[] {
66
77
  const lines = content.split("\n");
67
78
  const hashes = new Array<string>(lines.length);
@@ -79,6 +79,7 @@ export function fmtMismatch(
79
79
  mismatches: HMismatch[],
80
80
  fileLines: string[],
81
81
  fileHashes: string[],
82
+ filePath?: string,
82
83
  ): string {
83
84
  assertAligned(fileLines, fileHashes, "fmtMismatch");
84
85
 
@@ -88,15 +89,15 @@ export function fmtMismatch(
88
89
 
89
90
  const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
90
91
  if (notFound.length > 0) {
91
- out.push(
92
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
93
- );
92
+ out.push(
93
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
94
+ );
94
95
  }
95
96
  if (ambiguous.length > 0) {
96
97
  if (out.length > 0) out.push("");
97
- out.push(
98
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
99
- );
98
+ out.push(
99
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH from each line into your next replace call.`
100
+ );
100
101
  for (const m of ambiguous) {
101
102
  const sample = (m.candidates ?? []).slice(0, 5);
102
103
  const more =
@@ -187,8 +188,7 @@ export function assertNoBarePrefix(
187
188
  edits: HEdit[],
188
189
  fileLines: string[],
189
190
  fileHashes: string[],
190
- ): string[] {
191
- assertAligned(fileLines, fileHashes, "assertNoBarePrefix");
191
+ ): void {
192
192
  const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
193
193
  for (let editIndex = 0; editIndex < edits.length; editIndex++) {
194
194
  const edit = edits[editIndex]!;
@@ -198,8 +198,7 @@ export function assertNoBarePrefix(
198
198
  if (match) suspects.push({ line, hash: match[1]!, editIndex, lineIndex });
199
199
  }
200
200
  }
201
- if (suspects.length === 0) return [];
202
-
201
+ if (suspects.length === 0) return;
203
202
  const locations = suspects
204
203
  .map((s) => `edit ${s.editIndex}, new_lines[${s.lineIndex}]`)
205
204
  .join("; ");
package/src/read.ts CHANGED
@@ -51,12 +51,14 @@ export function fmtReadPreview(
51
51
  const startLine = normPosInt(options.offset, "offset") ?? 1;
52
52
  if (totalLines === 0) {
53
53
  if (startLine === 1) {
54
+ const allHashes = precomputedHashes ?? lineHashes(text);
55
+ const emptyLineHash = allHashes[0] ?? "";
54
56
  return {
55
- text: "File is empty. Use edit to insert content.",
57
+ text: `${emptyLineHash}│\n[File is empty. Use replace to insert content.]`,
56
58
  };
57
59
  }
58
60
  return {
59
- text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use edit to insert content.`,
61
+ text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
60
62
  };
61
63
 
62
64
  }
@@ -12,16 +12,6 @@ function coerceEditsArray(edits: unknown): unknown {
12
12
  }
13
13
  }
14
14
 
15
- function normalizeEditItem(item: unknown): unknown {
16
- if (!isRec(item)) return item;
17
- const record: Record<string, unknown> = { ...item };
18
- // Rename old_range to hash_range_incl for backward compatibility
19
- if (has(record, "old_range") && !has(record, "hash_range_incl")) {
20
- record.hash_range_incl = record.old_range;
21
- delete record.old_range;
22
- }
23
- return record;
24
- }
25
15
 
26
16
  export function normReq(input: unknown): unknown {
27
17
  if (!isRec(input)) {
@@ -41,10 +31,6 @@ export function normReq(input: unknown): unknown {
41
31
  record.edits = coerceEditsArray(record.edits);
42
32
  }
43
33
 
44
- // Normalize each edit item: rename old_range to hash_range_incl
45
- if (Array.isArray(record.edits)) {
46
- record.edits = record.edits.map(normalizeEditItem);
47
- }
48
34
 
49
35
  return record;
50
36
  }
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { normReq } from "./replace-normalize";
3
+ import type { HTEdit } from "./hashline";
3
4
  import type { ReqParams, ReplaceDetails } from "./replace";
4
5
  import { isRec } from "./utils";
5
6
 
@@ -31,12 +32,16 @@ export function getPreviewInput(
31
32
  return null;
32
33
  }
33
34
 
34
- const request: ReqParams = { path: normalized.path };
35
- if (Array.isArray(normalized.edits)) {
36
- request.edits = normalized.edits as any;
35
+ if (!Array.isArray(normalized.edits)) {
36
+ return null;
37
37
  }
38
38
 
39
- return request.edits !== undefined ? request : null;
39
+ const request: ReqParams = {
40
+ path: normalized.path,
41
+ edits: normalized.edits as HTEdit[],
42
+ };
43
+
44
+ return request;
40
45
  }
41
46
 
42
47
  export function colorLines(lines: string[], theme: FgT): string[] {
@@ -1,3 +1,4 @@
1
+ import type { ReplaceDetails } from "./replace";
1
2
  import { genDiff } from "./replace-diff";
2
3
  import {
3
4
  affRange,
@@ -10,7 +11,7 @@ import { ANCHOR_BUDGET } from "./constants";
10
11
  type TResult = {
11
12
  content: Array<{ type: "text"; text: string }>;
12
13
  isError?: boolean;
13
- details: any;
14
+ details: ReplaceDetails;
14
15
  };
15
16
 
16
17
  export type RMetrics = {
@@ -175,7 +176,7 @@ export function buildChanged(input: SuccessInput): TResult {
175
176
  : "Anchors omitted; use read for subsequent edits.";
176
177
  })()
177
178
  : resultLines.length === 0
178
- ? "File is empty. Use edit to insert content."
179
+ ? "File is empty. Use replace to insert content."
179
180
  : "";
180
181
  const text = [anchorsBlock, warningsBlock.trimStart()]
181
182
  .filter((section) => section.length > 0)
package/src/replace.ts CHANGED
@@ -66,16 +66,14 @@ const editItemSchema = Type.Object(
66
66
  export const editToolSchema = Type.Object(
67
67
  {
68
68
  path: Type.String({ description: "path" }),
69
- edits: Type.Optional(
70
- Type.Array(editItemSchema, { description: "edits over $path" }),
71
- ),
69
+ edits: Type.Array(editItemSchema, { description: "edits over $path" }),
72
70
  },
73
71
  { additionalProperties: false },
74
72
  );
75
73
 
76
74
  export type ReqParams = {
77
75
  path: string;
78
- edits?: HTEdit[];
76
+ edits: HTEdit[];
79
77
  };
80
78
 
81
79
  export type ReplaceDetails = {
@@ -99,7 +97,7 @@ export function assertReq(
99
97
  throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
100
98
  }
101
99
 
102
- for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "start", "end", "lines"]) {
100
+ for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
103
101
  if (has(request, legacyKey)) {
104
102
  throw new Error(
105
103
  `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_incl: ["<START>", "<END>"], new_lines: [...]}.`
@@ -113,8 +111,8 @@ export function assertReq(
113
111
  throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
114
112
  }
115
113
 
116
- if (has(request, "edits") && !Array.isArray(request.edits)) {
117
- throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array when provided. Each edit is { hash_range_incl: ["<START>", "<END>"], new_lines: [...] }.');
114
+ if (!Array.isArray(request.edits)) {
115
+ throw new Error('[E_BAD_SHAPE] Edit request requires an "edits" array. Each edit is { hash_range_incl: ["<START>", "<END>"], new_lines: [...] }.');
118
116
  }
119
117
  }
120
118
 
@@ -157,6 +155,7 @@ async function execPipeline(
157
155
  resolved,
158
156
  signal,
159
157
  originalHashes,
158
+ path,
160
159
  );
161
160
 
162
161
  return {