pi-hashline-edit-pro 2.7.2 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/index.ts +9 -6
- package/package.json +10 -10
- package/prompts/grep-guidelines.md +3 -7
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/prompts/insert-guidelines.md +3 -7
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -1
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -9
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +1 -1
- package/prompts/undo-last-change-guidelines.md +2 -4
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/commit.ts +2 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +14 -8
- package/src/file-reader.ts +34 -13
- package/src/fs-write.ts +60 -3
- package/src/grep.ts +119 -32
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +62 -0
- package/src/hash-store.ts +68 -133
- package/src/hashline/hash.ts +11 -9
- package/src/hashline/parse.ts +15 -1
- package/src/hashline/resolve.ts +3 -2
- package/src/insert.ts +11 -32
- package/src/normalize.ts +27 -0
- package/src/payload-contract.ts +102 -0
- package/src/read.ts +15 -1
- package/src/replace-diff.ts +28 -45
- package/src/replace-render.ts +18 -40
- package/src/replace-response.ts +1 -0
- package/src/replace-undo.ts +28 -13
- package/src/replace.ts +49 -128
- package/src/served.ts +26 -5
- package/src/utils.ts +58 -0
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +59 -0
- package/src/replace-normalize.ts +0 -13
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ Notes:
|
|
|
139
139
|
| --- | --- |
|
|
140
140
|
| `pattern` | Search pattern (regex, or literal text when `literal` is true). |
|
|
141
141
|
| `path` | File or directory to search (default: the current working directory). |
|
|
142
|
-
| `glob` | Filter files by glob pattern; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`. |
|
|
142
|
+
| `glob` | Filter files by glob pattern; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`. A leading `/` is ignored, and the pattern may be relative to the search root or to the current directory. |
|
|
143
143
|
| `ignoreCase` | Case-insensitive search (default: false). |
|
|
144
144
|
| `literal` | Treat the pattern as literal text instead of a regex (default: false). |
|
|
145
145
|
| `context` | Lines of context before and after each match; context rows carry anchors too (default: 0). |
|
|
@@ -148,6 +148,7 @@ Notes:
|
|
|
148
148
|
Notes:
|
|
149
149
|
- Results are grouped per file under a `=== path ===` header; every shown row carries the anchor it would have in `read` output.
|
|
150
150
|
- Directory searches skip `node_modules`, `.git`, `.tmp`, and `coverage`. Binary, image, and oversized files are skipped silently.
|
|
151
|
+
- Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
|
|
151
152
|
- Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line). Directory scans stop after 4000 files with a hint; results may be incomplete.
|
|
152
153
|
- `file_path` works as an alias for `path`.
|
|
153
154
|
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
|
|
@@ -206,6 +207,8 @@ Anchors are unique by construction. If a line's base hash collides with an alrea
|
|
|
206
207
|
|
|
207
208
|
Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
|
|
208
209
|
|
|
210
|
+
On POSIX systems, the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so the store should still be treated as sensitive data.
|
|
211
|
+
|
|
209
212
|
The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
|
|
210
213
|
|
|
211
214
|
Two guarantees make this safe even with duplicated content:
|
|
@@ -235,6 +238,9 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
235
238
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
|
|
236
239
|
| `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
|
|
237
240
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
|
|
241
|
+
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
242
|
+
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
243
|
+
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
238
244
|
|
|
239
245
|
## Troubleshooting
|
|
240
246
|
|
package/index.ts
CHANGED
|
@@ -16,10 +16,10 @@ import {
|
|
|
16
16
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
17
17
|
import { recordServedSafe, clearServed } from "./src/served";
|
|
18
18
|
import { clearBoundaryBypass } from "./src/boundary-bypass";
|
|
19
|
+
import { registerWriteHook } from "./src/write-hook";
|
|
19
20
|
import { readNormFile } from "./src/file-reader";
|
|
20
21
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
21
|
-
import {
|
|
22
|
-
import { resolveTarget } from "./src/fs-write";
|
|
22
|
+
import { resolveInCwd } from "./src/fs-write";
|
|
23
23
|
import { valAccess } from "./src/validation";
|
|
24
24
|
|
|
25
25
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -29,6 +29,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
29
29
|
regInsert(pi);
|
|
30
30
|
regGrep(pi);
|
|
31
31
|
regUndo(pi);
|
|
32
|
+
registerWriteHook(pi);
|
|
32
33
|
|
|
33
34
|
let autoRead = true;
|
|
34
35
|
|
|
@@ -67,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
67
68
|
let resolvedPath: string | undefined;
|
|
68
69
|
if (typeof writtenPath === "string") {
|
|
69
70
|
try {
|
|
70
|
-
resolvedPath = await
|
|
71
|
+
resolvedPath = (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
|
|
71
72
|
await clearUndo(resolvedPath);
|
|
72
73
|
clearBoundaryBypass(resolvedPath);
|
|
73
74
|
const store = await loadHashStore();
|
|
@@ -79,7 +80,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
79
80
|
if (!autoRead) return;
|
|
80
81
|
if (typeof writtenPath !== "string") return;
|
|
81
82
|
try {
|
|
82
|
-
resolvedPath ??= await
|
|
83
|
+
resolvedPath ??= (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
|
|
83
84
|
await valAccess(resolvedPath, writtenPath);
|
|
84
85
|
const file = await loadFileKindAndText(resolvedPath, { maxLines: MAX_HASH_LINES, displayPath: writtenPath });
|
|
85
86
|
if (file.kind !== "text") return;
|
|
@@ -124,7 +125,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
124
125
|
if (metrics?.classification === "noop") return;
|
|
125
126
|
|
|
126
127
|
const diff = (event.details as { diff?: string } | undefined)?.diff;
|
|
127
|
-
if (
|
|
128
|
+
if (typeof diff !== "string") return;
|
|
129
|
+
const hasDiff = diff.length > 0;
|
|
128
130
|
|
|
129
131
|
const rendered = (event.content ?? [])
|
|
130
132
|
.filter(
|
|
@@ -134,11 +136,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
134
136
|
.map((entry) => entry.text)
|
|
135
137
|
.join("\n");
|
|
136
138
|
const warnings = extractWarnings(rendered);
|
|
139
|
+
const hint = hasDiff ? (warnings ? `${diff}\n\n${warnings}` : diff) : warnings ? `[post-edit] applied successfully; the diff is empty (whitespace-only change).\n\n${warnings}` : "[post-edit] applied successfully; the diff is empty (whitespace-only change).";
|
|
137
140
|
return {
|
|
138
141
|
content: [
|
|
139
142
|
{
|
|
140
143
|
type: "text",
|
|
141
|
-
text:
|
|
144
|
+
text: hint,
|
|
142
145
|
},
|
|
143
146
|
],
|
|
144
147
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
]
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"diff": "^
|
|
43
|
-
"file-type": "^
|
|
42
|
+
"diff": "^9.0.0",
|
|
43
|
+
"file-type": "^22.0.2",
|
|
44
44
|
"typebox": "^1.3.7",
|
|
45
45
|
"xxhash-wasm": "^1.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
49
49
|
"@earendil-works/pi-tui": "*"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
-
"@types/node": "^24
|
|
67
|
-
"@vitest/coverage-v8": "^4.1.
|
|
68
|
-
"eslint": "^10.
|
|
69
|
-
"typescript": "^5.
|
|
70
|
-
"typescript-eslint": "^8.
|
|
71
|
-
"vitest": "^4.1.
|
|
66
|
+
"@types/node": "^24",
|
|
67
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
68
|
+
"eslint": "^10.9.1",
|
|
69
|
+
"typescript": "^5.9.3",
|
|
70
|
+
"typescript-eslint": "^8.68.0",
|
|
71
|
+
"vitest": "^4.1.11"
|
|
72
72
|
},
|
|
73
73
|
"allowScripts": {
|
|
74
74
|
"@google/genai@1.52.0": true,
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
- `grep`:
|
|
2
|
-
- `grep`:
|
|
3
|
-
- `grep`:
|
|
4
|
-
- `grep`: use `context` to see surrounding lines; context rows carry anchors too.
|
|
5
|
-
- `grep`: use `glob` to filter files; `*` matches across directories, e.g. `*.ts` or `**/*.spec.ts`.
|
|
6
|
-
- `grep`: results are capped at `limit` matches (default 100), 2000 rows, and 50KB; refine the pattern or raise limit to see more.
|
|
7
|
-
- `grep`: a matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides; the row keeps its anchor and is editable with replace (which replaces the whole line). Use read to see the full line.
|
|
1
|
+
- `grep`: every hit and `context` line comes back as `anchor│content` — use that anchor directly for `replace`/`insert` without a new `read`.
|
|
2
|
+
- `grep`: use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
|
|
3
|
+
- `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
|
package/prompts/grep-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search
|
|
1
|
+
Search with `anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files for a pattern.
|
|
1
|
+
Search text files for a pattern. Every hit and each `context` line is returned as `anchor│content` so you can use that anchor directly for `replace`/`insert` without a new `read`. Directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
- `insert`: anchor
|
|
2
|
-
- `insert`:
|
|
3
|
-
- `insert`:
|
|
4
|
-
- `insert`: read the file first, so the anchor line was shown to you. Use a post-edit diff row or grep output for follow-up inserts.
|
|
5
|
-
- `insert`: to seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
6
|
-
- `insert`: lines are applied literally — never deduplicated — so restating a neighbor is safe and has no effect on the result.
|
|
7
|
-
- `insert`: the post-edit diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
|
|
1
|
+
- `insert`: `anchor` is bare `aB3` from `aB3│content` (never the content), direction `after` adds below, `before` adds above. Do not put the anchor line in `lines`; `[""]` is a blank line.
|
|
2
|
+
- `insert`: `lines` is bare content, one element per line, kept literally even if it duplicates neighbors — nothing is removed.
|
|
3
|
+
- `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or `grep`. For an empty file, `read` shows one `anchor│` empty row — insert `after` it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert lines after
|
|
1
|
+
Insert `lines` after/before bare anchor `aB3` from `aB3│content`: anchor stays, lines are bare without `│`, one per element
|
package/prompts/insert.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Insert lines after or before
|
|
1
|
+
Insert lines after or before one existing line in a text file, using a bare anchor like `aB3` from `aB3│content`. The anchor line stays; your `lines` are added after (`"after"`) or before (`"before"`) it, one string per line. Use `[""]` for a blank line, never put `\n` inside an element and never include the anchor line in `lines`. Lines are added literally, even if they duplicate neighbors.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
- `read`: call before `replace` when you need fresh anchors for a file.
|
|
2
|
-
- `read`: call again after an edit when you need anchors you do not have
|
|
2
|
+
- `read`: call again after an edit when you need anchors you do not have — post-edit diff `+anchor│`/` anchor│` rows and `grep` hits are already fresh anchors for the changed range, so no new `read` needed for those lines.
|
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file; each line returned as `anchor│content` with a 3-character alphanumeric anchor. No line numbers: use the anchor in replace and insert calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
1
|
+
Read a text file; each line returned as `anchor│content` with a 3-character alphanumeric anchor (e.g. `aB3│hello` is anchor `aB3` plus content `hello`). No line numbers: use the anchor in replace and insert calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → anchor│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD. If output says truncated, use `offset`/`limit` as hinted.
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
- `replace`: remove_from and remove_to
|
|
2
|
-
- `replace`:
|
|
3
|
-
- `replace`:
|
|
4
|
-
- `replace`:
|
|
5
|
-
- `replace`:
|
|
6
|
-
- `replace`: replacement_lines is an array of strings, one element per line. Mirror the removed lines exactly, blank lines included: use `[]` to delete the range, `[""]` for a single blank line, `["a", ""]` for a line followed by a blank line, and `["", ""]` for two blank lines. Do not embed `\n` inside an element: each element is exactly one line.
|
|
7
|
-
- `replace`: when auto-read shows the post-edit diff, its rows are the fresh anchors for the new file: `+anchor│` and ` anchor│` rows carry current anchors and unchanged lines keep their previous anchors, so you can anchor follow-up edits on the diff without re-reading.
|
|
8
|
-
- `replace`: do not issue multiple replace calls on the same file in one message. Issue the next edit only after verifying the previous diff.
|
|
9
|
-
- `replace`: the post-edit diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
|
|
1
|
+
- `replace`: `remove_from` and `remove_to` are bare anchors like `aB3` (the 3 chars before `│` in `aB3│content`), never the full row or file content. Use the same anchor for both to change one line.
|
|
2
|
+
- `replace`: `replacement_lines` is bare content without `│`, one element per line. Use `[]` to delete, `[""]` for one blank line, never put `\n` inside an element and never include the `anchor│` prefix.
|
|
3
|
+
- `replace`: the range from `remove_from` to `remove_to` is exactly deleted and replaced in order — make it tight, only the lines that actually change, and copy leading spaces exactly.
|
|
4
|
+
- `replace`: diff markers like `+aB3│` or `-aB3│` are stripped automatically if pasted, but always try to send bare content.
|
|
5
|
+
- `replace`: `+anchor│` and ` anchor│` rows in a post-edit diff are fresh anchors — you can use them for the next edit without a new `read`. Do one `replace`/`insert` per turn and check the diff before the next edit.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace lines
|
|
1
|
+
Replace lines via bare 3-char anchor `aB3` from `aB3│content`: `remove_from`/`remove_to` are `aB3` only, `replacement_lines` is bare lines without `│`; one edit per call
|
package/prompts/replace.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output.
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 3-character anchors from read output. Saw `aB3│content` in read/diff/grep: `aB3` is the bare HASH before `│`, `aB3│content` is the full row. `remove_from` and `remove_to` are bare HASH only (e.g. `"aB3"`), never the full row or file content. `replacement_lines` is bare content without `│`, one string per line — use `[]` to delete. Example: read showed `aB3│old` and `kQm│old2`, to replace both use `{"remove_from":"aB3","remove_to":"kQm","replacement_lines":["new line 1","new line 2"]}`. Single line → same anchor for both.
|
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
- `undo_last_change`:
|
|
2
|
-
- `undo_last_change`:
|
|
3
|
-
- `undo_last_change`: if the file was deleted since the edit, the undo restores it from the recorded pre-edit content; if the file was modified since the edit, the undo is refused with `[E_UNDO_STALE]` and the record is kept, so reverting the external change makes the undo succeed.
|
|
4
|
-
- `undo_last_change`: the undo diff is capped at 50KB; a row longer than 50KB is shown as a marker that keeps the row's anchor, and the diff ends with a truncation note when the cap is hit, so call read for content the diff did not show.
|
|
1
|
+
- `undo_last_change`: only the last `replace`/`insert` per file can be undone; a successful `write` clears it, so call immediately after a bad diff (look for `-anchor│` lines you wanted to keep).
|
|
2
|
+
- `undo_last_change`: if the file changed after the edit you get `[E_UNDO_STALE]` — the record is kept, so undo again after the file matches the edited state; if the file was deleted it is restored.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo
|
|
1
|
+
Undo last `replace`/`insert` on a file; restores deleted file, keeps record on `[E_UNDO_STALE]`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo the last
|
|
1
|
+
Undo the last `replace` or `insert` on a file, restoring previous content, BOM and line endings. Use after a bad edit when the diff showed wrong lines removed. If the file was deleted, it is restored; if changed elsewhere, you get `[E_UNDO_STALE]` and the record is kept so you can retry after restoring the edited state. If output says truncated, use `read` to see full file.
|
package/src/commit.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { saveUndo } from "./replace-undo";
|
|
|
5
5
|
import { safeSnapId } from "./file-reader";
|
|
6
6
|
import { writeAtomic } from "./fs-write";
|
|
7
7
|
import { recordServedDiffSafe } from "./served";
|
|
8
|
-
import { restoreEndings } from "./
|
|
8
|
+
import { restoreEndings } from "./normalize";
|
|
9
9
|
|
|
10
10
|
export interface CommitMeta {
|
|
11
11
|
path: string;
|
|
@@ -72,6 +72,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
72
72
|
await writeAtomic(
|
|
73
73
|
absolutePath,
|
|
74
74
|
pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
|
|
75
|
+
pipe.identity,
|
|
75
76
|
);
|
|
76
77
|
} catch (error) {
|
|
77
78
|
await undo.restore();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolveInCwd } from "./fs-write";
|
|
3
|
+
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
|
+
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
5
|
+
import type { ReplaceDetails } from "./replace";
|
|
6
|
+
|
|
7
|
+
export const editPrepare = makePrepareArguments();
|
|
8
|
+
|
|
9
|
+
export function editRenderResultWrapper(
|
|
10
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
11
|
+
opts: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
12
|
+
theme: FgT,
|
|
13
|
+
context: any,
|
|
14
|
+
) {
|
|
15
|
+
return renderEditResult(result, opts, theme, context);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function editRenderCallWrapper(
|
|
19
|
+
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
20
|
+
getInput?: (args: unknown) => { path?: string } | null,
|
|
21
|
+
toolName?: string,
|
|
22
|
+
) {
|
|
23
|
+
return makeRenderCall(preview, { getInput, toolName });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const editToolBase = {
|
|
27
|
+
prepareArguments: editPrepare,
|
|
28
|
+
executionMode: "sequential" as const,
|
|
29
|
+
renderShell: "default" as const,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function queuedEdit<T>(
|
|
33
|
+
path: string,
|
|
34
|
+
cwd: string,
|
|
35
|
+
signal: AbortSignal | undefined,
|
|
36
|
+
work: (absolute: string, resolved: string) => Promise<T>,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
abortIf(signal);
|
|
39
|
+
const { absolute, resolved } = await resolveInCwd(path, cwd);
|
|
40
|
+
return withFileMutationQueue(resolved, async () => {
|
|
41
|
+
abortIf(signal);
|
|
42
|
+
return work(absolute, resolved);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
package/src/file-kind.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
2
2
|
import { fileTypeFromBuffer } from "file-type";
|
|
3
3
|
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
|
+
import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
|
|
5
|
+
import type { FileIdentity } from "./fs-write";
|
|
4
6
|
|
|
5
7
|
const IMG_TYPES = new Set<string>([
|
|
6
8
|
"image/bmp",
|
|
@@ -53,7 +55,7 @@ function looksLikeText(sample: Uint8Array): boolean {
|
|
|
53
55
|
export type LFile =
|
|
54
56
|
| { kind: "directory" }
|
|
55
57
|
| { kind: "image"; mimeType: string }
|
|
56
|
-
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
58
|
+
| { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true }
|
|
57
59
|
| { kind: "binary"; description: string }
|
|
58
60
|
| { kind: "too_large"; description: string };
|
|
59
61
|
|
|
@@ -86,6 +88,8 @@ export async function loadFileKindAndText(
|
|
|
86
88
|
|
|
87
89
|
const fileHandle = await fsOpen(filePath, "r");
|
|
88
90
|
try {
|
|
91
|
+
const openedStats = await fileHandle.stat();
|
|
92
|
+
const identity = { dev: openedStats.dev, ino: openedStats.ino };
|
|
89
93
|
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
90
94
|
const { bytesRead } = await fileHandle.read(
|
|
91
95
|
buffer,
|
|
@@ -94,7 +98,7 @@ export async function loadFileKindAndText(
|
|
|
94
98
|
0,
|
|
95
99
|
);
|
|
96
100
|
if (bytesRead === 0) {
|
|
97
|
-
return { kind: "text", text: "" };
|
|
101
|
+
return { kind: "text", text: "", identity };
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
const sample = buffer.subarray(0, bytesRead);
|
|
@@ -141,11 +145,7 @@ export async function loadFileKindAndText(
|
|
|
141
145
|
for (let i = 0; i < decoded.length; i++) {
|
|
142
146
|
if (decoded.charCodeAt(i) === 10) newlineCount++;
|
|
143
147
|
}
|
|
144
|
-
if (newlineCount > options.maxLines)
|
|
145
|
-
throw new Error(
|
|
146
|
-
`[E_FILE_TOO_LARGE] ${options.displayPath ?? filePath} has more than ${options.maxLines} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
|
|
147
|
-
);
|
|
148
|
-
}
|
|
148
|
+
if (newlineCount > options.maxLines) throw new Error(lineLimitMoreThanMessage(options.displayPath ?? filePath, options.maxLines));
|
|
149
149
|
}
|
|
150
150
|
return decoded;
|
|
151
151
|
}
|
|
@@ -169,6 +169,11 @@ export async function loadFileKindAndText(
|
|
|
169
169
|
position += chunkBytesRead;
|
|
170
170
|
}
|
|
171
171
|
parts.push(decodeChunk(new Uint8Array(0), false));
|
|
172
|
+
const text = parts.join("");
|
|
173
|
+
if (options?.maxLines !== undefined && text.length > 0) {
|
|
174
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
175
|
+
assertLineLimit(normalized, options.displayPath ?? filePath, options.maxLines);
|
|
176
|
+
}
|
|
172
177
|
|
|
173
178
|
if (containsNul) {
|
|
174
179
|
return { kind: "binary", description: "contains NUL bytes" };
|
|
@@ -176,7 +181,8 @@ export async function loadFileKindAndText(
|
|
|
176
181
|
|
|
177
182
|
return {
|
|
178
183
|
kind: "text",
|
|
179
|
-
text
|
|
184
|
+
text,
|
|
185
|
+
identity,
|
|
180
186
|
...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
|
|
181
187
|
};
|
|
182
188
|
} finally {
|
package/src/file-reader.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
2
|
import { stat } from "fs/promises";
|
|
3
|
+
import { relative } from "path";
|
|
3
4
|
import { lineHashes } from "./hashline";
|
|
4
5
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
5
|
-
import { resolveTarget } from "./fs-write";
|
|
6
|
+
import { resolveTarget, type FileIdentity } from "./fs-write";
|
|
6
7
|
import { toCwd } from "./paths";
|
|
7
|
-
import { detectEnding, toLF, stripBOM, type LineEnding } from "./
|
|
8
|
-
import { abortIf } from "./utils";
|
|
8
|
+
import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
|
|
9
|
+
import { abortIf, errCode, assertLineLimit } from "./utils";
|
|
9
10
|
import { valKind, valAccess } from "./validation";
|
|
10
|
-
import { visLines } from "./utils";
|
|
11
11
|
import type { HashStore } from "./hash-store";
|
|
12
12
|
export interface NormFile {
|
|
13
13
|
absolutePath: string;
|
|
@@ -16,6 +16,7 @@ export interface NormFile {
|
|
|
16
16
|
originalEnding: LineEnding;
|
|
17
17
|
fileHashes: string[];
|
|
18
18
|
hadUtf8DecodeErrors: boolean;
|
|
19
|
+
identity: FileIdentity;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export type SnapInfo = {
|
|
@@ -52,7 +53,7 @@ export async function safeSnapId(
|
|
|
52
53
|
try {
|
|
53
54
|
return (await fileSnap(absolutePath)).snapshotId;
|
|
54
55
|
} catch (error) {
|
|
55
|
-
console.error(`
|
|
56
|
+
console.error(`[safeSnapId] ${context}: failed to stat "${absolutePath}" (code=${errCode(error) ?? "?"}):`, error);
|
|
56
57
|
return undefined;
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -91,22 +92,42 @@ export async function readNormFile(
|
|
|
91
92
|
const originalEnding = detectEnding(rawContent);
|
|
92
93
|
const normalized = toLF(rawContent);
|
|
93
94
|
|
|
94
|
-
if (options?.maxLines !== undefined)
|
|
95
|
-
const lineCount = visLines(normalized).length;
|
|
96
|
-
if (lineCount > options.maxLines) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${options.maxLines}-line hashline limit. For very large files, use write.`,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
95
|
+
if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
|
|
102
96
|
|
|
103
97
|
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
|
|
98
|
+
let identity = file.identity;
|
|
99
|
+
if (!identity) {
|
|
100
|
+
const { dev, ino } = await stat(resolvedPath);
|
|
101
|
+
identity = { dev, ino };
|
|
102
|
+
}
|
|
104
103
|
return {
|
|
105
104
|
absolutePath: resolvedPath,
|
|
106
105
|
normalized,
|
|
107
106
|
bom,
|
|
108
107
|
originalEnding,
|
|
109
108
|
fileHashes,
|
|
109
|
+
identity,
|
|
110
110
|
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
export async function tryReadNormFile(
|
|
115
|
+
absPath: string,
|
|
116
|
+
cwd: string,
|
|
117
|
+
options?: ReadNormOptions,
|
|
118
|
+
): Promise<NormFile | undefined> {
|
|
119
|
+
try {
|
|
120
|
+
const displayPath = relative(cwd, absPath).replace(/\\/g, "/") || absPath;
|
|
121
|
+
const file = await loadFileKindAndText(absPath, { maxLines: options?.maxLines, displayPath });
|
|
122
|
+
if (file.kind !== "text") return undefined;
|
|
123
|
+
return await readNormFile(absPath, cwd, { ...options, preloadedFile: file });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const code = errCode(error);
|
|
126
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return undefined;
|
|
127
|
+
if (error instanceof Error) {
|
|
128
|
+
const msg = error.message;
|
|
129
|
+
if (msg.startsWith("[E_FILE_TOO_LARGE]") || msg.startsWith("[E_NOT_FOUND]") || msg.startsWith("[E_ACCESS]") || msg.startsWith("[E_NOT_TEXT]")) return undefined;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/fs-write.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { constants } from "fs";
|
|
2
3
|
import {
|
|
3
4
|
lstat,
|
|
4
5
|
mkdir,
|
|
@@ -11,8 +12,25 @@ import {
|
|
|
11
12
|
writeFile,
|
|
12
13
|
} from "fs/promises";
|
|
13
14
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
15
|
+
import { toCwd } from "./paths";
|
|
14
16
|
import { errCode } from "./utils";
|
|
15
17
|
|
|
18
|
+
export interface FileIdentity {
|
|
19
|
+
dev: number;
|
|
20
|
+
ino: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameIdentity(
|
|
24
|
+
actual: Pick<Awaited<ReturnType<typeof stat>>, "dev" | "ino">,
|
|
25
|
+
expected: FileIdentity,
|
|
26
|
+
): boolean {
|
|
27
|
+
return actual.dev === expected.dev && actual.ino === expected.ino;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathChanged(path: string): Error {
|
|
31
|
+
return new Error(`[E_PATH_CHANGED] Refusing to write ${path}: the target changed after it was read.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
export async function resolveTarget(path: string): Promise<string> {
|
|
17
35
|
const absolutePath = resolve(path);
|
|
18
36
|
const { root } = parse(absolutePath);
|
|
@@ -25,7 +43,15 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
25
43
|
async function resParts(
|
|
26
44
|
currentPath: string,
|
|
27
45
|
remainingParts: string[],
|
|
46
|
+
symlinkDepth = 0,
|
|
28
47
|
): Promise<string> {
|
|
48
|
+
if (symlinkDepth > 40) {
|
|
49
|
+
const error = new Error(
|
|
50
|
+
`Too many symbolic links while resolving ${path}`,
|
|
51
|
+
) as NodeJS.ErrnoException;
|
|
52
|
+
error.code = "ELOOP";
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
29
55
|
if (remainingParts.length === 0) {
|
|
30
56
|
return currentPath;
|
|
31
57
|
}
|
|
@@ -36,7 +62,7 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
36
62
|
try {
|
|
37
63
|
const candidateStats = await lstat(candidatePath);
|
|
38
64
|
if (!candidateStats.isSymbolicLink()) {
|
|
39
|
-
return resParts(candidatePath, tail);
|
|
65
|
+
return resParts(candidatePath, tail, symlinkDepth);
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
if (visitedSymlinks.has(candidatePath)) {
|
|
@@ -59,7 +85,7 @@ export async function resolveTarget(path: string): Promise<string> {
|
|
|
59
85
|
return resParts(parse(linkTargetPath).root, [
|
|
60
86
|
...targetParts,
|
|
61
87
|
...tail,
|
|
62
|
-
]);
|
|
88
|
+
], symlinkDepth + 1);
|
|
63
89
|
} catch (error: unknown) {
|
|
64
90
|
if (errCode(error) === "ENOENT") {
|
|
65
91
|
return join(candidatePath, ...tail);
|
|
@@ -110,9 +136,15 @@ async function syncDir(dir: string): Promise<void> {
|
|
|
110
136
|
}
|
|
111
137
|
}
|
|
112
138
|
|
|
139
|
+
export async function resolveInCwd(path: string, cwd: string): Promise<{ absolute: string; resolved: string }> {
|
|
140
|
+
const absolute = toCwd(path, cwd);
|
|
141
|
+
const resolved = await resolveTarget(absolute);
|
|
142
|
+
return { absolute, resolved };
|
|
143
|
+
}
|
|
113
144
|
export async function writeAtomic(
|
|
114
145
|
path: string,
|
|
115
146
|
content: string,
|
|
147
|
+
expectedIdentity?: FileIdentity,
|
|
116
148
|
): Promise<void> {
|
|
117
149
|
const targetPath = await resolveTarget(path);
|
|
118
150
|
|
|
@@ -125,8 +157,25 @@ export async function writeAtomic(
|
|
|
125
157
|
}
|
|
126
158
|
}
|
|
127
159
|
|
|
160
|
+
if (expectedIdentity && (!existingStats || !sameIdentity(existingStats, expectedIdentity))) {
|
|
161
|
+
throw pathChanged(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
128
164
|
if (existingStats && existingStats.nlink > 1) {
|
|
129
|
-
|
|
165
|
+
const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
166
|
+
const handle = await open(targetPath, constants.O_WRONLY | noFollow);
|
|
167
|
+
try {
|
|
168
|
+
const openedStats = await handle.stat();
|
|
169
|
+
if (!sameIdentity(openedStats, existingStats)) throw pathChanged(path);
|
|
170
|
+
await handle.writeFile(content, "utf-8");
|
|
171
|
+
await handle.truncate(Buffer.byteLength(content, "utf-8"));
|
|
172
|
+
try {
|
|
173
|
+
await handle.chmod(existingStats.mode & 0o7777);
|
|
174
|
+
} catch {}
|
|
175
|
+
await handle.sync();
|
|
176
|
+
} finally {
|
|
177
|
+
await handle.close();
|
|
178
|
+
}
|
|
130
179
|
return;
|
|
131
180
|
}
|
|
132
181
|
|
|
@@ -148,6 +197,14 @@ export async function writeAtomic(
|
|
|
148
197
|
}
|
|
149
198
|
try {
|
|
150
199
|
await tempHandle.close();
|
|
200
|
+
try {
|
|
201
|
+
const finalStats = await lstat(targetPath);
|
|
202
|
+
if (!existingStats || finalStats.isSymbolicLink() || !sameIdentity(finalStats, existingStats)) {
|
|
203
|
+
throw pathChanged(path);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (errCode(error) !== "ENOENT" || existingStats) throw error;
|
|
207
|
+
}
|
|
151
208
|
await rename(tempPath, targetPath);
|
|
152
209
|
await syncDir(dir);
|
|
153
210
|
} catch (error: unknown) {
|