pi-hashline-edit-pro 0.6.1 → 0.7.0

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
@@ -1,8 +1,8 @@
1
1
  # pi-hashline-edit-pro
2
2
 
3
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 4-character content hashes over a 64-character URL-safe base64 alphabet give 24 bits of entropy per anchor, so collisions are effectively zero in any realistic file.
3
+ A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that replaces the built-in `read` and `edit` tools with a hash-anchored line-replacing workflow. Strict semantics, no silent relocation, no autocorrection, no fuzzy fallback. 3-character content hashes over a 64-character URL-safe base64 alphabet give 18 bits of entropy per anchor, with perfect hashing (collision resolution) ensuring every line gets a unique anchor.
4
4
 
5
- Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design in two ways: a 4-character hash length and an occurrence-aware discriminator that makes identical content at different positions hash to different values.
5
+ Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW. The strict-semantics policy is unchanged. This fork extends the upstream design in three ways: a 3-character hash length with perfect hashing, an occurrence-aware discriminator that makes identical content at different positions hash to different values, and collision resolution that ensures unique anchors for every line in a file.
6
6
 
7
7
  Every line returned by `read` carries a short content hash. Edits reference those hashes instead of raw text, so the tool can detect stale context and reject outdated changes before they reach the file.
8
8
 
@@ -10,10 +10,12 @@ Every line returned by `read` carries a short content hash. Edits reference thos
10
10
 
11
11
  The original uses 2-character hashes of a 16-character alphabet, with the hash being a pure function of line content. That's 8 bits / 256 buckets, and two byte-identical lines (e.g. repeated `import` statements, repeated `}`) always share a hash because the hash is `xxHash32(content)`.
12
12
 
13
- This fork makes two changes that compound:
13
+ This fork makes three changes that compound:
14
14
 
15
- 1. **Bump hash length to 4 characters** of the 64-char URL-safe base64 alphabet. That gives 24 bits / 16 777 216 buckets. Birthday-paradox collisions are effectively nullified for any realistic file.
15
+ 1. **Bump hash length to 3 characters** of the 64-char URL-safe base64 alphabet. That gives 18 bits / 262,144 buckets. With perfect hashing (collision resolution), every line in a file gets a unique anchor.
16
16
  2. **Make the hash occurrence-aware.** The hash for line N is `xxHash32("C{occurrence}:{content}")` where `occurrence` is the running count of that content string earlier in the file. Two `import {...}` statements at different positions now hash to different values, so the model can target a specific occurrence without resorting to `offset` + a small `limit` window. All lines — including symbol-only lines like `}` — use the same occurrence-based discrimination.
17
+ 3. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter in the discriminator: `C{occurrence}:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space.
18
+
17
19
  ## Installation
18
20
 
19
21
  From npm:
@@ -32,7 +34,7 @@ pi install /path/to/pi-hashline-edit-pro
32
34
 
33
35
  ### `read` -- tagged line output
34
36
 
35
- Text files are returned with a `HASH│content` prefix on every line. The line number is not part of the wire format, only the 4-character hash followed by the `│` separator and the line content. Example output for the source below:
37
+ Text files are returned with a `HASH│content` prefix on every line. The line number is not part of the wire format, only the 3-character hash followed by the `│` separator and the line content. Example output for the source below:
36
38
 
37
39
  ```js
