pi-hashline-edit-pro 0.4.2 → 0.5.1
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 +16 -17
- package/package.json +1 -1
- package/prompts/edit-guidelines.md +3 -0
- package/prompts/edit-snippet.md +1 -1
- package/prompts/edit.md +1 -0
- package/prompts/read.md +3 -0
- package/src/edit.ts +10 -0
- package/src/hashline/hash.ts +7 -22
package/README.md
CHANGED
|
@@ -13,8 +13,7 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
|
|
|
13
13
|
This fork makes two changes that compound:
|
|
14
14
|
|
|
15
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.
|
|
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.
|
|
17
|
-
|
|
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.
|
|
18
17
|
## Installation
|
|
19
18
|
|
|
20
19
|
From npm:
|
|
@@ -33,7 +32,7 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
33
32
|
|
|
34
33
|
### `read` -- tagged line output
|
|
35
34
|
|
|
36
|
-
Text files are returned with a `HASH
|
|
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
36
|
|
|
38
37
|
```js
|
|
39
38
|
function hello() {
|
|
@@ -44,9 +43,9 @@ function hello() {
|
|
|
44
43
|
would be returned as:
|
|
45
44
|
|
|
46
45
|
```text
|
|
47
|
-
0qH3
|
|
48
|
-
szJr
|
|
49
|
-
_zlP
|
|
46
|
+
0qH3│function hello() {
|
|
47
|
+
szJr│ console.log("world");
|
|
48
|
+
_zlP│}
|
|
50
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`).
|
|
@@ -60,7 +59,7 @@ Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not parti
|
|
|
60
59
|
|
|
61
60
|
### `edit` -- hash-anchored modifications
|
|
62
61
|
|
|
63
|
-
Edits use the `HASH
|
|
62
|
+
Edits use the `HASH│content` anchors from `read` output to target lines precisely:
|
|
64
63
|
|
|
65
64
|
```json
|
|
66
65
|
{
|
|
@@ -84,11 +83,11 @@ All edits in a single call validate against the same pre-edit snapshot and apply
|
|
|
84
83
|
|
|
85
84
|
### Chained edits
|
|
86
85
|
|
|
87
|
-
After a successful edit, the result text contains an `--- Anchors ---` block with fresh `HASH
|
|
86
|
+
After a successful edit, the result text contains an `--- Anchors ---` block with fresh `HASH│content` references for the changed region. These can be used directly in the next `edit` call on the same file without a full re-read, provided the next edit targets the same or nearby lines. For distant changes, use `read` first.
|
|
88
87
|
|
|
89
88
|
### Auto-read after write
|
|
90
89
|
|
|
91
|
-
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
|
|
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
91
|
|
|
93
92
|
1. `write` a file, result includes hashline anchors
|
|
94
93
|
2. `edit` using those anchors directly
|
|
@@ -97,27 +96,27 @@ For large files (>2000 lines), the auto-read output is truncated with a paginati
|
|
|
97
96
|
|
|
98
97
|
### Diff for the host
|
|
99
98
|
|
|
100
|
-
The post-edit diff (with `+`/`-` markers and new `HASH
|
|
99
|
+
The post-edit diff (with `+`/`-` markers and new `HASH│content` anchors) is exposed to the host UI via `details.diff`. It is intentionally not in the LLM-visible text. The model only needs the fresh anchors in `text` to chain follow-up edits, and re-emitting the diff would cost extra tokens.
|
|
101
100
|
|
|
102
101
|
## Design Decisions
|
|
103
102
|
|
|
104
|
-
- **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes fresh `>>> HASH
|
|
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.
|
|
105
104
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
106
|
-
- **Strict patch content.** If `lines` contains `+HASH
|
|
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.
|
|
107
106
|
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect and `op: "replace_text"` are rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{op:"replace", start:"<HASH>", end:"<HASH>", lines:[...]}` (or `append`/`prepend` with `pos`).
|
|
108
107
|
- **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.
|
|
109
108
|
- **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.
|
|
110
109
|
|
|
111
110
|
## Hashing
|
|
112
111
|
|
|
113
|
-
Hashes are computed with [
|
|
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.
|
|
114
113
|
|
|
115
114
|
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.
|
|
116
115
|
|
|
117
|
-
Hashes are occurrence-aware: a discriminator prefix is mixed into the xxHash input before the line content.
|
|
116
|
+
Hashes are occurrence-aware: a discriminator prefix is mixed into the xxHash input before the line content. All lines (including symbol-only lines like `}`) use `C{occurrence}` as the discriminator, where `occurrence` is the running count of that canonical content earlier in the file. This way:
|
|
118
117
|
|
|
119
|
-
- `}` on line 5 and `}` on line 17 hash differently (
|
|
120
|
-
- `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (
|
|
118
|
+
- `}` on line 5 and `}` on line 17 hash differently (1st vs 2nd occurrence of `}`).
|
|
119
|
+
- `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (1st vs 2nd occurrence).
|
|
121
120
|
|
|
122
121
|
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` / `edit` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
|
|
123
122
|
|
|
@@ -125,7 +124,7 @@ The runtime always precomputes the full per-line hash array for a file via `comp
|
|
|
125
124
|
|
|
126
125
|
### Bare-prefix detector
|
|
127
126
|
|
|
128
|
-
With the
|
|
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.
|
|
129
128
|
|
|
130
129
|
## Development
|
|
131
130
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
- Use edit with HASH anchors for all file changes; batch every change to one file into a single edit call.
|
|
2
|
+
- After a successful edit, the returned `--- Anchors ---` block replaces a re-read for nearby follow-up edits.
|
|
3
|
+
- On `[E_STALE_ANCHOR]`, retry with the `>>>` lines quoted in the error instead of re-reading the whole file.
|
package/prompts/edit-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Edit a text file via HASH anchors from read
|
|
1
|
+
Edit a text file via HASH anchors from read, batching all edits to a file in one call
|
package/prompts/edit.md
CHANGED
|
@@ -44,6 +44,7 @@ Rules:
|
|
|
44
44
|
- `lines` is literal file content. No `HASH│` prefix, no leading `+`/`-` (those are read/diff metadata, not file content). Lines starting with 4 base64 chars + `│` are checked; if detected, the edit is rejected with `[E_BARE_HASH_PREFIX]`. For `.py` files, this becomes a `[W_BARE_HASH_PREFIX]` warning instead (Python syntax like `else:`, `except:` triggers the detector).
|
|
45
45
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
46
46
|
- All edits in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if: two `replace` ranges overlap; two `append`/`prepend` target the same insertion boundary (e.g. two EOF appends on a newline-terminated file); or an `append`/`prepend` falls inside a `replace` range in the same call. Fix: merge into one, use different boundaries, or split into a follow-up `edit` call.
|
|
47
|
+
- When building `replace` ranges, double-check that `start` is on the first line you want changed and `end` is on the last. If two edits would touch the same lines or adjacent lines, merge them into one `replace` with the combined new content.
|
|
47
48
|
- If `lines` matches the current content byte-for-byte, the edit is classified as `Classification: noop` (file unchanged, not an error).
|
|
48
49
|
|
|
49
50
|
On success (`changed` mode, default), the response text contains an `--- Anchors ---` block with fresh `HASH│content` for the changed region (2 lines of context, capped at ~12 lines / 50 KB). Use those for nearby follow-up edits instead of re-reading. If the response says `Anchors omitted; use read for subsequent edits`, the region was too large — call `read` again. For distant follow-ups, or on any error, call `read` again. `full` and `ranges` modes put previews in `details`; the model only needs what's in the text.
|
package/prompts/read.md
CHANGED
|
@@ -22,6 +22,9 @@ File kinds:
|
|
|
22
22
|
- Images (JPEG, PNG, GIF, WebP) are returned as visual attachments; the HASH-line protocol does not apply.
|
|
23
23
|
- Binary files and directories are rejected with a descriptive error.
|
|
24
24
|
|
|
25
|
+
Non-UTF-8 bytes:
|
|
26
|
+
- Non-UTF-8 bytes are decoded as U+FFFD. The output is flagged when this happens; editing such a file rewrites it as UTF-8. Recover the original encoding with `iconv` afterwards if it must survive.
|
|
27
|
+
|
|
25
28
|
Auto-read after write:
|
|
26
29
|
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with `HASH│content` for the written file.
|
|
27
30
|
- Use those anchors directly for `edit` calls without a separate `read`.
|
package/src/edit.ts
CHANGED
|
@@ -198,6 +198,15 @@ const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
|
198
198
|
"utf-8",
|
|
199
199
|
).trim();
|
|
200
200
|
|
|
201
|
+
|
|
202
|
+
const EDIT_PROMPT_GUIDELINES = readFileSync(
|
|
203
|
+
new URL("../prompts/edit-guidelines.md", import.meta.url),
|
|
204
|
+
"utf-8",
|
|
205
|
+
)
|
|
206
|
+
.split("\n")
|
|
207
|
+
.map((line) => line.trim())
|
|
208
|
+
.filter((line) => line.startsWith("- "))
|
|
209
|
+
.map((line) => line.slice(2));
|
|
201
210
|
const ROOT_KEYS = new Set(["path", "returnMode", "returnRanges", "edits"]);
|
|
202
211
|
|
|
203
212
|
export function assertEditRequest(
|
|
@@ -426,6 +435,7 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
426
435
|
description: EDIT_DESC,
|
|
427
436
|
parameters: hashlineEditToolSchema,
|
|
428
437
|
promptSnippet: EDIT_PROMPT_SNIPPET,
|
|
438
|
+
promptGuidelines: EDIT_PROMPT_GUIDELINES,
|
|
429
439
|
prepareArguments: (args: unknown) =>
|
|
430
440
|
normalizeEditRequest(args) as EditRequestParams,
|
|
431
441
|
renderShell: "default",
|
package/src/hashline/hash.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
|
40
40
|
|
|
41
41
|
export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS})│`);
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
|
|
45
45
|
// Lazy-initialized xxhash-wasm hasher. Initialization starts at module load
|
|
46
46
|
// time and completes in ~2ms. By the time any tool calls xxh32(), the hasher
|
|
@@ -64,50 +64,35 @@ hasherPromise = xxhash().then((h) => {
|
|
|
64
64
|
|
|
65
65
|
// Export for tests that need to await readiness.
|
|
66
66
|
export function ensureHasherReady(): Promise<Hasher> {
|
|
67
|
-
return hasherPromise
|
|
67
|
+
return hasherPromise!
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function xxh32(input: string, seed = 0): number {
|
|
71
71
|
return getHasher().h32(input, seed) >>> 0;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const
|
|
75
|
-
const CONTENT_DISCRIMINATOR = (occurrence: number): string => `C${occurrence}`;
|
|
74
|
+
const DISCRIMINATOR = (occurrence: number): string => `C${occurrence}`;
|
|
76
75
|
|
|
77
76
|
function canonicalizeLine(line: string): string {
|
|
78
77
|
return line.replace(/\r/g, "").trimEnd();
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
function isSymbolOnly(canonical: string): boolean {
|
|
82
|
-
return !RE_SIGNIFICANT.test(canonical);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
80
|
export function computeLineHashes(content: string): string[] {
|
|
86
81
|
const lines = content.split("\n");
|
|
87
82
|
const hashes = new Array<string>(lines.length);
|
|
88
83
|
const counts = new Map<string, number>();
|
|
89
84
|
for (let i = 0; i < lines.length; i++) {
|
|
90
|
-
const lineNumber = i + 1;
|
|
91
85
|
const canonical = canonicalizeLine(lines[i]!);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
} else {
|
|
96
|
-
const occurrence = (counts.get(canonical) ?? 0) + 1;
|
|
97
|
-
counts.set(canonical, occurrence);
|
|
98
|
-
discriminator = CONTENT_DISCRIMINATOR(occurrence);
|
|
99
|
-
}
|
|
100
|
-
hashes[i] = hashToString(xxh32(`${discriminator}:${canonical}`));
|
|
86
|
+
const occurrence = (counts.get(canonical) ?? 0) + 1;
|
|
87
|
+
counts.set(canonical, occurrence);
|
|
88
|
+
hashes[i] = hashToString(xxh32(`${DISCRIMINATOR(occurrence)}:${canonical}`));
|
|
101
89
|
}
|
|
102
90
|
return hashes;
|
|
103
91
|
}
|
|
104
92
|
|
|
105
93
|
export function computeLineHash(idx: number, line: string): string {
|
|
106
94
|
const canonical = canonicalizeLine(line);
|
|
107
|
-
|
|
108
|
-
? SYMBOL_DISCRIMINATOR(idx)
|
|
109
|
-
: CONTENT_DISCRIMINATOR(1);
|
|
110
|
-
return hashToString(xxh32(`${discriminator}:${canonical}`));
|
|
95
|
+
return hashToString(xxh32(`${DISCRIMINATOR(1)}:${canonical}`));
|
|
111
96
|
}
|
|
112
97
|
|
|
113
98
|
export const HASH_FORMAT = {
|