pi-hashline-edit-pro 0.16.3 → 0.16.5

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
@@ -56,7 +56,7 @@ Optional parameters:
56
56
  - `offset` -- start reading from this line number (1-indexed).
57
57
  - `limit` -- maximum number of lines to return.
58
58
 
59
- Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files return an advisory suggesting using replace to insert content.
59
+ Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files are returned as a single empty-line hash (`HASH│`). Use replace on that hash to insert content.
60
60
 
61
61
  ### `replace` -- hash-anchored modifications
62
62
 
@@ -66,9 +66,10 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
66
66
 
67
67
  ```json
68
68
  {
69
- "path": "src/main.ts",
70
69
  "changes": [
71
70
  { "content_lines": [" console.log('hashline');"], "hash_range_inclusive": ["ve7", "ve7"] }
71
+ ],
72
+ "path": "src/main.ts"
72
73
  }
73
74
  ```
74
75
 
@@ -93,7 +94,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
93
94
 
94
95
  ### Stable hashing across edits
95
96
 
96
- Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.json`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime diffs the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
97
+ Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.json`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
97
98
 
98
99
  The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
99
100
 
@@ -144,7 +145,7 @@ The file is created automatically when any setting is toggled. Both fields are i
144
145
  - **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.
145
146
  - **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 is a short header followed by a hashline-anchored window: the duplicated pair plus 2 lines of context before and after, shown as plain `HASH│content` rows with post-edit hashes. Seeing the duplication in context (rather than just being told a hash) is what prompts the model to act on it — and since the hashes are current and staleness is per-line, the model can remove the duplicate in one follow-up `replace` without re-reading. 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.
146
147
  - **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
147
- - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). When called from the replace pipeline, it diffs old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines.
148
+ - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines.
148
149
  ## Hashing
149
150
 
150
151
  Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
@@ -157,7 +158,7 @@ The runtime always precomputes the full per-line hash array for a file via `line
157
158
 
158
159
  ### Bare-prefix detector
159
160
 
160
- With the `│` delimiter format, the bare-prefix detector regex `^\s*[A-Za-z0-9_\-]{3}│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
161
+ With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
161
162
 
162
163
  ## Development
163
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
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": {
@@ -3,4 +3,5 @@
3
3
  - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
4
  - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
5
5
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
+ - **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
6
7
  - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
@@ -80,6 +80,18 @@ Right:
80
80
  ```json
81
81
  { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
82
82
 
83
+ ⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
84
+
85
+ Wrong:
86
+ ```json
87
+ { "changes": [{ "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"] }], "path": "src/main.ts" }
88
+
89
+ Right:
90
+ ```json
91
+ { "changes": [{ "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"] }], "path": "src/main.ts" }
92
+
93
+ `content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
94
+
83
95
  Rules:
84
96
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
85
97
  - To delete a range, use `content_lines: []`.
@@ -3,4 +3,5 @@
3
3
  - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
4
  - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
5
5
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
+ - **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
6
7
  - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
@@ -65,6 +65,18 @@ Right:
65
65
  ```json
66
66
  { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
67
67
 
68
+ ⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
69
+
70
+ Wrong:
71
+ ```json
72
+ { "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"], "path": "src/main.ts" }
73
+
74
+ Right:
75
+ ```json
76
+ { "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"], "path": "src/main.ts" }
77
+
78
+ `content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
79
+
68
80
  Rules:
69
81
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
70
82
  - To delete a range, use `content_lines: []`.
@@ -3,5 +3,6 @@
3
3
  - On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_inclusive`, and retry.
4
4
  - `hash_range_inclusive` replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put the replacement lines in `content_lines` — do not include lines that already exist outside the range.
5
5
  - **Preserve leading whitespace (indentation) exactly.** The content after `│` in read output includes all leading spaces and tabs — copy them into `content_lines` unchanged.
6
+ - **`content_lines` must be a native JSON array of strings**, not a JSON string. Do NOT serialize it: `"content_lines": "[\"line1\", \"line2\"]"` is WRONG. Use `"content_lines": ["line1", "line2"]` instead.
6
7
  - Two modes are available: bulk (default, uses `changes` array) and flat (top-level `hash_range_inclusive`/`content_lines`). Toggle with `/toggle-replace-mode`.
7
8
  - If the response includes a "Boundary duplication" warning, the edge line of your `content_lines` matches a line outside the replaced range. Remove that line from `content_lines` — the original is still in the file outside the range.
@@ -127,6 +127,18 @@ Right:
127
127
  ```json