38
40
  function hello() {
@@ -43,12 +45,12 @@ function hello() {
43
45
  would be returned as:
44
46
 
45
47
  ```text
46
- 0qH3│function hello() {
47
- szJr│ console.log("world");
48
- _zlP│}
48
+ 0qH│function hello() {
49
+ szJ│ console.log("world");
50
+ _zl│}
49
51
  ```
50
52
 
51
- - `HASH` is a 4-character content hash from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`).
53
+ - `HASH` is a 3-character content hash from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3`).
52
54
 
53
55
  Optional parameters:
54
56
 
@@ -65,7 +67,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
65
67
  {
66
68
  "path": "src/main.ts",
67
69
  "edits": [
68
- { "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
70
+ { "start": "ve7", "end": "ve7", "lines": [" console.log('hashline');"] }
69
71
  ]
70
72
  }
71
73
  ```
@@ -87,11 +89,13 @@ After a successful replace, the result text contains an `--- Anchors ---` block
87
89
 
88
90
  ### Auto-read after write
89
91
 
90
- After a successful `write`, the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the newly written file without requiring a separate `read` call. The workflow becomes:
92
+ Auto-read is **disabled by default**. When enabled, after a successful `write` the extension automatically reads the file and appends a `--- Auto-read (hashline anchors) ---` block to the result. This gives the model immediate `HASH│content` anchors for the newly written file without requiring a separate `read` call. The workflow becomes:
91
93
 
92
94
  1. `write` a file, result includes hashline anchors
93
95
  2. `replace` using those anchors directly
94
96
 
97
+ Toggle at runtime with the `/toggle-auto-read` command. The current state persists for the session.
98
+
95
99
  For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use `read` with `offset` to see more.
96
100
 
97
101
  ### Diff for the host
@@ -100,16 +104,16 @@ The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is ex
100
104
 
101
105
  ## Design Decisions
102
106
 
103
- - **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes fresh `>>> HASH│content` lines for the affected region. The model copies the HASH portion and retries.
107
+ - **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.
104
108
  - **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
105
- - **Strict patch content.** If `lines` contains `+HASH│` display prefixes (or `-N ` diff rows), the edit is rejected with `[E_INVALID_PATCH]`. Bare `HASH│` content (the first 5 chars of a `lines` entry looking like 4 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.
109
+ - **Strict patch content.** If `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 `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.
106
110
  - **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 `{start:"<HASH>", end:"<HASH>", lines:[...]}`.
107
111
  - **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.
108
112
  - **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.
109
113
 
110
114
  ## Hashing
111
115
 
112
- Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 4-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 24 bits of entropy per anchor.
116
+ 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.
113
117
 
114
118
  The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply. The full 64 chars give maximum entropy per character, with case and digits included.
115
119
 
@@ -118,13 +122,15 @@ Hashes are occurrence-aware: a discriminator prefix is mixed into the xxHash inp
118
122
  - `}` on line 5 and `}` on line 17 hash differently (1st vs 2nd occurrence of `}`).
119
123
  - `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (1st vs 2nd occurrence).
120
124
 
125
+ **Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter in the discriminator: `C{occurrence}:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space.
126
+
121
127
  The runtime always precomputes the full per-line hash array for a file via `computeLineHashes(content)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
122
128
 
123
- `HASH_LENGTH` and `HASH_ALPHABET` are constants at the top of `src/hashline/hash.ts`; bump the length to 5 if you ever need even more entropy.
129
+ `HASH_LENGTH` and `HASH_ALPHABET` are constants at the top of `src/hashline/hash.ts`; bump the length to 4 if you need even more entropy without collision resolution.
124
130
 
125
131
  ### Bare-prefix detector
126
132
 
127
- With the `│` delimiter format, the bare-prefix detector regex `^\s*[A-Za-z0-9_\-]{4}│` is highly specific. It only matches lines starting with exactly 4 base64 chars and `│`. 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.
133
+ With the `│` delimiter format, the bare-prefix detector regex `^\s*[A-Za-z0-9_\-]{3}│` is highly specific. It only matches lines starting with exactly 3 base64 chars and `│`. 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.
128
134
 
129
135
  ## Development
130
136
 
@@ -137,6 +143,8 @@ npm test
137
143
 
138
144
  Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
139
145
 
146
+ Set `PI_HASHLINE_AUTO_READ=1` to enable auto-read after write by default (can still be toggled at runtime with `/toggle-auto-read`).
147
+
140
148
  ## Credits
141
149
 
142
150
  - [RimuruW](https://github.com/RimuruW) -- original `pi-hashline-edit` and the strict-semantics policy
package/index.ts CHANGED
@@ -17,9 +17,24 @@ export default function (pi: ExtensionAPI): void {
17
17
  pi.setActiveTools(active.filter((t) => t !== "edit"));
18
18
  });
19
19
 
