pi-edit-file 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nikita Bilous
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 CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # pi-edit-file
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Block-based, atomic file editing for [Pi](https://pi.dev) agents — one flat patch string instead of nested schemas, with match diagnostics precise enough that the model does not have to re-read the file to trust the result.
4
+
5
+ ```
6
+ NNN @@@
7
+ old line 1
8
+ old line 2
9
+ @@@
10
+ new line 1
11
+ new line 2
12
+ @@@
13
+ ```
14
+
15
+ ## Why
16
+
17
+ Model-facing edit tooling tends to fail in two ways: the call itself is rejected by an over-complicated schema, or the call "succeeds" into the wrong place. Real numbers from two weeks of Pi sessions (personal setup, 946 edit calls):
18
+
19
+ | Tool | Calls | Failures | Nature |
20
+ |---|---|---|---|
21
+ | `quick_edit` | 371 | 4 (1%) | — |
22
+ | `target_edit` | 575 | 47 (8.2%) | **all schema validation** |
23
+
24
+ Every one of those 47 failures was the model mixing up the two tools' schemas — a seven-variant `anyOf` is not something a model navigates reliably. `pi-edit-file` has exactly one shape to get right:
25
+
26
+ ```ts
27
+ Type.Object({
28
+ path: Type.String(),
29
+ patch: Type.String(), // the whole edit, as text
30
+ })
31
+ ```
32
+
33
+ The second failure mode was worse than a rejection: a silently misplaced edit. So the tool spends most of its code on **telling the model what actually happened**.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pi install git:github.com/acidnik/pi-edit-file
39
+ ```
40
+
41
+ Or try it for one run:
42
+
43
+ ```bash
44
+ pi -e git:github.com/acidnik/pi-edit-file
45
+ ```
46
+
47
+ Loaded from a package, `edit_file` withdraws pi's built-in `edit` tool (same name re-registered with `exposure: "hidden"`), so the model sees a single edit path instead of picking between `edit` and `edit_file` mid-session. Plugin-provided `quick_edit` / `target_edit` are left active as a fallback. If you previously kept it as a plain extension file (`~/.pi/agent/extensions/edit-file.ts`), remove that file first — otherwise the tool gets registered twice.
48
+
49
+ ## Patch format
50
+
51
+ - `NNN` — 1-based line number where the old block starts. It is an **anchor hint, not a requirement**: the old block itself must be unique (see Matching), and `NNN` only says where you expect it. It may be omitted entirely — start the hunk with a bare `@@@` (an insert still needs `NNN`, since there is no block to match).
52
+ - `@@@` — delimiter: 3+ repetitions of one character from `@ # % $ ~ ^ = +`. The character is fixed by the first delimiter and must stay the same for the whole call; escalate to a longer run (`####`) when the file content contains a line like `@@@`.
53
+ - A patch that opens with content and holds exactly one delimiter line is read as **one replace hunk** — the leading header was left out, and one delimiter cannot express a chain. Two or more delimiters without headers are still rejected, and the reply rebuilds the blocks with real headers.
54
+ - Empty old block → **insert**: `NNN @@@` inserts **before** line `NNN`, `NNN+ @@@` inserts **after** it (append when `NNN` is past the end). Empty new block → **delete**. Otherwise → **replace**. A hunk whose old block equals its new block is a no-op: in a batch it is reported as `SKIPPED`, and a patch made only of no-ops is **rejected** — nothing is written.
55
+ - The patch is a JSON string: one patch line is one file line. You never type `\n` yourself — write real line breaks. Backticks, `${...}` and quotes need no escaping.
56
+ - A line that *looks* like a hunk header (`NNN` followed by the delimiter — e.g. a line of documentation about this format) starts the next hunk, so it cannot be block content: keep such lines out of a patch, or write them with a leading space and clean up in a second call.
57
+ - Lines beginning with `-` or `+` are **ordinary content** — markdown bullets, list continuations, a diff quoted inside a document. They never make a patch invalid; the unified-diff hint only appears in a diagnosis, after such a block failed to match the file (2026-10-07: a bullet plus its `+18…` continuation was rejected as a diff and the whole patch was thrown away).
58
+
59
+ Several hunks per call, applied atomically:
60
+
61
+ ```
62
+ 42 @@@
63
+ const timeout = 1000
64
+ @@@
65
+ const timeout = 5000
66
+ @@@
67
+ 120+ @@@
68
+ @@@
69
+ export const maxRetries = 3
70
+ @@@
71
+ ```
72
+
73
+ ## Matching
74
+
75
+ Per hunk, against the **original** file content:
76
+
77
+ 1. **exact** block match → 2. lines compared **trimmed** → 3. internal whitespace **collapsed**.
78
+ 2. Every tier scans the **whole file** — a copy far outside the hint window still counts.
79
+ 3. The block must match **exactly once**. Several matches → the batch is **rejected** with the candidate lines, whether or not `NNN` points at one of them; proximity never chooses a copy, so extend the block with surrounding lines instead.
80
+ 4. `NNN` only says where the block is expected: the distance to the matched range (0 when the hint falls inside the block) is reported as `hint N off by K`. Nothing else depends on it — a unique block far from its hint is applied.
81
+ 5. No hint given → the same uniqueness rule, reported as `unique match (no hint given)`.
82
+
83
+ Indentation of the file is preserved for matched lines; `CRLF`/`LF` and the presence or absence of a final newline are preserved as well.
84
+
85
+ ## What the model gets back
86
+
87
+ The first line is the verdict, and it mirrors the rejection line exactly, so "applied and unique" is never confused with "not found / not unique":
88
+
89
+ ```
90
+ applied — 2 of 2 hunks, file written
91
+ note: hunk 1's hint 20 was off by 49 — the block was found by content and is unique, so the edit was applied at src 69-72.
92
+ hunk 1: replace src 69-72 → out 69-73 (4 → 5 lines), exact match, hint 20 off by 49
93
+ hunk 2: insert src line 100 → out line 101 (0 → 3 lines), trim match, unique match (no hint given)
94
+ src = original file, out = resulting file
95
+ file: src/widget.ts — now 205 lines (was 193)
96
+ ```
97
+
98
+ A `note:` line sits above the hunk reports when something about the match needs reading before the numbers: a hint that was off (hint and matched `src` lines are both named, and the note says the block was found by content and is unique), and, in a multi-hunk call, the reminder that every hunk was matched against the original numbering — the `out`-numbers are for a follow-up call, never for a later hunk of the same one. The last line may also name a shorter form (`NNN+ @@@`) when an insert was written as a replace.
99
+
100
+ `[indentation: file indents with tabs, the patch's new lines use spaces]` marks a silent indent-style change (reported, never rewritten). A skipped no-op hunk appears as `SKIPPED — no-op (the old block equals the new block)`; when every hunk is a no-op the call **fails** instead of reporting success.
101
+
102
+ ### A rejected batch says so, and accounts for every hunk
103
+
104
+ All hunks are resolved against the original before anything is written, so a failure writes nothing — and the error states that explicitly, plus the fate of the hunks that *would* have matched:
105
+
106
+ ```
107
+ batch rejected — 0 of 2 hunk(s) applied, nothing was written to the file.
108
+ hunk 1: REJECTED — before-block not found (exact, trim and whitespace-collapse matching all failed)
109
+ Diagnosis: 2 of 2 before-block line(s) do not exist in the file at all:
110
+ "const nonexistent_block = 42;"
111
+ "const also_missing = true;"
112
+ hunk 2: would have matched (replace src lines 4-4, exact) — NOT applied.
113
+ ```
114
+
115
+ Without that line, a model reads the failure as "only hunk 1 failed" and walks away believing its second edit landed.
116
+
117
+ ### Failures name the defect
118
+
119
+ Not-found is the most expensive failure because it is the one models retry blindly. Each case below actually happened, and each now produces its own diagnosis:
120
+
121
+ | Situation | What the error says |
122
+ |---|---|
123
+ | Line was deleted earlier in the session | `1 of 1 before-block line(s) do not exist in the file at all: "…"` |
124
+ | Block lines exist, wrong order | `every line exists, but NOT in the order written` + actual position of each line |
125
+ | Lines exist, not contiguous | `not contiguous — found at lines 41, 57 (expected consecutive lines)` |
126
+ | New content written into the old block | `The first 9 line(s) match the file and the trailing 6 do not exist. If those trailing lines are the NEW content …` |
127
+ | Unified-diff habit (`-old` / `+new`) | the block is diagnosed **after** it fails to match: *the block mixes N "-" marked line(s) with M "+" marked line(s) — that is a unified diff, not file content* + the shape of a replace patch |
128
+ | Typo in one character (`1000` → `1001`) | `closest line 2 (95% similar): "const timeout = 1000;"` |
129
+ | Hand-written `\n` inside a line | `Escaping note: a literal "\n" in a before-line is a backslash followed by "n", not a line break …` |
130
+ | Block occurs more than once in the file | `not unique — the before-block matches at lines 12, 45 (exact match, 2 copies)` + *include more surrounding lines … a line number cannot choose between identical blocks* |
131
+ | Every hunk is a no-op (`before == after`) | `nothing applied — every hunk of this patch is a no-op …, so the file was NOT written` |
132
+ | No hunk header at all (chain form), a header where the closing delimiter belongs, or an unterminated hunk | the blocks are located in the file and re-emitted as a ready-to-paste numbered skeleton (`NOT UNIQUE` / `NOT FOUND` placeholders when a block cannot be pinned), with the note that a single unique block may start with a bare delimiter line instead of a number |
133
+
134
+ Grammar failures get the same treatment as not-found ones: the patch is never applied, and the reply rebuilds the model's own blocks into legal form instead of stopping at a parse error. Not-found failures also include the nearest candidate region with per-line `=` / `≠` markers, and a **ready-to-paste corrected hunk** built from the real file content:
135
+
136
+ ```
137
+ Closest candidate: lines 2-4 — 2 of 3 line(s) match.
138
+ Actual file content there:
139
+ 2 | = function f() {
140
+ 3 | ≠ return 42;
141
+ 4 | = }
142
+
143
+ Suggested corrected hunk for hunk 1:
144
+ 2 @@@
145
+ function f() {
146
+ return 42;
147
+ }
148
+ @@@
149
+ function f() {
150
+ return 7;
151
+ }
152
+ @@@
153
+ ```
154
+
155
+ ## Rendering
156
+
157
+ The UI draws the change as a unified diff with word-level highlighting (`details.diff`). The transcript card shows that diff plus a compact `+adds / -removals · N hunks` line — the per-hunk report, the anchor caveats and the shorter-form tip are written for the model and are **not** drawn next to the diff (Nik, 2026-10-01: "только сам дифф"). The model-facing content stays a summary, because models that see raw diffs in tool output start imitating the diff format in their own patches (this happened, and is why the escaping and diff-style diagnostics above exist).
158
+ A failed call draws its **error text** instead: a patch rejection carries the whole diagnosis there, and rendering the empty result showed "+0 / -0 · 0 hunks", hiding the reason (2026-10-07).
159
+
160
+
161
+ The same renderer is attached to a `write` override, so overwriting an existing file shows a diff of the old content instead of a bare "Successfully wrote to …".
162
+
163
+ ## Tests
164
+
165
+ ```bash
166
+ npm test
167
+ ```
168
+
169
+ 99 tests over the pure core (parser, matching ladder, atomicity, diagnostics, report caveats, diff generation, CRLF handling). The core has no Pi imports, so it runs on plain Node ≥ 22.6 with the built-in type stripping.
170
+
171
+ ## License
172
+
173
+ MIT
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "pi-edit-file",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Block-based atomic file editing for Pi agents: one flat patch string, precise match diagnostics, word-level diff rendering",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "edit",
9
+ "patch",
10
+ "diff",
11
+ "atomic-edit",
12
+ "block-edit",
13
+ "coding-agent"
14
+ ],
15
+ "type": "module",
16
+ "main": "./src/extension.ts",
17
+ "files": [
18
+ "src/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --experimental-strip-types --test test/core.test.ts"
24
+ },
25
+ "pi": {
26
+ "extensions": [
27
+ "./src/extension.ts"
28
+ ]
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*",
33
+ "typebox": "*"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.6.0"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/acidnik/pi-edit-file.git"
41
+ },
42
+ "author": "acidnik",
43
+ "license": "MIT"
44
+ }