@jerryan/pi-hashline-edit 0.7.0 → 0.7.2
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 +127 -114
- package/package.json +53 -53
- package/prompts/edit.md +3 -3
- package/src/edit-diff.ts +38 -77
- package/src/edit-response.ts +5 -67
- package/src/edit.ts +519 -642
- package/src/hashline.ts +1068 -1058
- package/src/read.ts +10 -2
package/README.md
CHANGED
|
@@ -1,114 +1,127 @@
|
|
|
1
|
-

|
|
2
|
-
|
|
3
|
-
# pi-hashline-edit
|
|
4
|
-
|
|
5
|
-
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.
|
|
6
|
-
|
|
7
|
-
Every line returned by `read` carries a short content hash. Edits reference these hashes instead of raw text, so the tool can detect stale context and reject outdated changes before they reach the file.
|
|
8
|
-
|
|
9
|
-
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
10
|
-
|
|
11
|
-
## Differences from upstream
|
|
12
|
-
|
|
13
|
-
This is a fork of the original [pi-hashline-edit](https://github.com/earendil-works/pi-hashline-edit). The core protocol (hash-anchored reads, stale-anchor rejection, atomic writes) is unchanged from upstream. Key differences:
|
|
14
|
-
|
|
15
|
-
- **Single edit shape.** One entry type: `{ range: [start, end], lines: [...] }`. No `op` field, no `append`/`prepend`/`replace_text` ops, no `after`/`before`. The tuple enforces explicit endpoint anchors, eliminating the common "forgot `end`" failure mode.
|
|
16
|
-
- **Standard hex hash alphabet.** `0-9 A-F` instead of `ZPMQVRWSNKTXJBYH`. Hex pairs are more likely to be single tokens.
|
|
17
|
-
- **Symmetric boundary-duplication detection.** Runtime warnings catch duplicated boundary lines on both sides of a replacement, not just trailing.
|
|
18
|
-
- **`read` raw mode.** `raw: true` returns plain text without `LINE#HASH:` anchors, for reads that don't plan to edit.
|
|
19
|
-
- **Inline FNV-1a hashing.** Replaces `xxhashjs` dependency. Always incorporates line index.
|
|
20
|
-
- **Minimal prompt surface.** Prompt text describes what the model needs to use the tool; return-format documentation and error catalogues are omitted.
|
|
21
|
-
- **No legacy compatibility.** The `{ oldText, newText }` substring-replace format is not accepted. The schema is hashline-only.
|
|
22
|
-
|
|
23
|
-
## Installation
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
# From npm
|
|
27
|
-
pi install npm
|
|
28
|
-
|
|
29
|
-
# From a local checkout
|
|
30
|
-
pi install /path/to/pi-hashline-edit
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## How It Works
|
|
34
|
-
|
|
35
|
-
### `read` — tagged line output
|
|
36
|
-
|
|
37
|
-
Text files are returned with a `LINE#HASH:` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH:` columns align:
|
|
38
|
-
|
|
39
|
-
```text
|
|
40
|
-
8#A4:function hello() {
|
|
41
|
-
9#3F: console.log("world");
|
|
42
|
-
10#B2:}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
- `LINE` — 1-indexed line number.
|
|
46
|
-
- `HASH` — 2-character content hash (hex digits `0-9 A-F`).
|
|
47
|
-
|
|
48
|
-
Optional parameters:
|
|
49
|
-
- `offset` — start reading from this line number (1-indexed).
|
|
50
|
-
- `limit` — maximum number of lines to return.
|
|
51
|
-
- `raw` — when `true`, returns plain text without LINE#HASH anchors. Saves tokens when you don't plan to edit this file.
|
|
52
|
-
|
|
53
|
-
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.
|
|
54
|
-
|
|
55
|
-
### `edit` — hash-anchored modifications
|
|
56
|
-
|
|
57
|
-
Each edit entry replaces an inclusive anchor range:
|
|
58
|
-
|
|
59
|
-
```json
|
|
60
|
-
{
|
|
61
|
-
"path": "src/main.ts",
|
|
62
|
-
"edits": [
|
|
63
|
-
{ "range": ["11#3F", "11#3F"], "lines": [" console.log('hashline');"] },
|
|
64
|
-
{ "range": ["42#B2", "45#C7"], "lines": ["function foo() {", " return 42;", "}"] }
|
|
65
|
-
]
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
- `range` — `[start, end]` pair of LINE#HASH anchors. Use the same anchor twice for single-line.
|
|
70
|
-
- `lines` — new content replacing the range (string array). Use `[]` to delete.
|
|
71
|
-
|
|
72
|
-
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
|
|
73
|
-
|
|
74
|
-
### Chained edits
|
|
75
|
-
|
|
76
|
-
After a successful edit, the
|
|
77
|
-
|
|
78
|
-
### Diff
|
|
79
|
-
|
|
80
|
-
Each edit result
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# pi-hashline-edit
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
Every line returned by `read` carries a short content hash. Edits reference these hashes instead of raw text, so the tool can detect stale context and reject outdated changes before they reach the file.
|
|
8
|
+
|
|
9
|
+
Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
10
|
+
|
|
11
|
+
## Differences from upstream
|
|
12
|
+
|
|
13
|
+
This is a fork of the original [pi-hashline-edit](https://github.com/earendil-works/pi-hashline-edit). The core protocol (hash-anchored reads, stale-anchor rejection, atomic writes) is unchanged from upstream. Key differences:
|
|
14
|
+
|
|
15
|
+
- **Single edit shape.** One entry type: `{ range: [start, end], lines: [...] }`. No `op` field, no `append`/`prepend`/`replace_text` ops, no `after`/`before`. The tuple enforces explicit endpoint anchors, eliminating the common "forgot `end`" failure mode.
|
|
16
|
+
- **Standard hex hash alphabet.** `0-9 A-F` instead of `ZPMQVRWSNKTXJBYH`. Hex pairs are more likely to be single tokens.
|
|
17
|
+
- **Symmetric boundary-duplication detection.** Runtime warnings catch duplicated boundary lines on both sides of a replacement, not just trailing.
|
|
18
|
+
- **`read` raw mode.** `raw: true` returns plain text without `LINE#HASH:` anchors, for reads that don't plan to edit.
|
|
19
|
+
- **Inline FNV-1a hashing.** Replaces `xxhashjs` dependency. Always incorporates line index.
|
|
20
|
+
- **Minimal prompt surface.** Prompt text describes what the model needs to use the tool; return-format documentation and error catalogues are omitted.
|
|
21
|
+
- **No legacy compatibility.** The `{ oldText, newText }` substring-replace format is not accepted. The schema is hashline-only.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# From npm
|
|
27
|
+
pi install npm:@jerryan/pi-hashline-edit
|
|
28
|
+
|
|
29
|
+
# From a local checkout
|
|
30
|
+
pi install /path/to/pi-hashline-edit
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
### `read` — tagged line output
|
|
36
|
+
|
|
37
|
+
Text files are returned with a `LINE#HASH:` prefix on every line. Line numbers may be left-padded within each returned block so the `#HASH:` columns align:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
8#A4:function hello() {
|
|
41
|
+
9#3F: console.log("world");
|
|
42
|
+
10#B2:}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `LINE` — 1-indexed line number.
|
|
46
|
+
- `HASH` — 2-character content hash (hex digits `0-9 A-F`).
|
|
47
|
+
|
|
48
|
+
Optional parameters:
|
|
49
|
+
- `offset` — start reading from this line number (1-indexed).
|
|
50
|
+
- `limit` — maximum number of lines to return.
|
|
51
|
+
- `raw` — when `true`, returns plain text without LINE#HASH anchors. Saves tokens when you don't plan to edit this file.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
### `edit` — hash-anchored modifications
|
|
56
|
+
|
|
57
|
+
Each edit entry replaces an inclusive anchor range:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"path": "src/main.ts",
|
|
62
|
+
"edits": [
|
|
63
|
+
{ "range": ["11#3F", "11#3F"], "lines": [" console.log('hashline');"] },
|
|
64
|
+
{ "range": ["42#B2", "45#C7"], "lines": ["function foo() {", " return 42;", "}"] }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `range` — `[start, end]` pair of LINE#HASH anchors. Use the same anchor twice for single-line.
|
|
70
|
+
- `lines` — new content replacing the range (string array). Use `[]` to delete.
|
|
71
|
+
|
|
72
|
+
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
|
|
73
|
+
|
|
74
|
+
### Chained edits
|
|
75
|
+
|
|
76
|
+
After a successful edit, the response contains a unified diff where context and added lines carry fresh `LINE#HASH` anchors. 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.
|
|
77
|
+
|
|
78
|
+
### Diff output
|
|
79
|
+
|
|
80
|
+
Each edit result shows a unified diff with hashline-formatted lines:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
8#A4:function hello() {
|
|
84
|
+
-9 : console.log("world");
|
|
85
|
+
+9#B1: console.log("hashline");
|
|
86
|
+
10#B2:}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- Context lines: ` NN#HH:content` (space prefix)
|
|
90
|
+
- Removed lines: `-NN :content` (no hash, aligned colon)
|
|
91
|
+
- Added lines: `+NN#HH:content` (hash for new anchors)
|
|
92
|
+
- Multiple hunks are shown when edits are far apart.
|
|
93
|
+
|
|
94
|
+
## Design Decisions
|
|
95
|
+
|
|
96
|
+
- **Stale anchors fail.** A hash mismatch means the file has changed since the last `read`. The error includes a snippet with fresh `LINE#HASH` references for the affected lines for immediate retry.
|
|
97
|
+
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
98
|
+
- **Strict patch content.** If `lines` contains `LINE#HASH:` display prefixes or diff `+`/`-` markers, the edit is rejected with `[E_INVALID_PATCH]`. The model must send literal file content; the runtime does not silently strip accidental prefixes.
|
|
99
|
+
- **Full-file deletion guardrail.** Edits that would empty a file with more than 50 lines are rejected with `[E_WOULD_EMPTY]`. Small files show the full diff normally; large deletions are almost always mistakes.
|
|
100
|
+
- **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.
|
|
101
|
+
- **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.
|
|
102
|
+
- **Schema-delegated validation.** Field-type and schema validation are the responsibility of pi's AJV layer. The extension's runtime guard only prevents crashes from missing required top-level fields.
|
|
103
|
+
|
|
104
|
+
## Hashing
|
|
105
|
+
|
|
106
|
+
Hashes are computed with inline FNV-1a (32-bit, mask-reduced to 8 bits), then mapped to a 2-character hex string from `0-9 A-F`.
|
|
107
|
+
|
|
108
|
+
The line index is always incorporated into the hash, so identical content on different lines produces different hashes.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
Requires [Node.js](https://nodejs.org) and npm.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install
|
|
116
|
+
npm test
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
|
|
120
|
+
|
|
121
|
+
## Credits
|
|
122
|
+
|
|
123
|
+
Thanks to [can1357](https://github.com/can1357) for the original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@jerryan/pi-hashline-edit",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Hashline read/edit tool override for pi-coding-agent",
|
|
5
|
-
"repository": {
|
|
6
|
-
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/JerryAZR/pi-hashline-edit.git"
|
|
8
|
-
},
|
|
9
|
-
"author": "JerryAZR",
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"keywords": [
|
|
14
|
-
"pi-package",
|
|
15
|
-
"pi",
|
|
16
|
-
"coding-agent",
|
|
17
|
-
"extension",
|
|
18
|
-
"hashline"
|
|
19
|
-
],
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"files": [
|
|
22
|
-
"index.ts",
|
|
23
|
-
"src",
|
|
24
|
-
"prompts",
|
|
25
|
-
"README.md",
|
|
26
|
-
"LICENSE"
|
|
27
|
-
],
|
|
28
|
-
"pi": {
|
|
29
|
-
"extensions": [
|
|
30
|
-
"./index.ts"
|
|
31
|
-
]
|
|
32
|
-
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"diff": "^8.0.2",
|
|
35
|
-
"file-type": "^21.3.0"
|
|
36
|
-
},
|
|
37
|
-
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-ai": ">=0.74.0",
|
|
39
|
-
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
40
|
-
"@earendil-works/pi-tui": "*",
|
|
41
|
-
"@sinclair/typebox": "*"
|
|
42
|
-
},
|
|
43
|
-
"scripts": {
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:watch": "vitest"
|
|
46
|
-
},
|
|
47
|
-
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
49
|
-
"@types/node": "^22.0.0",
|
|
50
|
-
"ajv": "^8.20.0",
|
|
51
|
-
"vitest": "^3.0.0"
|
|
52
|
-
}
|
|
53
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@jerryan/pi-hashline-edit",
|
|
3
|
+
"version": "0.7.2",
|
|
4
|
+
"description": "Hashline read/edit tool override for pi-coding-agent",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/JerryAZR/pi-hashline-edit.git"
|
|
8
|
+
},
|
|
9
|
+
"author": "JerryAZR",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"coding-agent",
|
|
17
|
+
"extension",
|
|
18
|
+
"hashline"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src",
|
|
24
|
+
"prompts",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"diff": "^8.0.2",
|
|
35
|
+
"file-type": "^21.3.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": ">=0.74.0",
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"@sinclair/typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
49
|
+
"@types/node": "^22.0.0",
|
|
50
|
+
"ajv": "^8.20.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/prompts/edit.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Patch a UTF-8 text file using `LINE#HASH` anchors copied verbatim from `read`.
|
|
2
2
|
|
|
3
|
-
Submit one `edit` call per file. All operations go in a single `edits` array; anchors must
|
|
3
|
+
Submit one `edit` call per file. All operations go in a single `edits` array; anchors must come from the same fresh source — the most recent `read` or diff output of a successful `edit` on this file.
|
|
4
4
|
|
|
5
5
|
Each edit entry replaces an inclusive anchor range:
|
|
6
6
|
```json
|
|
7
7
|
{ "range": [startAnchor, endAnchor], "lines": [...] }
|
|
8
8
|
```
|
|
9
|
-
- `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read
|
|
9
|
+
- `range` — `[start, end]` pair of LINE#HASH anchors from the most recent `read` or diff output.
|
|
10
10
|
Use the same anchor twice for single-line: `["42#A4", "42#A4"]`.
|
|
11
11
|
- `lines` — new content replacing the range (string array). Use `[]` to delete.
|
|
12
12
|
Must be literal file content, not LINE#HASH-prefixed output. Match indentation exactly.
|
|
@@ -19,5 +19,5 @@ Example:
|
|
|
19
19
|
] }
|
|
20
20
|
|
|
21
21
|
Rules:
|
|
22
|
-
- Do not guess, shift, or construct anchors. Copy them from the most recent `read` of this file.
|
|
22
|
+
- Do not guess, shift, or construct anchors. Copy them from the most recent `read` or diff output of this file.
|
|
23
23
|
- Do not emit overlapping or adjacent edits — merge them into one.
|
package/src/edit-diff.ts
CHANGED
|
@@ -223,102 +223,63 @@ export function replaceText(
|
|
|
223
223
|
|
|
224
224
|
// ─── Diff generation ────────────────────────────────────────────────────
|
|
225
225
|
|
|
226
|
-
function formatDiffPreviewLine(
|
|
227
|
-
prefix: " " | "+" | "-",
|
|
228
|
-
lineNum: number,
|
|
229
|
-
lineNumWidth: number,
|
|
230
|
-
line: string,
|
|
231
|
-
includeHash: boolean,
|
|
232
|
-
): string {
|
|
233
|
-
const paddedLineNum = String(lineNum).padStart(lineNumWidth, " ");
|
|
234
|
-
if (!includeHash) {
|
|
235
|
-
return `${prefix}${paddedLineNum} ${line}`;
|
|
236
|
-
}
|
|
237
|
-
return `${prefix}${paddedLineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
226
|
export function generateDiffString(
|
|
241
227
|
oldContent: string,
|
|
242
228
|
newContent: string,
|
|
243
229
|
contextLines = 4,
|
|
244
|
-
): { diff: string
|
|
245
|
-
const
|
|
246
|
-
|
|
230
|
+
): { diff: string } {
|
|
231
|
+
const patch = Diff.structuredPatch("a", "b", oldContent, newContent, undefined, undefined, {
|
|
232
|
+
context: contextLines,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!patch.hunks.length) {
|
|
236
|
+
return { diff: "" };
|
|
237
|
+
}
|
|
238
|
+
|
|
247
239
|
const maxLineNum = Math.max(
|
|
248
240
|
oldContent.split("\n").length,
|
|
249
241
|
newContent.split("\n").length,
|
|
250
242
|
);
|
|
251
243
|
const lineNumWidth = String(maxLineNum).length;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (raw[raw.length - 1] === "") raw.pop();
|
|
261
|
-
|
|
262
|
-
if (part.added || part.removed) {
|
|
263
|
-
if (firstChangedLine === undefined) firstChangedLine = newLineNum;
|
|
264
|
-
for (const line of raw) {
|
|
265
|
-
if (part.added) {
|
|
266
|
-
output.push(
|
|
267
|
-
formatDiffPreviewLine("+", newLineNum, lineNumWidth, line, true),
|
|
268
|
-
);
|
|
269
|
-
newLineNum++;
|
|
270
|
-
} else {
|
|
271
|
-
output.push(
|
|
272
|
-
formatDiffPreviewLine("-", oldLineNum, lineNumWidth, line, false),
|
|
273
|
-
);
|
|
274
|
-
oldLineNum++;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
lastWasChange = true;
|
|
278
|
-
continue;
|
|
244
|
+
const hashPad = " ".repeat(3); // align with `#HH:`
|
|
245
|
+
|
|
246
|
+
const output: string[] = [];
|
|
247
|
+
|
|
248
|
+
for (let h = 0; h < patch.hunks.length; h++) {
|
|
249
|
+
const hunk = patch.hunks[h]!;
|
|
250
|
+
if (h > 0) {
|
|
251
|
+
output.push(" ...");
|
|
279
252
|
}
|
|
280
253
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (lastWasChange || nextPartIsChange) {
|
|
284
|
-
let linesToShow = raw;
|
|
285
|
-
let skipStart = 0;
|
|
286
|
-
let skipEnd = 0;
|
|
254
|
+
let oldLineNum = hunk.oldStart;
|
|
255
|
+
let newLineNum = hunk.newStart;
|
|
287
256
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
linesToShow = raw.slice(skipStart);
|
|
291
|
-
}
|
|
292
|
-
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
293
|
-
skipEnd = linesToShow.length - contextLines;
|
|
294
|
-
linesToShow = linesToShow.slice(0, contextLines);
|
|
295
|
-
}
|
|
257
|
+
for (const line of hunk.lines) {
|
|
258
|
+
if (line === "\") continue;
|
|
296
259
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
);
|
|
260
|
+
const prefix = line[0] as " " | "+" | "-";
|
|
261
|
+
const text = line.slice(1);
|
|
262
|
+
|
|
263
|
+
if (prefix === "-") {
|
|
264
|
+
const padded = String(oldLineNum).padStart(lineNumWidth, " ");
|
|
265
|
+
output.push(`-${padded}${hashPad}:${text}`);
|
|
266
|
+
oldLineNum++;
|
|
267
|
+
} else if (prefix === "+") {
|
|
268
|
+
const padded = String(newLineNum).padStart(lineNumWidth, " ");
|
|
269
|
+
const hash = computeLineHash(newLineNum, text);
|
|
270
|
+
output.push(`+${padded}#${hash}:${text}`);
|
|
271
|
+
newLineNum++;
|
|
272
|
+
} else {
|
|
273
|
+
const padded = String(newLineNum).padStart(lineNumWidth, " ");
|
|
274
|
+
const hash = computeLineHash(newLineNum, text);
|
|
275
|
+
output.push(` ${padded}#${hash}:${text}`);
|
|
306
276
|
oldLineNum++;
|
|
307
277
|
newLineNum++;
|
|
308
278
|
}
|
|
309
|
-
if (skipEnd > 0) {
|
|
310
|
-
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
|
311
|
-
oldLineNum += skipEnd;
|
|
312
|
-
newLineNum += skipEnd;
|
|
313
|
-
}
|
|
314
|
-
} else {
|
|
315
|
-
oldLineNum += raw.length;
|
|
316
|
-
newLineNum += raw.length;
|
|
317
279
|
}
|
|
318
|
-
lastWasChange = false;
|
|
319
280
|
}
|
|
320
281
|
|
|
321
|
-
return { diff: output.join("\n")
|
|
282
|
+
return { diff: output.join("\n") };
|
|
322
283
|
}
|
|
323
284
|
|
|
324
285
|
export interface CompactHashlineDiffPreview {
|
package/src/edit-response.ts
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Edit response builders.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* No behaviour change: outputs are byte-identical to the previous inline
|
|
8
|
-
* implementation. The only additive surface is `details.metrics` (Phase 2 C
|
|
9
|
-
* — observability for hosts; the LLM-visible text is unchanged).
|
|
4
|
+
* Unified diff output: agent and user see the same content. The diff is
|
|
5
|
+
* generated from structuredPatch hunks with hashline-formatted lines.
|
|
10
6
|
*/
|
|
11
7
|
|
|
12
8
|
import { generateDiffString } from "./edit-diff";
|
|
13
|
-
import {
|
|
14
|
-
computeAffectedLineRange,
|
|
15
|
-
formatHashlineRegion,
|
|
16
|
-
} from "./hashline";
|
|
17
|
-
|
|
18
|
-
const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
|
|
19
9
|
|
|
20
10
|
// ─── Public types ───────────────────────────────────────────────────────
|
|
21
11
|
|
|
@@ -24,7 +14,6 @@ export type EditMetrics = {
|
|
|
24
14
|
edits_noop: number;
|
|
25
15
|
warnings: number;
|
|
26
16
|
classification: "applied" | "noop";
|
|
27
|
-
changed_lines?: { first: number; last: number };
|
|
28
17
|
added_lines?: number;
|
|
29
18
|
removed_lines?: number;
|
|
30
19
|
};
|
|
@@ -51,8 +40,6 @@ export interface SuccessResponseInput {
|
|
|
51
40
|
originalNormalized: string;
|
|
52
41
|
result: string;
|
|
53
42
|
warnings: string[] | undefined;
|
|
54
|
-
firstChangedLine: number | undefined;
|
|
55
|
-
lastChangedLine: number | undefined;
|
|
56
43
|
snapshotId: string;
|
|
57
44
|
editsAttempted: number;
|
|
58
45
|
noopEditsCount: number;
|
|
@@ -60,12 +47,6 @@ export interface SuccessResponseInput {
|
|
|
60
47
|
|
|
61
48
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
62
49
|
|
|
63
|
-
function getVisibleLines(text: string): string[] {
|
|
64
|
-
if (text.length === 0) return [];
|
|
65
|
-
const lines = text.split("\n");
|
|
66
|
-
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
50
|
function countDiffLines(diff: string, marker: "+" | "-"): number {
|
|
70
51
|
if (!diff) return 0;
|
|
71
52
|
let count = 0;
|
|
@@ -82,8 +63,6 @@ function buildMetrics(args: {
|
|
|
82
63
|
editsAttempted: number;
|
|
83
64
|
noopEditsCount: number;
|
|
84
65
|
warningsCount: number;
|
|
85
|
-
firstChangedLine?: number;
|
|
86
|
-
lastChangedLine?: number;
|
|
87
66
|
addedLines?: number;
|
|
88
67
|
removedLines?: number;
|
|
89
68
|
}): EditMetrics {
|
|
@@ -93,16 +72,6 @@ function buildMetrics(args: {
|
|
|
93
72
|
warnings: args.warningsCount,
|
|
94
73
|
classification: args.classification,
|
|
95
74
|
};
|
|
96
|
-
if (
|
|
97
|
-
args.classification === "applied" &&
|
|
98
|
-
args.firstChangedLine !== undefined &&
|
|
99
|
-
args.lastChangedLine !== undefined
|
|
100
|
-
) {
|
|
101
|
-
metrics.changed_lines = {
|
|
102
|
-
first: args.firstChangedLine,
|
|
103
|
-
last: args.lastChangedLine,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
75
|
if (args.addedLines !== undefined) metrics.added_lines = args.addedLines;
|
|
107
76
|
if (args.removedLines !== undefined) metrics.removed_lines = args.removedLines;
|
|
108
77
|
return metrics;
|
|
@@ -145,7 +114,6 @@ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
|
145
114
|
content: [{ type: "text", text }],
|
|
146
115
|
details: {
|
|
147
116
|
diff: "",
|
|
148
|
-
firstChangedLine: undefined,
|
|
149
117
|
snapshotId,
|
|
150
118
|
classification: "noop" as const,
|
|
151
119
|
metrics,
|
|
@@ -154,42 +122,15 @@ export function buildNoopResponse(input: NoopResponseInput): ToolResult {
|
|
|
154
122
|
}
|
|
155
123
|
|
|
156
124
|
export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
157
|
-
const {
|
|
158
|
-
|
|
159
|
-
warnings,
|
|
160
|
-
firstChangedLine,
|
|
161
|
-
lastChangedLine,
|
|
162
|
-
snapshotId,
|
|
163
|
-
originalNormalized,
|
|
164
|
-
editsAttempted,
|
|
165
|
-
noopEditsCount,
|
|
166
|
-
} = input;
|
|
125
|
+
const { result, warnings, snapshotId, originalNormalized, editsAttempted, noopEditsCount } =
|
|
126
|
+
input;
|
|
167
127
|
|
|
168
128
|
const diffResult = generateDiffString(originalNormalized, result);
|
|
169
129
|
const addedLines = countDiffLines(diffResult.diff, "+");
|
|
170
130
|
const removedLines = countDiffLines(diffResult.diff, "-");
|
|
171
131
|
const warningsBlock = warningsBlockOf(warnings);
|
|
172
132
|
|
|
173
|
-
const
|
|
174
|
-
const anchorRange = computeAffectedLineRange({
|
|
175
|
-
firstChangedLine,
|
|
176
|
-
lastChangedLine,
|
|
177
|
-
resultLineCount: resultLines.length,
|
|
178
|
-
});
|
|
179
|
-
const anchorsBlock = anchorRange
|
|
180
|
-
? (() => {
|
|
181
|
-
const region = resultLines.slice(anchorRange.start - 1, anchorRange.end);
|
|
182
|
-
const formatted = formatHashlineRegion(region, anchorRange.start);
|
|
183
|
-
const block = `--- Anchors ${anchorRange.start}-${anchorRange.end} ---\n${formatted}`;
|
|
184
|
-
return Buffer.byteLength(block, "utf8") <= CHANGED_ANCHOR_TEXT_BUDGET_BYTES
|
|
185
|
-
? block
|
|
186
|
-
: "Anchors omitted; use read for subsequent edits.";
|
|
187
|
-
})()
|
|
188
|
-
: resultLines.length === 0
|
|
189
|
-
? "File is empty. Use edit with prepend or append and omit pos to insert content."
|
|
190
|
-
: "Anchors omitted; use read for subsequent edits.";
|
|
191
|
-
|
|
192
|
-
const text = [anchorsBlock, warningsBlock.trimStart()]
|
|
133
|
+
const text = [diffResult.diff, warningsBlock.trimStart()]
|
|
193
134
|
.filter((section) => section.length > 0)
|
|
194
135
|
.join("\n\n");
|
|
195
136
|
|
|
@@ -198,8 +139,6 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
198
139
|
editsAttempted,
|
|
199
140
|
noopEditsCount,
|
|
200
141
|
warningsCount: warnings?.length ?? 0,
|
|
201
|
-
firstChangedLine,
|
|
202
|
-
lastChangedLine,
|
|
203
142
|
addedLines,
|
|
204
143
|
removedLines,
|
|
205
144
|
});
|
|
@@ -208,7 +147,6 @@ export function buildChangedResponse(input: SuccessResponseInput): ToolResult {
|
|
|
208
147
|
content: [{ type: "text", text }],
|
|
209
148
|
details: {
|
|
210
149
|
diff: diffResult.diff,
|
|
211
|
-
firstChangedLine: firstChangedLine ?? diffResult.firstChangedLine,
|
|
212
150
|
snapshotId,
|
|
213
151
|
metrics,
|
|
214
152
|
},
|