pi-hashline-edit-pro 0.2.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/LICENSE +21 -0
- package/README.md +143 -0
- package/index.ts +64 -0
- package/package.json +52 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +58 -0
- package/prompts/read-guidelines.md +3 -0
- package/prompts/read-snippet.md +1 -0
- package/prompts/read.md +28 -0
- package/src/edit-diff.ts +234 -0
- package/src/edit-normalize.ts +68 -0
- package/src/edit-render.ts +280 -0
- package/src/edit-response.ts +531 -0
- package/src/edit.ts +689 -0
- package/src/file-kind.ts +161 -0
- package/src/fs-write.ts +105 -0
- package/src/hashline/apply.ts +660 -0
- package/src/hashline/hash.ts +192 -0
- package/src/hashline/index.ts +70 -0
- package/src/hashline/parse.ts +116 -0
- package/src/hashline/resolve.ts +552 -0
- package/src/path-utils.ts +13 -0
- package/src/read.ts +256 -0
- package/src/runtime.ts +3 -0
- package/src/snapshot.ts +29 -0
- package/src/utils.ts +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RimuruW
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# pi-hashline-edit-pro
|
|
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** — 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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
9
|
+
## Why fork?
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
This fork makes **two** changes that compound:
|
|
14
|
+
|
|
15
|
+
1. **Bump hash length to 4 characters** of the 64-char URL-safe base64 alphabet → 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. Symbol-only lines use `"S{lineNumber}"` as the discriminator. 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.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# From npm (once published)
|
|
22
|
+
pi install npm:pi-hashline-edit-pro
|
|
23
|
+
|
|
24
|
+
# From a local checkout
|
|
25
|
+
pi install /path/to/pi-hashline-edit-pro
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## How It Works
|
|
29
|
+
|
|
30
|
+
### `read` — tagged line output
|
|
31
|
+
|
|
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 line content. Example output for the source below; the hashes are the real xxHash-derived values for the file content shown:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
function hello() {
|
|
36
|
+
console.log("world");
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
would be returned as:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
0qH3:function hello() {
|
|
44
|
+
szJr: console.log("world");
|
|
45
|
+
_zlP:}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `HASH` — 4-character content hash from the URL-safe base64 alphabet `A-Za-z0-9-_`.
|
|
49
|
+
|
|
50
|
+
Optional parameters:
|
|
51
|
+
|
|
52
|
+
- `offset` — start reading from this line number (1-indexed).
|
|
53
|
+
- `limit` — maximum number of lines to return.
|
|
54
|
+
|
|
55
|
+
Images (JPEG, PNG, GIF, WebP) are passed through as attachments and do not participate in the hashline protocol. Binary and directory paths are rejected with a descriptive error. Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
|
|
56
|
+
|
|
57
|
+
### `edit` — hash-anchored modifications
|
|
58
|
+
|
|
59
|
+
Edits use the `HASH:content` anchors from `read` output to target lines precisely:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"path": "src/main.ts",
|
|
64
|
+
"edits": [
|
|
65
|
+
{ "op": "replace", "start": "ve7o", "end": "ve7o", "lines": [" console.log('hashline');"] }
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Op | Purpose | Fields |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `replace` | Replace the inclusive range `start`..`end`. To replace a single line, set `start` = `end`. | `start` required, `end` required, `lines` |
|
|
73
|
+
| `append` | Insert lines after `pos`. Omit `pos` to append at EOF. | `pos` optional, `lines` |
|
|
74
|
+
| `prepend` | Insert lines before `pos`. Omit `pos` to prepend at BOF. | `pos` optional, `lines` |
|
|
75
|
+
|
|
76
|
+
- **Request structure validation.** The request envelope (path, edits, returnMode, returnRanges) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_OP]`. This catches structural errors early with actionable messages.
|
|
77
|
+
- **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`).
|
|
78
|
+
|
|
79
|
+
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so line numbers stay consistent across operations.
|
|
80
|
+
|
|
81
|
+
### Chained edits
|
|
82
|
+
|
|
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
|
+
|
|
85
|
+
### Auto-read after write
|
|
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 `HASH:content` anchors for the newly written file without requiring a separate `read` call. The workflow becomes:
|
|
88
|
+
|
|
89
|
+
1. `write` a file → result includes hashline anchors
|
|
90
|
+
2. `edit` using those anchors directly
|
|
91
|
+
|
|
92
|
+
For large files (>2000 lines), the auto-read output is truncated with a pagination hint. Use `read` with `offset` to see more.
|
|
93
|
+
### Diff for the host
|
|
94
|
+
|
|
95
|
+
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
|
+
## Design Decisions
|
|
98
|
+
|
|
99
|
+
- **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
|
+
- **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 `+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 a 4-char hash followed by `:`) is also rejected with `[E_BARE_HASH_PREFIX]` — issue #24. When the suspect's prefix happens to match a real file-line hash, the error message flags that as strong evidence the model copied a hash from the read output; the model should rephrase the line (quote it, escape the colon, or use a different identifier shape) and retry.
|
|
102
|
+
- **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
|
+
- **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
|
+
- **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
|
+
## Hashing
|
|
107
|
+
|
|
108
|
+
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
|
+
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
|
+
Hashes are **occurrence-aware**: a discriminator prefix is mixed into the xxHash input before the line content. Symbol-only lines (lone `}`, etc.) use `S{lineNumber}` as the discriminator; content lines use `C{occurrence}` where `occurrence` is the running count of that canonical content earlier in the file. This way:
|
|
113
|
+
|
|
114
|
+
- `}` on line 5 and `}` on line 17 hash differently (different `S{...}` prefix).
|
|
115
|
+
- `import { foo } from 'bar';` on line 3 and the same string on line 47 hash differently (different `C{...}` prefix — 1 vs 2).
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
`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.
|
|
120
|
+
|
|
121
|
+
### Trade-off: the bare-prefix detector
|
|
122
|
+
|
|
123
|
+
With a 64-char alphabet, the regex `^\s*[A-Za-z0-9_-]{4}:` matches a LOT of code (any 4-char identifier followed by `:` — `todo:`, `done:`, `note:`, `init:`). The "did the model accidentally paste a hash into its content?" detector used to fire on a count-based heuristic (too noisy at 64 chars), then on a "strong signal" gate (the prefix matches a real file-line hash) and only warned, then escalated to a strict rejection. Today the first 5 characters of every `lines` entry are checked; if they look like a 4-char hash followed by `:`, the edit is rejected with `[E_BARE_HASH_PREFIX]`. The false-positive cost (rejecting `init:`, `data:`, etc.) is real but small: the model can rephrase the line (quote it, add a leading space, use a different identifier shape) and retry. The false-negative cost (a stray hash in the file) is silent and catastrophic.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
Requires [Node.js](https://nodejs.org) and npm.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm install
|
|
131
|
+
npm test
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
|
|
135
|
+
|
|
136
|
+
## Credits
|
|
137
|
+
|
|
138
|
+
- [RimuruW](https://github.com/RimuruW) — original `pi-hashline-edit` and the strict-semantics policy
|
|
139
|
+
- [can1357](https://github.com/can1357) — original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE)
|
package/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { join, isAbsolute } from "path";
|
|
4
|
+
import { computeLineHashes, formatHashlineRegion } from "./src/hashline";
|
|
5
|
+
import { registerEditTool } from "./src/edit";
|
|
6
|
+
import { registerReadTool } from "./src/read";
|
|
7
|
+
|
|
8
|
+
export default function (pi: ExtensionAPI): void {
|
|
9
|
+
registerReadTool(pi);
|
|
10
|
+
registerEditTool(pi);
|
|
11
|
+
|
|
12
|
+
// Auto-read after write: append hashline read output to write results
|
|
13
|
+
// so the model immediately has anchors for subsequent edits.
|
|
14
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
15
|
+
if (event.toolName !== "write" || event.isError) return;
|
|
16
|
+
|
|
17
|
+
const filePath = (event.input as Record<string, unknown>)?.path;
|
|
18
|
+
if (typeof filePath !== "string") return;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const absolutePath = isAbsolute(filePath) ? filePath : join(ctx.cwd, filePath);
|
|
22
|
+
const content = await readFile(absolutePath, "utf-8");
|
|
23
|
+
|
|
24
|
+
// Normalize and compute hashline output
|
|
25
|
+
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
26
|
+
const lines = normalized.split("\n");
|
|
27
|
+
const visibleLines = normalized.endsWith("\n") ? lines.slice(0, -1) : lines;
|
|
28
|
+
|
|
29
|
+
if (visibleLines.length === 0) return;
|
|
30
|
+
|
|
31
|
+
// Truncate to a reasonable limit to avoid excessive token usage
|
|
32
|
+
const MAX_LINES = 2000;
|
|
33
|
+
const truncated = visibleLines.length > MAX_LINES;
|
|
34
|
+
const displayLines = truncated ? visibleLines.slice(0, MAX_LINES) : visibleLines;
|
|
35
|
+
|
|
36
|
+
const hashes = computeLineHashes(normalized);
|
|
37
|
+
const selectedHashes = hashes.slice(0, displayLines.length);
|
|
38
|
+
const hashlineOutput = formatHashlineRegion(selectedHashes, displayLines);
|
|
39
|
+
|
|
40
|
+
// Add pagination hint if truncated
|
|
41
|
+
const paginationHint = truncated
|
|
42
|
+
? `\n\n[Showing lines 1-${MAX_LINES} of ${visibleLines.length}. Use offset=${MAX_LINES + 1} to continue.]`
|
|
43
|
+
: "";
|
|
44
|
+
|
|
45
|
+
if (hashlineOutput) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
...(event.content ?? []),
|
|
49
|
+
{ type: "text", text: `\n\n--- Auto-read (hashline anchors) ---\n${hashlineOutput}${paginationHint}` },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Auto-read failure should not affect write result
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
59
|
+
if (debugValue === "1" || debugValue === "true") {
|
|
60
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
61
|
+
ctx.ui.notify("Hashline Edit mode active", "info");
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-hashline-edit-pro",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Strict hashline read/edit tool override for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/YuGiMob/pi-hashline-edit-pro.git"
|
|
8
|
+
},
|
|
9
|
+
"author": "pi-hashline-edit-pro contributors",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"extension",
|
|
15
|
+
"hashline",
|
|
16
|
+
"hash-anchored",
|
|
17
|
+
"strict"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": [
|
|
21
|
+
"index.ts",
|
|
22
|
+
"src",
|
|
23
|
+
"prompts",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"diff": "^8.0.2",
|
|
34
|
+
"file-type": "^21.3.0",
|
|
35
|
+
"xxhashjs": "^0.2.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"@sinclair/typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:watch": "vitest"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"@types/xxhashjs": "^0.2.4",
|
|
50
|
+
"vitest": "^4.1.8"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Edit a text file via bare HASH anchors from read
|
package/prompts/edit.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Patch a text file using `HASH` anchors copied verbatim from `read`.
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
Hashes are 4 characters (e.g. `aB3x`), alphabet `A-Za-z0-9-_`. The wire format for `start`/`end`/`pos` is the bare hash only — no line number, no trailing content, no `HASH:content` form.
|
|
6
|
+
|
|
7
|
+
Ops:
|
|
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`.
|
|
9
|
+
- `append` — insert `lines` after `pos`; omit `pos` to append at EOF.
|
|
10
|
+
- `prepend` — insert `lines` before `pos`; omit `pos` to prepend at BOF. Use `prepend` at an anchor to insert a new block between line N-1 and N (anchor on the line *after* the insertion point).
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
|
|
14
|
+
1. Single line replace:
|
|
15
|
+
```json
|
|
16
|
+
{ "path": "src/main.ts", "edits": [
|
|
17
|
+
{ "op": "replace", "start": "MQXV", "end": "MQXV", "lines": ["const x = 1;"] }
|
|
18
|
+
] }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. Range replace (3 lines → 3 new lines):
|
|
22
|
+
```json
|
|
23
|
+
{ "path": "src/main.ts", "edits": [
|
|
24
|
+
{ "op": "replace", "start": "ZPMQ", "end": "VRWS", "lines": [
|
|
25
|
+
"function greet(name) {",
|
|
26
|
+
" return `Hello, ${name}`;",
|
|
27
|
+
"}"
|
|
28
|
+
] }
|
|
29
|
+
] }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. Multiple regions in one call (delete two non-adjacent ranges, insert before a third anchor):
|
|
33
|
+
```json
|
|
34
|
+
{ "path": "src/server.ts", "edits": [
|
|
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
|
+
] }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
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 hash.
|
|
43
|
+
- `start`, `end`, `pos` are bare 4-character HASH strings only. 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). The first 5 characters of every `lines` entry are checked; if they look like a 4-char hash followed by `:` (after any leading whitespace), 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
|
+
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
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
|
+
- If `lines` matches the current content byte-for-byte, the edit is classified as `Classification: noop` (file unchanged, not an error).
|
|
48
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
55
|
+
Auto-read after write:
|
|
56
|
+
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with HASH:content for the written file.
|
|
57
|
+
- Use those anchors directly for `edit` calls without a separate `read`.
|
|
58
|
+
- This enables a seamless write → edit workflow with no extra tool calls.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
- Use read before edit when you do not have current HASH anchors for the file.
|
|
2
|
+
- Copy exactly the 4-character HASH (the part before the `:`); never include the `:` or line content in `pos`/`end`.
|
|
3
|
+
- A HASH may start with `-`; that is a normal alphabet character, not a diff-remove marker.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Read a text file with HASH:content anchors for edit (copy the HASH portion into `start`/`end`/`pos`)
|
package/prompts/read.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Read a text file. Each line is returned as `HASH:content`. The HASH is the 4 characters before the first `:`; the content after is the line verbatim. Pass the 4-character HASH into `edit`'s `start`/`end` (for `replace`) or `pos` (for `append`/`prepend`) — never the rendered `HASH:content` form.
|
|
2
|
+
|
|
3
|
+
HASH shape:
|
|
4
|
+
- 4 characters (e.g. `aB3x`), from the URL-safe base64 alphabet `A-Za-z0-9-_`. A HASH can start with any of these characters, including `-`. A leading `-` is a normal alphabet char, not a diff-remove marker.
|
|
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
|
+
|
|
7
|
+
HASH → edit:
|
|
8
|
+
- Copy exactly the 4 characters before the `:`. Use that bare 4-character 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 bare 4-character HASH only.
|
|
10
|
+
|
|
11
|
+
Pagination:
|
|
12
|
+
- Large files return a truncated preview with a `nextOffset` line. Call `read` again with `offset=nextOffset` to continue.
|
|
13
|
+
- For nearby follow-up edits, prefer the `--- Anchors ---` block from a previous `edit` call — fresh HASHes, cheaper than re-reading.
|
|
14
|
+
- Empty files return an advisory suggesting `prepend`/`append` instead of a synthetic anchor.
|
|
15
|
+
|
|
16
|
+
Error recovery:
|
|
17
|
+
- `[E_STALE_ANCHOR]` — the file changed since your last read. The error includes fresh `>>> HASH:content` lines; copy the HASH portion (4 chars before `:`) and retry.
|
|
18
|
+
- `[E_BAD_REF]` — malformed HASH. Re-read and try again with a valid 4-character HASH.
|
|
19
|
+
|
|
20
|
+
File kinds:
|
|
21
|
+
- Text files are returned as `HASH:content` lines.
|
|
22
|
+
- Images (JPEG, PNG, GIF, WebP) are returned as visual attachments; the HASH-line protocol does not apply.
|
|
23
|
+
- Binary files and directories are rejected with a descriptive error.
|
|
24
|
+
|
|
25
|
+
Auto-read after write:
|
|
26
|
+
- After a successful `write`, the result includes a `--- Auto-read (hashline anchors) ---` block with HASH:content for the written file.
|
|
27
|
+
- Use those anchors directly for `edit` calls without a separate `read`.
|
|
28
|
+
- The auto-read output follows the same format and rules as `read` output.
|
package/src/edit-diff.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import * as Diff from "diff";
|
|
2
|
+
import {
|
|
3
|
+
computeLineHashes,
|
|
4
|
+
HASH_LENGTH,
|
|
5
|
+
} from "./hashline";
|
|
6
|
+
|
|
7
|
+
// ─── Line ending normalization ──────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function detectLineEnding(content: string): "\r\n" | "\n" {
|
|
10
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
11
|
+
const lfIdx = content.indexOf("\n");
|
|
12
|
+
if (lfIdx === -1 || crlfIdx === -1) return "\n";
|
|
13
|
+
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeToLF(text: string): string {
|
|
17
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function restoreLineEndings(
|
|
21
|
+
text: string,
|
|
22
|
+
ending: "\r\n" | "\n",
|
|
23
|
+
): string {
|
|
24
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function stripBom(content: string): { bom: string; text: string } {
|
|
28
|
+
return content.startsWith("\uFEFF")
|
|
29
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
30
|
+
: { bom: "", text: content };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Diff generation ────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function formatDiffPreviewLine(
|
|
36
|
+
prefix: " " | "+" | "-",
|
|
37
|
+
line: string,
|
|
38
|
+
hash: string | undefined,
|
|
39
|
+
): string {
|
|
40
|
+
if (hash === undefined) {
|
|
41
|
+
// Removed lines have no hash, but they still need column alignment with
|
|
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(HASH_LENGTH)}:${line}`;
|
|
45
|
+
}
|
|
46
|
+
return `${prefix}${hash}:${line}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function generateDiffString(
|
|
50
|
+
oldContent: string,
|
|
51
|
+
newContent: string,
|
|
52
|
+
contextLines = 4,
|
|
53
|
+
newContentHashes?: string[],
|
|
54
|
+
): { diff: string; firstChangedLine: number | undefined } {
|
|
55
|
+
const parts = Diff.diffLines(oldContent, newContent);
|
|
56
|
+
const output: string[] = [];
|
|
57
|
+
const effectiveNewHashes = newContentHashes ?? computeLineHashes(newContent);
|
|
58
|
+
|
|
59
|
+
let oldLineNum = 1;
|
|
60
|
+
let newLineNum = 1;
|
|
61
|
+
let lastWasChange = false;
|
|
62
|
+
let firstChangedLine: number | undefined;
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < parts.length; i++) {
|
|
65
|
+
const part = parts[i]!;
|
|
66
|
+
const raw = part.value.split("\n");
|
|
67
|
+
if (raw[raw.length - 1] === "") raw.pop();
|
|
68
|
+
|
|
69
|
+
if (part.added || part.removed) {
|
|
70
|
+
if (firstChangedLine === undefined) firstChangedLine = newLineNum;
|
|
71
|
+
for (const line of raw) {
|
|
72
|
+
if (part.added) {
|
|
73
|
+
const hash = effectiveNewHashes[newLineNum - 1];
|
|
74
|
+
output.push(formatDiffPreviewLine("+", line, hash));
|
|
75
|
+
newLineNum++;
|
|
76
|
+
} else {
|
|
77
|
+
output.push(
|
|
78
|
+
formatDiffPreviewLine("-", line, undefined),
|
|
79
|
+
);
|
|
80
|
+
oldLineNum++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lastWasChange = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nextPartIsChange =
|
|
88
|
+
i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed);
|
|
89
|
+
if (lastWasChange || nextPartIsChange) {
|
|
90
|
+
let linesToShow = raw;
|
|
91
|
+
let skipStart = 0;
|
|
92
|
+
let skipEnd = 0;
|
|
93
|
+
|
|
94
|
+
if (!lastWasChange) {
|
|
95
|
+
skipStart = Math.max(0, raw.length - contextLines);
|
|
96
|
+
linesToShow = raw.slice(skipStart);
|
|
97
|
+
}
|
|
98
|
+
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
99
|
+
skipEnd = linesToShow.length - contextLines;
|
|
100
|
+
linesToShow = linesToShow.slice(0, contextLines);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (skipStart > 0) {
|
|
104
|
+
output.push(` ...`);
|
|
105
|
+
oldLineNum += skipStart;
|
|
106
|
+
newLineNum += skipStart;
|
|
107
|
+
}
|
|
108
|
+
for (const line of linesToShow) {
|
|
109
|
+
const hash = effectiveNewHashes[newLineNum - 1];
|
|
110
|
+
output.push(formatDiffPreviewLine(" ", line, hash));
|
|
111
|
+
|
|
112
|
+
oldLineNum++;
|
|
113
|
+
newLineNum++;
|
|
114
|
+
}
|
|
115
|
+
if (skipEnd > 0) {
|
|
116
|
+
output.push(` ...`);
|
|
117
|
+
oldLineNum += skipEnd;
|
|
118
|
+
newLineNum += skipEnd;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
oldLineNum += raw.length;
|
|
122
|
+
newLineNum += raw.length;
|
|
123
|
+
}
|
|
124
|
+
lastWasChange = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { diff: output.join("\n"), firstChangedLine };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface CompactHashlineDiffPreview {
|
|
131
|
+
preview: string;
|
|
132
|
+
addedLines: number;
|
|
133
|
+
removedLines: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type DiffPreviewKind = "context" | "addition" | "deletion";
|
|
137
|
+
|
|
138
|
+
function classifyDiffPreviewLine(line: string): DiffPreviewKind | null {
|
|
139
|
+
if (line.startsWith("+")) return "addition";
|
|
140
|
+
if (line.startsWith("-")) return "deletion";
|
|
141
|
+
if (line.startsWith(" ")) return "context";
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function summarizeOmitted(count: number, label: string): string {
|
|
146
|
+
return `... ${count} more ${label} line${count === 1 ? "" : "s"}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collapseDiffPreviewRun(
|
|
150
|
+
lines: string[],
|
|
151
|
+
maxVisible: number,
|
|
152
|
+
label: string,
|
|
153
|
+
): string[] {
|
|
154
|
+
if (lines.length <= maxVisible) {
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
...lines.slice(0, maxVisible),
|
|
160
|
+
summarizeOmitted(lines.length - maxVisible, label),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildCompactHashlineDiffPreview(
|
|
165
|
+
diff: string,
|
|
166
|
+
options: {
|
|
167
|
+
maxUnchangedRun?: number;
|
|
168
|
+
maxAdditionRun?: number;
|
|
169
|
+
maxDeletionRun?: number;
|
|
170
|
+
maxOutputLines?: number;
|
|
171
|
+
} = {},
|
|
172
|
+
): CompactHashlineDiffPreview {
|
|
173
|
+
const {
|
|
174
|
+
maxUnchangedRun = 2,
|
|
175
|
+
maxAdditionRun = 4,
|
|
176
|
+
maxDeletionRun = 4,
|
|
177
|
+
maxOutputLines = 12,
|
|
178
|
+
} = options;
|
|
179
|
+
|
|
180
|
+
if (!diff.trim()) {
|
|
181
|
+
return { preview: "", addedLines: 0, removedLines: 0 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const lines = diff.split("\n").filter((line) => line.length > 0);
|
|
185
|
+
const previewLines: string[] = [];
|
|
186
|
+
let addedLines = 0;
|
|
187
|
+
let removedLines = 0;
|
|
188
|
+
|
|
189
|
+
for (let index = 0; index < lines.length; ) {
|
|
190
|
+
const kind = classifyDiffPreviewLine(lines[index]!);
|
|
191
|
+
let end = index + 1;
|
|
192
|
+
while (end < lines.length && classifyDiffPreviewLine(lines[end]!) === kind) {
|
|
193
|
+
end += 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const run = lines.slice(index, end);
|
|
197
|
+
switch (kind) {
|
|
198
|
+
case "addition":
|
|
199
|
+
addedLines += run.length;
|
|
200
|
+
previewLines.push(...collapseDiffPreviewRun(run, maxAdditionRun, "added"));
|
|
201
|
+
break;
|
|
202
|
+
case "deletion":
|
|
203
|
+
removedLines += run.length;
|
|
204
|
+
previewLines.push(...collapseDiffPreviewRun(run, maxDeletionRun, "removed"));
|
|
205
|
+
break;
|
|
206
|
+
case "context":
|
|
207
|
+
previewLines.push(...collapseDiffPreviewRun(run, maxUnchangedRun, "unchanged"));
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
previewLines.push(...run);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
index = end;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (previewLines.length > maxOutputLines) {
|
|
218
|
+
const visibleLines = previewLines.slice(0, maxOutputLines);
|
|
219
|
+
visibleLines.push(
|
|
220
|
+
summarizeOmitted(previewLines.length - maxOutputLines, "preview"),
|
|
221
|
+
);
|
|
222
|
+
return {
|
|
223
|
+
preview: visibleLines.join("\n"),
|
|
224
|
+
addedLines,
|
|
225
|
+
removedLines,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
preview: previewLines.join("\n"),
|
|
231
|
+
addedLines,
|
|
232
|
+
removedLines,
|
|
233
|
+
};
|
|
234
|
+
}
|