128
128
  { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
129
129
 
130
+ ⚠️ Common mistake: do not serialize `content_lines` as a JSON string.
131
+
132
+ Wrong:
133
+ ```json
134
+ { "content_lines": "[\"line1\", \"line2\"]", "hash_range_inclusive": ["F4T", "F4T"] }
135
+
136
+ Right:
137
+ ```json
138
+ { "content_lines": ["line1", "line2"], "hash_range_inclusive": ["F4T", "F4T"] }
139
+
140
+ `content_lines` must be a native JSON array of strings, not a string that looks like an array. Pass it as a proper JSON array value.
141
+
130
142
  Rules:
131
143
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
132
144
  - To delete a range, use `content_lines: []`.
@@ -56,13 +56,13 @@ function assertNoPrefixes(lines: string[]): void {
56
56
  }
57
57
 
58
58
  export function parseText(edit: string[] | string | null): string[] {
59
- if (edit === null) return [];
60
- const lines =
61
- typeof edit === "string"
62
- ? (edit.endsWith("\n") ? edit.slice(0, -1) : edit)
63
- .replaceAll("\r", "")
64
- .split("\n")
65
- : edit;
66
- assertNoPrefixes(lines);
67
- return lines;
59
+ if (edit === null) return [];
60
+ if (typeof edit === "string") {
61
+ throw new Error(
62
+ `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
63
+ + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
64
+ );
65
+ }
66
+ assertNoPrefixes(edit);
67
+ return edit;
68
68
  }
@@ -148,6 +148,13 @@ function assertItem(edit: Record<string, unknown>, index: number): void {
148
148
  throw new Error(`[E_BAD_SHAPE] Edit ${index} requires a "content_lines" field. Provide the replacement lines (use [] to delete).`);
149
149
  }
150
150
  if ("content_lines" in edit && !isStrArr(edit.content_lines)) {
151
+ const val = edit.content_lines;
152
+ if (typeof val === "string") {
153
+ throw new Error(
154
+ `[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a native JSON array of strings, not a JSON string.`
155
+ + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
156
+ );
157
+ }
151
158
  throw new Error(`[E_BAD_SHAPE] Edit ${index} field "content_lines" must be a string array.`);
152
159
  }
153
160
  if (!isStrPair(edit.hash_range_inclusive)) {
@@ -1,5 +1,17 @@
1
1
  import { isRec, has } from "./utils";
2
2
 
3
+ function assertContentLinesNotString(
4
+ value: unknown,
5
+ label: string,
6
+ ): void {
7
+ if (typeof value === "string") {
8
+ throw new Error(
9
+ `[E_BAD_SHAPE] ${label}: "content_lines" must be a native JSON array of strings, not a JSON string.`
10
+ + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
11
+ );
12
+ }
13
+ }
14
+
3
15
  export function normalizeFilePath(record: Record<string, unknown>): void {
4
16
  if (typeof record.path !== "string" && typeof record.file_path === "string") {
5
17
  record.path = record.file_path;
@@ -31,9 +43,24 @@ export function normReq(input: unknown): unknown {
31
43
 
32
44
  normalizeFilePath(record);
33
45
 
46
+ // Early validation: reject string-typed content_lines at the top level
47
+ if (has(record, "content_lines") && typeof record.content_lines === "string") {
48
+ assertContentLinesNotString(record.content_lines, "Top-level");
49
+ }
50
+
34
51
  normalizeField(record, "changes", "changes");
35
52
  normalizeField(record, "edits", "changes");
36
53
 
54
+ // Validate items in the changes array before wrapping flat format
55
+ if (Array.isArray(record.changes)) {
56
+ for (let i = 0; i < record.changes.length; i++) {
57
+ const item = record.changes[i];
58
+ if (isRec(item) && has(item, "content_lines") && typeof item.content_lines === "string") {
59
+ assertContentLinesNotString(item.content_lines, `changes[${i}]`);
60
+ }
61
+ }
62
+ }
63
+
37
64
  if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
38
65
  const hri = record.hash_range_inclusive;
39
66
  const cl = record.content_lines;