pi-hashline-edit-pro 0.3.1 → 0.3.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 +16 -15
- package/package.json +1 -1
- package/prompts/edit-snippet.md +1 -1
- package/prompts/edit.md +12 -12
- package/prompts/read-guidelines.md +2 -2
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +8 -8
- package/src/edit-diff.ts +2 -7
- package/src/edit-normalize.ts +0 -29
- package/src/edit-response.ts +2 -26
- package/src/edit.ts +4 -50
- package/src/hashline/apply.ts +3 -75
- package/src/hashline/hash.ts +10 -114
- package/src/hashline/index.ts +0 -32
- package/src/hashline/parse.ts +6 -57
- package/src/hashline/resolve.ts +11 -119
- package/src/read.ts +0 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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-editing workflow. **Strict semantics** — no silent relocation, no autocorrection, no fuzzy fallback. **Higher-entropy anchors** —
|
|
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-editing workflow. **Strict semantics** — no silent relocation, no autocorrection, no fuzzy fallback. **Higher-entropy anchors** — 4-character content hashes over a 64-character URL-safe base64 alphabet (24 bits / 16 777 216 buckets) so birthday-paradox collisions are effectively zero in any realistic file.
|
|
4
4
|
|
|
5
5
|
This is a 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 compounding ways: a 4-character hash length and an occurrence-aware discriminator that makes identical content at different positions hash to different values.
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ pi install /path/to/pi-hashline-edit-pro
|
|
|
29
29
|
|
|
30
30
|
### `read` — tagged line output
|
|
31
31
|
|
|
32
|
-
Text files are returned with a
|
|
32
|
+
Text files are returned with a `HASH│content` prefix on every line. The line number is no longer part of the wire format — only the 4-character hash followed by the `│` separator and the line content. Example output for the source below; the hashes are the real xxHash-derived values for the file content shown:
|
|
33
33
|
|
|
34
34
|
```js
|
|
35
35
|
function hello() {
|
|
@@ -40,12 +40,12 @@ function hello() {
|
|
|
40
40
|
would be returned as:
|
|
41
41
|
|
|
42
42
|
```text
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
0qH3│function hello() {
|
|
44
|
+
szJr│ console.log("world");
|
|
45
|
+
_zlP│}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
- `HASH` —
|
|
48
|
+
- `HASH` — 4-character content hash from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`).
|
|
49
49
|
|
|
50
50
|
Optional parameters:
|
|
51
51
|
|
|
@@ -56,13 +56,13 @@ Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not parti
|
|
|
56
56
|
|
|
57
57
|
### `edit` — hash-anchored modifications
|
|
58
58
|
|
|
59
|
-
Edits use the
|
|
59
|
+
Edits use the `HASH│content` anchors from `read` output to target lines precisely:
|
|
60
60
|
|
|
61
61
|
```json
|
|
62
62
|
{
|
|
63
63
|
"path": "src/main.ts",
|
|
64
64
|
"edits": [
|
|
65
|
-
{ "op": "replace", "start": "
|
|
65
|
+
{ "op": "replace", "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
|
|
66
66
|
]
|
|
67
67
|
}
|
|
68
68
|
```
|
|
@@ -80,32 +80,33 @@ All edits in a single call validate against the same pre-edit snapshot and apply
|
|
|
80
80
|
|
|
81
81
|
### Chained edits
|
|
82
82
|
|
|
83
|
-
After a successful edit, the result text contains an `--- Anchors ---` block with fresh
|
|
83
|
+
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.
|
|
84
84
|
|
|
85
85
|
### Auto-read after write
|
|
86
86
|
|
|
87
|
-
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
|
|
87
|
+
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:
|
|
88
88
|
|
|
89
89
|
1. `write` a file → result includes hashline anchors
|
|
90
90
|
2. `edit` using those anchors directly
|
|
91
91
|
|
|
92
92
|
For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use `read` with `offset` to see more.
|
|
93
|
+
|
|
93
94
|
### Diff for the host
|
|
94
95
|
|
|
95
|
-
The post-edit diff (with `+`/`-` markers and new
|
|
96
|
+
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.
|
|
96
97
|
|
|
97
98
|
## Design Decisions
|
|
98
99
|
|
|
99
|
-
- **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes fresh `>>>
|
|
100
|
+
- **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.
|
|
100
101
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
101
|
-
- **Strict patch content.** If `lines` contains
|
|
102
|
+
- **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]` — issue #24. 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; the model should rephrase the line (quote it, escape the separator, or use a different identifier shape) and retry.
|
|
102
103
|
- **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`).
|
|
103
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.
|
|
104
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.
|
|
105
106
|
|
|
106
107
|
## Hashing
|
|
107
108
|
|
|
108
|
-
Hashes are computed with [xxhashjs](https://github.com/pierrec/js-xxhash) (xxHash32), then mapped to a
|
|
109
|
+
Hashes are computed with [xxhashjs](https://github.com/pierrec/js-xxhash) (xxHash32), then mapped to a 4-character string from the URL-safe base64 alphabet `A-Za-z0-9-_` — 64 distinct characters, 6 bits per position, **24 bits of entropy per anchor**.
|
|
109
110
|
|
|
110
111
|
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.
|
|
111
112
|
|
|
@@ -120,7 +121,7 @@ The runtime always precomputes the full per-line hash array for a file via `comp
|
|
|
120
121
|
|
|
121
122
|
### Trade-off: the bare-prefix detector
|
|
122
123
|
|
|
123
|
-
With the
|
|
124
|
+
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.
|
|
124
125
|
|
|
125
126
|
## Development
|
|
126
127
|
|
package/package.json
CHANGED
package/prompts/edit-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Edit a text file via
|
|
1
|
+
Edit a text file via HASH anchors from read
|
package/prompts/edit.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
Patch a text file using
|
|
1
|
+
Patch a text file using `HASH` anchors copied verbatim from `read`.
|
|
2
2
|
|
|
3
3
|
Put all operations on one file in a single `edit` call. Stack every region into the `edits` array, even when they are far apart. Anchors within one call must all come from the same pre-edit read; the runtime applies them atomically against that one snapshot, so you do not adjust anchors for line-number shifts between edits in the same call.
|
|
4
4
|
|
|
5
|
-
Anchors are
|
|
5
|
+
Anchors are 4 characters (e.g. `aB3x`), alphabet `A-Za-z0-9-_`. The wire format for `start`/`end`/`pos` is the anchor only — no line number, no trailing content, no line content.
|
|
6
6
|
|
|
7
7
|
Ops:
|
|
8
8
|
- `replace` — replace the inclusive range `start`..`end`. Both anchors are required. Single line: `start = end`. To delete a range, use `lines: []`. Do NOT use the `pos` field on `replace`; use `start`.
|
|
@@ -14,14 +14,14 @@ Examples:
|
|
|
14
14
|
1. Single line replace:
|
|
15
15
|
```json
|
|
16
16
|
{ "path": "src/main.ts", "edits": [
|
|
17
|
-
{ "op": "replace", "start": "
|
|
17
|
+
{ "op": "replace", "start": "MQXV", "end": "MQXV", "lines": ["const x = 1;"] }
|
|
18
18
|
] }
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
2. Range replace (3 lines → 3 new lines):
|
|
22
22
|
```json
|
|
23
23
|
{ "path": "src/main.ts", "edits": [
|
|
24
|
-
{ "op": "replace", "start": "
|
|
24
|
+
{ "op": "replace", "start": "ZPMQ", "end": "VRWS", "lines": [
|
|
25
25
|
"function greet(name) {",
|
|
26
26
|
" return `Hello, ${name}`;",
|
|
27
27
|
"}"
|
|
@@ -32,27 +32,27 @@ Examples:
|
|
|
32
32
|
3. Multiple regions in one call (delete two non-adjacent ranges, insert before a third anchor):
|
|
33
33
|
```json
|
|
34
34
|
{ "path": "src/server.ts", "edits": [
|
|
35
|
-
{ "op": "replace", "start": "
|
|
36
|
-
{ "op": "replace", "start": "
|
|
37
|
-
{ "op": "prepend", "pos": "
|
|
35
|
+
{ "op": "replace", "start": "aB3x", "end": "xY7q", "lines": [] },
|
|
36
|
+
{ "op": "replace", "start": "MQXV", "end": "ZPMQ", "lines": [] },
|
|
37
|
+
{ "op": "prepend", "pos": "VRWS", "lines": ["// inserted before VRWS"] }
|
|
38
38
|
] }
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Rules:
|
|
42
42
|
- `replace` requires both `start` and `end`. A single-line replace is `start=X, end=X`. To replace more than one line, set `end` to a different line's anchor.
|
|
43
|
-
- `start`, `end`, `pos` are HASH anchors only (e.g.
|
|
44
|
-
- `lines` is literal file content. No
|
|
43
|
+
- `start`, `end`, `pos` are HASH anchors only (e.g. `aB3x`). Other forms are rejected with `[E_BAD_REF]`.
|
|
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
47
|
- If `lines` matches the current content byte-for-byte, the edit is classified as `Classification: noop` (file unchanged, not an error).
|
|
48
48
|
|
|
49
|
-
On success (`changed` mode, default), the response text contains an `--- Anchors ---` block with fresh
|
|
49
|
+
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.
|
|
50
50
|
|
|
51
|
-
Errors are text starting with a bracketed code (e.g. `[E_BAD_SHAPE]`, `[E_STALE_ANCHOR]`, `[E_BAD_OP]`, `[E_INVALID_PATCH]`, `[E_LEGACY_SHAPE]`, `[E_EDIT_CONFLICT]`, `[E_BAD_REF]`, `[E_AMBIGUOUS_ANCHOR]`, `[E_BARE_HASH_PREFIX]`, `[E_WOULD_EMPTY]`). The message tells you what to retry; stale-anchor errors include `>>>
|
|
51
|
+
Errors are text starting with a bracketed code (e.g. `[E_BAD_SHAPE]`, `[E_STALE_ANCHOR]`, `[E_BAD_OP]`, `[E_INVALID_PATCH]`, `[E_LEGACY_SHAPE]`, `[E_EDIT_CONFLICT]`, `[E_BAD_REF]`, `[E_AMBIGUOUS_ANCHOR]`, `[E_BARE_HASH_PREFIX]`, `[E_WOULD_EMPTY]`). The message tells you what to retry; stale-anchor errors include `>>> HASH│content` lines, ready to copy.
|
|
52
52
|
|
|
53
53
|
The legacy `oldText`/`newText` shape (top-level or as `op: "replace_text"`) is rejected with `[E_LEGACY_SHAPE]`. Use hash-anchored edits instead.
|
|
54
54
|
|
|
55
55
|
Auto-read after write:
|
|
56
|
-
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with
|
|
56
|
+
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with `HASH│content` for the written file.
|
|
57
57
|
- Use those anchors directly for `edit` calls without a separate `read`.
|
|
58
58
|
- This enables a seamless write → edit workflow with no extra tool calls.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- Use read before edit when you do not have current HASH anchors for the file.
|
|
2
|
-
- Copy exactly the HASH (the
|
|
3
|
-
- A HASH
|
|
2
|
+
- Copy exactly the HASH (the 4 characters before the `│`); never include the `│` or line content in `pos`/`end`.
|
|
3
|
+
- A HASH may contain `-` as a normal alphabet character.
|
package/prompts/read-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file with
|
|
1
|
+
Read a text file with HASH│content anchors for edit (copy the HASH into `start`/`end`/`pos`)
|
package/prompts/read.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
Read a text file. Each line is returned as
|
|
1
|
+
Read a text file. Each line is returned as `HASH│content`. The HASH is 4 base64 characters; the content after the `│` separator is the line verbatim. Pass the HASH (e.g. `aB3x`) into `edit`'s `start`/`end` (for `replace`) or `pos` (for `append`/`prepend`) — never include the line content.
|
|
2
2
|
|
|
3
3
|
HASH shape:
|
|
4
|
-
-
|
|
4
|
+
- 4 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3x`, `4yN-`, `-qkl`).
|
|
5
5
|
- The line number is not part of the wire format. Anchor by HASH, never by reading a line number off the rendered output.
|
|
6
6
|
|
|
7
7
|
HASH → edit:
|
|
8
|
-
- Copy the full
|
|
9
|
-
- Do not include the
|
|
8
|
+
- Copy the full 4-character HASH. Use that HASH as `start` or `end` (for `replace`) or `pos` (for `append`/`prepend`) in the next `edit` call.
|
|
9
|
+
- Do not include the `│`, the line content, or surrounding whitespace. The wire format for `start`/`end`/`pos` is the HASH only.
|
|
10
10
|
|
|
11
11
|
Pagination:
|
|
12
12
|
- Large files return a truncated preview with a `nextOffset` line. Call `read` again with `offset=nextOffset` to continue.
|
|
@@ -14,15 +14,15 @@ Pagination:
|
|
|
14
14
|
- Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
|
|
15
15
|
|
|
16
16
|
Error recovery:
|
|
17
|
-
- `[E_STALE_ANCHOR]` — the file changed since your last read. The error includes fresh `>>>
|
|
18
|
-
- `[E_BAD_REF]` — malformed HASH. Re-read and try again with a valid HASH anchor (e.g.
|
|
17
|
+
- `[E_STALE_ANCHOR]` — the file changed since your last read. The error includes fresh `>>> HASH│content` lines; copy the HASH portion (the 4 chars before `│`) and retry.
|
|
18
|
+
- `[E_BAD_REF]` — malformed HASH. Re-read and try again with a valid HASH anchor (e.g. `aB3x`).
|
|
19
19
|
|
|
20
20
|
File kinds:
|
|
21
|
-
- Text files are returned as
|
|
21
|
+
- Text files are returned as `HASH│content` lines.
|
|
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
25
|
Auto-read after write:
|
|
26
|
-
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with
|
|
26
|
+
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with `HASH│content` for the written file.
|
|
27
27
|
- Use those anchors directly for `edit` calls without a separate `read`.
|
|
28
28
|
- The auto-read output follows the same format and rules as `read` output.
|
package/src/edit-diff.ts
CHANGED
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
ANCHOR_LENGTH,
|
|
5
5
|
} from "./hashline";
|
|
6
6
|
|
|
7
|
-
// ─── Line ending normalization ──────────────────────────────────────────
|
|
8
7
|
|
|
9
8
|
export function detectLineEnding(content: string): "\r\n" | "\n" {
|
|
10
9
|
const crlfIdx = content.indexOf("\r\n");
|
|
@@ -30,7 +29,6 @@ export function stripBom(content: string): { bom: string; text: string } {
|
|
|
30
29
|
: { bom: "", text: content };
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
// ─── Diff generation ────────────────────────────────────────────────────
|
|
34
32
|
|
|
35
33
|
function formatDiffPreviewLine(
|
|
36
34
|
prefix: " " | "+" | "-",
|
|
@@ -38,12 +36,9 @@ function formatDiffPreviewLine(
|
|
|
38
36
|
hash: string | undefined,
|
|
39
37
|
): string {
|
|
40
38
|
if (hash === undefined) {
|
|
41
|
-
|
|
42
|
-
// the hash-prefixed lines (` HASH:`, `+HASH:`). Pad with `HASH_LENGTH`
|
|
43
|
-
// spaces so the `:` lines up in the same column.
|
|
44
|
-
return `${prefix}${" ".repeat(ANCHOR_LENGTH)}:${line}`;
|
|
39
|
+
return `${prefix}${" ".repeat(ANCHOR_LENGTH)}│${line}`;
|
|
45
40
|
}
|
|
46
|
-
return `${prefix}${hash}
|
|
41
|
+
return `${prefix}${hash}│${line}`;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
export function generateDiffString(
|
package/src/edit-normalize.ts
CHANGED
|
@@ -1,29 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Single normalization layer that maps the dialects a model may emit onto the
|
|
3
|
-
* canonical hashline edit request before validation runs.
|
|
4
|
-
*
|
|
5
|
-
* The only dialect we still absorb is the `file_path` → `path` alias and the
|
|
6
|
-
* JSON-stringified `edits` array. Pi's native legacy `oldText`/`newText`
|
|
7
|
-
* shape (whether top-level or as `op: "replace_text"`) is no longer
|
|
8
|
-
* supported: the hashline protocol requires hash-anchored edits, and the
|
|
9
|
-
* legacy text-matching path is what produces the
|
|
10
|
-
* `[E_NO_MATCH] replace_text found no exact unique match` failure mode the
|
|
11
|
-
* model hits on whitespace/Unicode drift. Any model that still emits the
|
|
12
|
-
* legacy shape is rejected with a clear error in `assertEditItem` /
|
|
13
|
-
* `assertEditRequest` so it learns the correct shape on the next turn.
|
|
14
|
-
*
|
|
15
|
-
* This runs as the tool's `prepareArguments` hook, which Pi executes before AJV
|
|
16
|
-
* schema validation and before `execute()`. The output is plain enumerable data
|
|
17
|
-
* (an `edits` array), so Pi's `structuredClone` of prepareArguments output keeps
|
|
18
|
-
* every field.
|
|
19
|
-
*/
|
|
20
1
|
|
|
21
2
|
import { isRecord, hasOwn } from "./utils";
|
|
22
3
|
|
|
23
|
-
/**
|
|
24
|
-
* Parse `edits` when a model serializes it as a JSON string instead of an array
|
|
25
|
-
* (observed with some models, mirrors Pi's built-in edit handling).
|
|
26
|
-
*/
|
|
27
4
|
function coerceEditsArray(edits: unknown): unknown {
|
|
28
5
|
if (typeof edits !== "string") {
|
|
29
6
|
return edits;
|
|
@@ -37,12 +14,6 @@ function coerceEditsArray(edits: unknown): unknown {
|
|
|
37
14
|
}
|
|
38
15
|
|
|
39
16
|
|
|
40
|
-
/**
|
|
41
|
-
* Normalize a raw edit-tool request into the canonical hashline shape.
|
|
42
|
-
*
|
|
43
|
-
* Returns the input unchanged when it is not an object, so malformed payloads
|
|
44
|
-
* still reach validation and surface a precise error there.
|
|
45
|
-
*/
|
|
46
17
|
export function normalizeEditRequest(input: unknown): unknown {
|
|
47
18
|
if (!isRecord(input)) {
|
|
48
19
|
return input;
|
package/src/edit-response.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Edit response builders.
|
|
3
|
-
*
|
|
4
|
-
* Pulled out of `src/edit.ts` execute() so each returnMode branch
|
|
5
|
-
* (noop / full / ranges / changed) is independently testable and the
|
|
6
|
-
* top-level execute path stays narrative.
|
|
7
|
-
*
|
|
8
|
-
* No behaviour change: outputs are byte-identical to the previous inline
|
|
9
|
-
* implementation. The only additive surface is `details.metrics` (Phase 2 C
|
|
10
|
-
* — observability for hosts; the LLM-visible text is unchanged).
|
|
11
|
-
*/
|
|
12
1
|
|
|
13
2
|
import { generateDiffString } from "./edit-diff";
|
|
14
3
|
import {
|
|
@@ -18,10 +7,6 @@ import {
|
|
|
18
7
|
} from "./hashline";
|
|
19
8
|
import { formatHashlineReadPreview } from "./read";
|
|
20
9
|
|
|
21
|
-
// Local shape — pi-coding-agent does not export a public `ToolResult`. The
|
|
22
|
-
// builders return `details` as `any` so callers can keep their own per-tool
|
|
23
|
-
// details type without re-asserting it here. This file intentionally does
|
|
24
|
-
// not import the agent's tool-result type to stay decoupled from internals.
|
|
25
10
|
type ToolResult = {
|
|
26
11
|
content: Array<{ type: "text"; text: string }>;
|
|
27
12
|
isError?: boolean;
|
|
@@ -30,7 +15,6 @@ type ToolResult = {
|
|
|
30
15
|
|
|
31
16
|
const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
|
|
32
17
|
|
|
33
|
-
// ─── Public types ───────────────────────────────────────────────────────
|
|
34
18
|
|
|
35
19
|
export type ReturnMode = "changed" | "full" | "ranges";
|
|
36
20
|
|
|
@@ -52,14 +36,6 @@ export type FullContentPreview = {
|
|
|
52
36
|
nextOffset?: number;
|
|
53
37
|
};
|
|
54
38
|
|
|
55
|
-
/**
|
|
56
|
-
* Host-visible, opt-in observability surface (Phase 2 C). The LLM never sees
|
|
57
|
-
* this — it lives in `details` only. Hosts can use it for dashboards,
|
|
58
|
-
* adoption metrics, or regression alarms (e.g. "noop rate spiking").
|
|
59
|
-
*
|
|
60
|
-
* snake_case is intentional: most observability backends prefer it and
|
|
61
|
-
* avoiding camelCase saves a transform on the host side.
|
|
62
|
-
*/
|
|
63
39
|
export type EditMetrics = {
|
|
64
40
|
edits_attempted: number;
|
|
65
41
|
edits_noop: number;
|
|
@@ -194,7 +170,7 @@ function truncateOutlineEntry(text: string, max = 88): string {
|
|
|
194
170
|
function collectOutlineEntries(previewText: string): string[] {
|
|
195
171
|
const structural: string[] = [];
|
|
196
172
|
for (const line of previewText.split("\n")) {
|
|
197
|
-
const match = line.match(/^\s
|
|
173
|
+
const match = line.match(/^\s*([A-Za-z0-9_\-]{4})│(.*)$/);
|
|
198
174
|
if (!match) continue;
|
|
199
175
|
const content = match[2]!.trim();
|
|
200
176
|
if (content.length === 0) continue;
|
|
@@ -250,7 +226,7 @@ function formatRequestedRangePreviews(
|
|
|
250
226
|
},
|
|
251
227
|
precomputedHashes,
|
|
252
228
|
);
|
|
253
|
-
const hasReturnedLines = /^\s
|
|
229
|
+
const hasReturnedLines = /^\s*[A-Za-z0-9_\-]{4}│/m.test(preview.text);
|
|
254
230
|
const actualEnd = hasReturnedLines
|
|
255
231
|
? preview.nextOffset !== undefined
|
|
256
232
|
? preview.nextOffset - 1
|
package/src/edit.ts
CHANGED
|
@@ -61,7 +61,7 @@ function stringEnumSchema<const Values extends readonly string[]>(
|
|
|
61
61
|
|
|
62
62
|
const hashlineEditLinesSchema = Type.Array(Type.String(), {
|
|
63
63
|
description:
|
|
64
|
-
"replacement content, one array entry per line, no HASH
|
|
64
|
+
"replacement content, one array entry per line, no HASH| prefix",
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
const returnRangeSchema = Type.Object(
|
|
@@ -92,19 +92,19 @@ const hashlineEditItemSchema = Type.Object(
|
|
|
92
92
|
start: Type.Optional(
|
|
93
93
|
Type.String({
|
|
94
94
|
description:
|
|
95
|
-
"required range-start anchor for op \"replace\" (hash anchor like \"
|
|
95
|
+
"required range-start anchor for op \"replace\" (hash anchor like \"aB3x\" copied from read output); no content may follow the anchor",
|
|
96
96
|
}),
|
|
97
97
|
),
|
|
98
98
|
end: Type.Optional(
|
|
99
99
|
Type.String({
|
|
100
100
|
description:
|
|
101
|
-
"required range-end anchor for op \"replace\" (hash anchor like \"
|
|
101
|
+
"required range-end anchor for op \"replace\" (hash anchor like \"aB3x\"). To replace a single line, set start = end = the line's anchor",
|
|
102
102
|
}),
|
|
103
103
|
),
|
|
104
104
|
pos: Type.Optional(
|
|
105
105
|
Type.String({
|
|
106
106
|
description:
|
|
107
|
-
"anchor for op \"append\" or \"prepend\" (hash anchor like \"
|
|
107
|
+
"anchor for op \"append\" or \"prepend\" (hash anchor like \"aB3x\"). Omit for file-boundary insertion (EOF/BOF).",
|
|
108
108
|
}),
|
|
109
109
|
),
|
|
110
110
|
lines: Type.Optional(hashlineEditLinesSchema),
|
|
@@ -127,11 +127,6 @@ export const hashlineEditToolSchema = Type.Object(
|
|
|
127
127
|
edits: Type.Optional(
|
|
128
128
|
Type.Array(hashlineEditItemSchema, { description: "edits over $path" }),
|
|
129
129
|
),
|
|
130
|
-
// File-path alias and JSON-stringified edits are still absorbed by
|
|
131
|
-
// normalizeEditRequest in the prepareArguments hook, which runs before
|
|
132
|
-
// this schema is validated. The legacy native top-level oldText/newText
|
|
133
|
-
// dialect is NOT folded — it is rejected outright with [E_LEGACY_SHAPE]
|
|
134
|
-
// in assertEditRequest, so it never reaches the schema validator.
|
|
135
130
|
},
|
|
136
131
|
{ additionalProperties: false },
|
|
137
132
|
);
|
|
@@ -205,15 +200,6 @@ const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
|
205
200
|
|
|
206
201
|
const ROOT_KEYS = new Set(["path", "returnMode", "returnRanges", "edits"]);
|
|
207
202
|
|
|
208
|
-
// Validates the canonical edit request envelope after normalizeEditRequest has
|
|
209
|
-
// converged any model dialects. Per-edit structural validation is delegated to
|
|
210
|
-
// resolveEditAnchors (src/hashline.ts), which is the single source of truth for
|
|
211
|
-
// edit-item shape + op constraints. This function validates only the root-level
|
|
212
|
-
// request fields: path, returnMode, returnRanges, and that edits is an array.
|
|
213
|
-
//
|
|
214
|
-
// Intentional overlap with the published TypeBox schema: pi normally runs AJV
|
|
215
|
-
// validation before execute(), but that can be disabled in environments without
|
|
216
|
-
// runtime code generation support, so the semantic checks here are the backstop.
|
|
217
203
|
export function assertEditRequest(
|
|
218
204
|
request: unknown,
|
|
219
205
|
): asserts request is EditRequestParams {
|
|
@@ -221,12 +207,6 @@ export function assertEditRequest(
|
|
|
221
207
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
222
208
|
}
|
|
223
209
|
|
|
224
|
-
// The legacy native top-level oldText/newText dialect (with or without the
|
|
225
|
-
// snake_case aliases) is no longer supported. Hash-anchored edits are the
|
|
226
|
-
// only path — the legacy shape is what produces
|
|
227
|
-
// `[E_NO_MATCH] replace_text found no exact unique match` on real-world
|
|
228
|
-
// whitespace/Unicode drift. Reject early with a clear error so the model
|
|
229
|
-
// learns the right shape on the next turn.
|
|
230
210
|
for (const legacyKey of ["oldText", "newText", "old_text", "new_text"]) {
|
|
231
211
|
if (hasOwn(request, legacyKey)) {
|
|
232
212
|
throw new Error(
|
|
@@ -310,16 +290,8 @@ export function assertEditRequest(
|
|
|
310
290
|
);
|
|
311
291
|
}
|
|
312
292
|
|
|
313
|
-
// Per-edit validation lives in resolveEditAnchors — the single source of
|
|
314
|
-
// truth for edit-item shape, op constraints, and anchor parsing.
|
|
315
293
|
}
|
|
316
294
|
|
|
317
|
-
/**
|
|
318
|
-
* Shared edit pipeline: normalize, validate, read file, resolve anchors,
|
|
319
|
-
* and apply edits. Both `computeEditPreview` (dry-run) and `execute()`
|
|
320
|
-
* (real) call this; the access mode parameter controls whether the file
|
|
321
|
-
* must be writable.
|
|
322
|
-
*/
|
|
323
295
|
async function executeEditPipeline(
|
|
324
296
|
request: unknown,
|
|
325
297
|
cwd: string,
|
|
@@ -392,10 +364,6 @@ async function executeEditPipeline(
|
|
|
392
364
|
const originalEnding = detectLineEnding(rawContent);
|
|
393
365
|
const originalNormalized = normalizeToLF(rawContent);
|
|
394
366
|
|
|
395
|
-
// Pre-compute hashes for the original file once. The same array is passed
|
|
396
|
-
// into applyHashlineEdits so validation and the stale-anchor retry block
|
|
397
|
-
// agree on what each line's hash is, and so we can return updated
|
|
398
|
-
// occurrence-aware anchors in the response without recomputing.
|
|
399
367
|
const originalHashes = computeLineHashes(originalNormalized);
|
|
400
368
|
|
|
401
369
|
const resolved = resolveEditAnchors(toolEdits);
|
|
@@ -458,16 +426,8 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
458
426
|
description: EDIT_DESC,
|
|
459
427
|
parameters: hashlineEditToolSchema,
|
|
460
428
|
promptSnippet: EDIT_PROMPT_SNIPPET,
|
|
461
|
-
// Converge model dialects (JSON-string edits, file_path alias) onto the
|
|
462
|
-
// canonical hashline shape before Pi validates and before execute(). The
|
|
463
|
-
// legacy top-level oldText/newText dialect is NOT folded — it is rejected
|
|
464
|
-
// outright with [E_LEGACY_SHAPE] in assertEditRequest. See
|
|
465
|
-
// src/edit-normalize.ts.
|
|
466
429
|
prepareArguments: (args: unknown) =>
|
|
467
430
|
normalizeEditRequest(args) as EditRequestParams,
|
|
468
|
-
// Force the default tool shell (Box with pending/success/error background) so
|
|
469
|
-
// we don't inherit renderShell: "self" from the built-in edit tool of the
|
|
470
|
-
// same name, which would drop the shared background color block.
|
|
471
431
|
renderShell: "default",
|
|
472
432
|
renderCall(args, theme, context) {
|
|
473
433
|
const previewInput = getRenderablePreviewInput(args);
|
|
@@ -588,8 +548,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
588
548
|
},
|
|
589
549
|
|
|
590
550
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
591
|
-
// normalizeEditRequest is re-applied here so execute does not depend on
|
|
592
|
-
// prepareArguments having run. Idempotent on canonical input.
|
|
593
551
|
const normalized = normalizeEditRequest(params);
|
|
594
552
|
assertEditRequest(normalized);
|
|
595
553
|
const normalizedParams = normalized;
|
|
@@ -667,10 +625,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
667
625
|
requestedReturnRanges,
|
|
668
626
|
originalNormalized,
|
|
669
627
|
result,
|
|
670
|
-
// Hash the post-edit file once. The result builders will use
|
|
671
|
-
// these for the per-line anchors in the full / ranges / changed
|
|
672
|
-
// response blocks; computing once here is cheaper than letting
|
|
673
|
-
// each builder recompute.
|
|
674
628
|
resultHashes: computeLineHashes(result),
|
|
675
629
|
warnings,
|
|
676
630
|
snapshotId: updatedSnapshotId,
|