20
- // Auto-read after write: append hashline read output to write results
21
- // so the model immediately has anchors for subsequent edits.
20
+ // Auto-read after write state - controlled by PI_HASHLINE_AUTO_READ env var (default: disabled).
21
+ // Can be toggled at runtime via /toggle-auto-read command.
22
+ const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
23
+ let autoReadEnabled = autoReadValue === "1" || autoReadValue === "true";
24
+
25
+ // Register toggle-auto-read command
26
+ pi.registerCommand("toggle-auto-read", {
27
+ description: "Toggle automatic hashline anchors after write operations",
28
+ handler: async (_args, ctx) => {
29
+ autoReadEnabled = !autoReadEnabled;
30
+ const state = autoReadEnabled ? "enabled" : "disabled";
31
+ ctx.ui.notify(`Auto-read after write: ${state}`, "info");
32
+ },
33
+ });
34
+
35
+ // Auto-read after write handler - always registered, but checks the flag
22
36
  pi.on("tool_result", async (event, ctx) => {
37
+ if (!autoReadEnabled) return;
23
38
  if (event.toolName !== "write" || event.isError) return;
24
39
 
25
40
  const filePath = (event.input as Record<string, unknown>)?.path;
@@ -30,8 +45,8 @@ export default function (pi: ExtensionAPI): void {
30
45
  const content = await readFile(absolutePath, "utf-8");
31
46
 
32
47
  // Normalize and compute hashline output
33
- const normalized = normalizeToLF(content);
34
- const visibleLines = getVisibleLines(normalized);
48
+ const normalized = normalizeToLF(content);
49
+ const visibleLines = getVisibleLines(normalized);
35
50
 
36
51
  if (visibleLines.length === 0) return;
37
52
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.6.1",
4
- "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
3
+ "version": "0.7.0",
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": {
7
7
  "type": "git",
package/prompts/read.md CHANGED
@@ -1,7 +1,7 @@
1
1
  Read a text file. Each line is returned as `HASH│content`.
2
2
 
3
3
  HASH format:
4
- - The HASH is 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
4
+ - The HASH is 3 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3`, `4yN`, `-qk`).
5
5
  - The content after the `│` separator is the line verbatim.
6
6
  - The line number is not part of the output. Use the HASH to reference lines.
7
7
 
@@ -8,15 +8,15 @@ How to use:
8
8
  ```
9
9
  read({ path: "src/main.ts" })
10
10
  // Returns:
11
- // MQXV│const x = 1;
12
- // ZPMQ│const y = 2;
13
- // VRWS│const z = 3;
11
+ // MQX│const x = 1;
12
+ // ZPM│const y = 2;
13
+ // VRW│const z = 3;
14
14
  ```
15
15
 
16
- 2. Copy the 4-character HASH (before `│`) into `start`/`end`:
16
+ 2. Copy the 3-character HASH (before `│`) into `start`/`end`:
17
17
  ```json
18
18
  { "path": "src/main.ts", "edits": [
19
- { "start": "MQXV", "end": "MQXV", "lines": ["const x = 99;"] }
19
+ { "start": "MQX", "end": "MQX", "lines": ["const x = 99;"] }
20
20
  ] }
21
21
  ```
22
22
 
@@ -25,14 +25,14 @@ Examples:
25
25
  1. Single line replace:
26
26
  ```json
27
27
  { "path": "src/main.ts", "edits": [
28
- { "start": "MQXV", "end": "MQXV", "lines": ["const x = 1;"] }
28
+ { "start": "MQX", "end": "MQX", "lines": ["const x = 1;"] }
29
29
  ] }
30
30
  ```
31
31
 
32
32
  2. Range replace (3 lines → 3 new lines):
33
33
  ```json
34
34
  { "path": "src/main.ts", "edits": [
35
- { "start": "ZPMQ", "end": "VRWS", "lines": [
35
+ { "start": "ZPM", "end": "VRW", "lines": [
36
36
  "function greet(name) {",
37
37
  " return `Hello, ${name}`;",
38
38
  "}"
@@ -43,15 +43,15 @@ Examples:
43
43
  3. Multiple regions in one call (delete two non-adjacent ranges):
44
44
  ```json
45
45
  { "path": "src/server.ts", "edits": [
46
- { "start": "aB3x", "end": "xY7q", "lines": [] },
47
- { "start": "MQXV", "end": "ZPMQ", "lines": [] }
46
+ { "start": "aB3", "end": "xY7", "lines": [] },
47
+ { "start": "MQX", "end": "ZPM", "lines": [] }
48
48
  ] }
49
49
  ```
50
50
 
51
51
  Rules:
52
52
  - `start` and `end` are required. A single-line replace is `start=X, end=X`.
53
53
  - To delete a range, use `lines: []`.
54
- - `start`, `end` are HASH anchors only (e.g. `aB3x`). Do not include `│` or line content.
54
+ - `start`, `end` are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
55
55
  - `lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix, no `+`/`-` diff markers.
56
56
  - Don't add `""` for spacing unless you actually want a new blank line.
57
57
  - Copy anchors from the most recent `read` of the file. Do not guess or construct them.
@@ -61,10 +61,6 @@ Rules:
61
61
 
62
62
  On success, the response contains an `--- Anchors ---` block with fresh HASH anchors for the changed region. Use those for nearby follow-up replaces instead of re-reading.
63
63
 
64
- Auto-read after write:
65
- - After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block.
66
- - Use those anchors directly for `replace` calls without a separate `read`.
67
-
68
64
  Error recovery:
69
65
  - `[E_STALE_ANCHOR]` — file changed since last read. The error includes fresh `>>> HASH│content` lines; copy the HASH and retry.
70
66
  - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
@@ -2,7 +2,7 @@
2
2
  import xxhash from "xxhash-wasm";
3
3
 
4
4
 
5
- export const HASH_LENGTH = 4;
5
+ export const HASH_LENGTH = 3;
6
6
 
7
7
  export const HASH_PREFIX = "";
8
8
 
@@ -81,11 +81,19 @@ export function computeLineHashes(content: string): string[] {
81
81
  const lines = content.split("\n");
82
82
  const hashes = new Array<string>(lines.length);
83
83
  const counts = new Map<string, number>();
84
+ const assigned = new Set<string>();
84
85
  for (let i = 0; i < lines.length; i++) {
85
86
  const canonical = canonicalizeLine(lines[i]!);
86
87
  const occurrence = (counts.get(canonical) ?? 0) + 1;
87
88
  counts.set(canonical, occurrence);
88
- hashes[i] = hashToString(xxh32(`${DISCRIMINATOR(occurrence)}:${canonical}`));
89
+ let hash = hashToString(xxh32(`${DISCRIMINATOR(occurrence)}:${canonical}`));
90
+ let retry = 0;
91
+ while (assigned.has(hash)) {
92
+ retry++;
93
+ hash = hashToString(xxh32(`${DISCRIMINATOR(occurrence)}:${canonical}:R${retry}`));
94
+ }
95
+ assigned.add(hash);
96
+ hashes[i] = hash;
89
97
  }
90
98
  return hashes;
91
99
  }
@@ -15,14 +15,14 @@ function diagnoseHashRef(ref: string): string {
15
15
  const trimmed = ref.trim();
16
16
 
17
17
  if (!trimmed.length) {
18
- return `[E_BAD_REF] Invalid anchor. Expected a 4-character base64 anchor (e.g. \"aB3x\").`;
18
+ return `[E_BAD_REF] Invalid anchor. Expected a 3-character base64 anchor (e.g. \"aB3\").`;
19
19
  }
20
20
 
21
21
  if (/^\d+/.test(trimmed)) {
22
- return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. \"aB3x\") — no line numbers or trailing content.`;
22
+ return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. \"aB3\") — no line numbers or trailing content.`;
23
23
  }
24
24
 
25
- return `[E_BAD_REF] Invalid anchor \"${trimmed}\". Expected a 4-character base64 anchor (e.g. \"aB3x\").`;
25
+ return `[E_BAD_REF] Invalid anchor \"${trimmed}\". Expected a 3-character base64 anchor (e.g. \"aB3\").`;
26
26
  }
27
27
 
28
28
  function parseAnchorRef(ref: string): Anchor {
@@ -166,7 +166,7 @@ function truncateOutlineEntry(text: string, max = 88): string {
166
166
  function collectOutlineEntries(previewText: string): string[] {
167
167
  const structural: string[] = [];
168
168
  for (const line of previewText.split("\n")) {
169
- const match = line.match(/^\s*([A-Za-z0-9_\-]{4})│(.*)$/);
169
+ const match = line.match(/^\s*([A-Za-z0-9_\-]{3})│(.*)$/);
170
170
  if (!match) continue;
171
171
  const content = match[2]!.trim();
172
172
  if (content.length === 0) continue;
@@ -222,7 +222,7 @@ function formatRequestedRangePreviews(
222
222
  },
223
223
  precomputedHashes,
224
224
  );
225
- const hasReturnedLines = /^\s*[A-Za-z0-9_\-]{4}│/m.test(preview.text);
225
+ const hasReturnedLines = /^\s*[A-Za-z0-9_\-]{3}│/m.test(preview.text);
226
226
  const actualEnd = hasReturnedLines
227
227
  ? preview.nextOffset !== undefined
228
228
  ? preview.nextOffset - 1