pi-hashline-edit-pro 4.5.1 → 4.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/index.ts +2 -1
- package/package.json +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/replace.md +1 -1
- package/src/batch.ts +29 -7
- package/src/commit.ts +16 -13
- package/src/config-ui.ts +1 -1
- package/src/copy-move.ts +4 -0
- package/src/grep.ts +19 -2
- package/src/hashline/apply.ts +7 -5
- package/src/hashline/parse.ts +8 -4
- package/src/hashline/resolve.ts +2 -0
- package/src/insert.ts +3 -1
- package/src/replace.ts +18 -2
package/README.md
CHANGED
|
@@ -186,7 +186,7 @@ Edge cases:
|
|
|
186
186
|
| --- | --- |
|
|
187
187
|
| `remove_from` | 4-char anchor marking the FIRST line to remove (inclusive). |
|
|
188
188
|
| `remove_to` | 4-char anchor marking the LAST line to remove (inclusive). |
|
|
189
|
-
| `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets
|
|
189
|
+
| `replacement_lines` | Replacement lines, one element per line. Mirror the removed lines exactly, blank lines included: `[]` deletes the range, `[""]` is a single blank line, `["a", ""]` is a line followed by a blank line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets the line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line-break sets the ending of the element's last line instead of adding a blank line, so `["b\n"]` sets that line's ending to LF, `["b\r\n"]` to CRLF, and on a file without a final newline it adds it. A lone string is accepted too: it is split on newlines, and stringified array text is unwrapped. |
|
|
190
190
|
|
|
191
191
|
Example: read showed `Hasu│old` and `arvm│old2`; to replace both:
|
|
192
192
|
|
|
@@ -208,7 +208,7 @@ Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before
|
|
|
208
208
|
|
|
209
209
|
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown, for example beyond an auto-read preview's truncation cap, is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
|
|
210
210
|
|
|
211
|
-
An edit that
|
|
211
|
+
An edit that changes neither content nor line endings reports `No changes made` and leaves the anchors alone.
|
|
212
212
|
|
|
213
213
|
After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
|
|
214
214
|
|
|
@@ -220,7 +220,7 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
220
220
|
| --- | --- |
|
|
221
221
|
| `anchor` | 4-char anchor marking the line next to which the lines go. The anchor line is preserved. A pasted `+Hasu│x` diff row or `anchor│` prefix is stripped automatically with a warning. |
|
|
222
222
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
223
|
-
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets
|
|
223
|
+
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line. One element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets the line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. A trailing line-break sets the ending of the element's last line instead of adding a blank line, so `["b\n"]` inserts `b` with an LF ending, and on a file without a final newline it adds it. A lone string is split on newlines, and stringified array text is unwrapped. |
|
|
224
224
|
|
|
225
225
|
Nothing is removed and the inserted lines are written exactly as given; the anchor line and every other line stay in place. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
226
226
|
|
|
@@ -314,6 +314,8 @@ Auto-read is enabled by default. After a successful `write`, the extension reads
|
|
|
314
314
|
|
|
315
315
|
After `replace`, `insert`, `copy`, `move`, and `undo_last_change`, the result shows the post-edit diff. Inside a same-message batch, only the batch's last call shows the combined diff, headed by a `batch N:` line; earlier calls reply `In batch N`. The `+anchor│` and ` anchor│` rows carry the current anchors, so follow-up edits can anchor on the diff directly. The `-anchor│` rows show removed lines with their old anchors, which are stale after the edit. When the context line next to a change is blank or whitespace-only, one more context line is shown in that direction, so the change stays anchored to visible content. Call `read` when you want the full file's anchors.
|
|
316
316
|
|
|
317
|
+
An edit that changes only line endings has no content diff; the result still reports `applied`, and one `undo_last_change` reverts it.
|
|
318
|
+
|
|
317
319
|
Auto-read keeps the same 50KB and 2000-line budget as `read`. Auto-read and Diff context live in `/hashline-config` and persist across sessions. The post-edit diff shows 1 surrounding line by default; change Diff context in `/hashline-config` (0-10, needs Auto-read) to show more or fewer.
|
|
318
320
|
|
|
319
321
|
## Auto-read all
|
|
@@ -357,7 +359,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
357
359
|
| `autoReadAllIgnore` | Ignore folders/files | `[]` | Extra folder names, file names, or globs skipped by auto-read all. |
|
|
358
360
|
| `anchorGrepEnabled` | Anchor grep | `true` | Register `anchor_grep` and disable the built-in grep while it is on. |
|
|
359
361
|
| `copyMoveEnabled` | Copy/move | `true` | Offer the `copy` and `move` tools; when off, both are removed from the active tools. |
|
|
360
|
-
| `requirePath` | Require path | `false` | `replace` and `
|
|
362
|
+
| `requirePath` | Require path | `false` | `replace`, `insert`, `copy`, and `move` require a `path` argument that must match anchor ownership. |
|
|
361
363
|
| `strictInput` | Strict input | `false` | Reject auto-fixable slips (`[W_BAD_SHAPE]`, `[W_BAD_REF]`, `[W_INVALID_PATCH]`, `[W_BARE_HASH_PREFIX]`) with `[E_BAD_SHAPE]` instead of applying them with a warning. |
|
|
362
364
|
| `diffContextLines` | Diff context | `1` | Surrounding lines in post-edit diffs, 0-10 (needs Auto-read). |
|
|
363
365
|
|
|
@@ -369,7 +371,7 @@ When `PI_HASHLINE_DIR` is unset or empty, non-Windows platforms honor `XDG_CONFI
|
|
|
369
371
|
| --- | --- | --- |
|
|
370
372
|
| Output cap | 2000 lines and 50KB | `read`, auto-read after `write`, post-edit diffs, patches, previews, `details.patch` |
|
|
371
373
|
| Oversized row | 50KB per `anchor│content` row | replaced by an anchor-keeping marker you can still edit through |
|
|
372
|
-
| Line cap | 1,353,139 lines per file | `read`, `replace`, `insert` (`[E_FILE_TOO_LARGE]`) |
|
|
374
|
+
| Line cap | 1,353,139 lines per file | `read`, `replace`, `insert`, `copy`, `move` (`[E_FILE_TOO_LARGE]`) |
|
|
373
375
|
| File size | 100MB | all tools (`[E_FILE_TOO_LARGE]`) |
|
|
374
376
|
| Hash window | first 500 bytes of a line | anchor identity for long lines |
|
|
375
377
|
| Patch guard | 1MB of pre-edit + post-edit text | patch generation is skipped and `patchTruncated` is set |
|
|
@@ -423,7 +425,7 @@ Full reference:
|
|
|
423
425
|
| `[W_INVALID_PATCH]` | A `replacement_lines` element is a diff-preview row (`+anchor│`, `-anchor│`, `- │`). The marker is stripped automatically with a warning. |
|
|
424
426
|
| `[W_BARE_HASH_PREFIX]` | A `replacement_lines` element starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
425
427
|
| `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
|
|
426
|
-
| `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains
|
|
428
|
+
| `[H_LITERAL_ESCAPE]` | `lines` or `replacement_lines` contains literal escaped text such as `\uXXXX` or `\n`; the file receives those backslash characters as written. Escapes decode once in the tool call (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally — resend with the real character if that was not intended. |
|
|
427
429
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. A cross-file `move` may empty its source file. |
|
|
428
430
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
429
431
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
@@ -479,7 +481,7 @@ Background snapshot pruning and registry sidecar GC skip `EPERM`/`EACCES` withou
|
|
|
479
481
|
|
|
480
482
|
### Allocation
|
|
481
483
|
|
|
482
|
-
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all
|
|
484
|
+
Anchors are allocated, never derived. Every line that is served to you, by `read`, `anchor_grep`, the auto-read block after `write`, or a post-edit diff, gets the next free anchor from the session's pool, claimed by walking the table with a stride of 836,286 entries (coprime to the 1,353,139-entry table), so consecutively minted anchors land in unrelated regions of the table instead of sharing leading characters. Each session seeds its walk from its own offset (derived from the session key and the process id), so concurrent sessions mint different sequences instead of identical ones: an anchor minted in one session is unknown in another and is rejected with `[E_STALE_ANCHOR]` rather than resolving to a different file. Ownership is exclusive: an anchor is owned by one file's line until it is freed (the line was edited, the file was written or deleted, you ran `/clear-anchors`, or the session's quota ran out and the file was the least recently read or edited, which frees all of its anchors and reports it in `[W_ANCHOR_RECLAIMED]`). Minting prefers anchors the session has never used; when a bounded fresh-anchor probe finds nothing, freed anchors are recycled after their stale served records are purged, so an anchor is never shared by two live lines. Because ownership is exclusive, an anchor resolves to exactly one file. Two byte-identical lines never share an anchor, and that guarantee sets the file size cap: the pool is the shipped table's 1,353,139 entries (not all 52⁴ letter combinations), so a file can hold at most 1,353,139 lines, beyond which `read`, `replace`, `insert`, `copy`, and `move` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
483
485
|
|
|
484
486
|
### Ownership and mapping across edits
|
|
485
487
|
|
package/index.ts
CHANGED
|
@@ -280,7 +280,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
280
280
|
const warnings = detailWarnings.length ? `Warnings:\n${detailWarnings.join("\n")}` : extractWarnings(rendered);
|
|
281
281
|
const hints = detailHints.length ? `Hints:\n${detailHints.join("\n")}` : extractHints(rendered);
|
|
282
282
|
const notices = [warnings, hints].filter((part): part is string => part !== undefined).join("\n\n");
|
|
283
|
-
const
|
|
283
|
+
const emptyDiffNotice = "[post-edit] applied successfully; the diff is empty (no content change: whitespace or line endings only).";
|
|
284
|
+
const noticeText = hasDiff ? (notices ? `${diff}\n\n${notices}` : diff) : notices ? `${emptyDiffNotice}\n\n${notices}` : emptyDiffNotice;
|
|
284
285
|
return {
|
|
285
286
|
content: [
|
|
286
287
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/prompts/insert.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
1
|
+
Insert lines after or before one existing line in a text file, addressed by a bare anchor from any served anchor│content row. The anchor line is preserved: `lines` go after it with `direction: "after"` or before it with `direction: "before"`, one string per line, no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. Inserted lines are written exactly as given; nothing else in the file changes.
|
|
2
2
|
|
|
3
3
|
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
- `replace`: `-anchor│` rows in a post-edit diff are dead anchors; only `+anchor│` and ` anchor│` rows are live. Check the post-edit diff before the next turn's edits on that file.
|
|
2
2
|
- `replace`: same-file same-message calls batch: disjoint ranges, one undo. A call whose anchors resolve nowhere fails alone; its file's batch still commits.
|
|
3
|
-
- `replace`: use `replace`/`insert
|
|
3
|
+
- `replace`: use `replace`/`insert` for structural or multi-line edits — anchored, verified, undoable.
|
|
4
4
|
- `replace`: `replacement_lines`: one string per line, `[""]` is one blank line, `[]` deletes. Pasted `anchor│` prefixes are stripped. Single line: same anchor for `remove_from` and `remove_to`.
|
package/prompts/replace.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending. `[]` deletes the range.
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from any served anchor│content row. Give `remove_from` and `remove_to` as bare anchors marking the first and last line to remove, and `replacement_lines` as one string per new line with no anchor prefixes. An element is one line: a real line-break character (`\n`, `\r\n`, or `\r`) splits it and sets that line's ending; escapes decode once — `\uXXXX` is the character, `\\uXXXX` the literal text. `[]` deletes the range.
|
|
2
2
|
|
|
3
3
|
Same-file calls in one message batch: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
4
4
|
|
package/src/batch.ts
CHANGED
|
@@ -543,13 +543,31 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
543
543
|
|
|
544
544
|
function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
|
|
545
545
|
const lines = splitLines(baseContent);
|
|
546
|
+
const baseLineCount = lines.length;
|
|
546
547
|
const descending = [...pieces].sort((a, b) => b.start - a.start);
|
|
547
548
|
for (const piece of descending) lines.splice(piece.start - 1, piece.end - piece.start + 1, ...piece.newLines);
|
|
548
549
|
let composed = lines.join("\n");
|
|
549
|
-
if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === ""))
|
|
550
|
+
if (lines.length > 0 && (baseContent.endsWith("\n") || lines[lines.length - 1] === "")) {
|
|
551
|
+
composed += "\n";
|
|
552
|
+
} else if (lines.length > 0) {
|
|
553
|
+
const trailing = pieces.reduce<BatchPiece | undefined>((best, piece) => (best === undefined || piece.end > best.end ? piece : best), undefined);
|
|
554
|
+
const lastEnding = trailing?.separators?.[trailing.newLines.length - 1];
|
|
555
|
+
if (trailing !== undefined && trailing.end === baseLineCount && lastEnding !== undefined) composed += "\n";
|
|
556
|
+
}
|
|
550
557
|
return composed;
|
|
551
558
|
}
|
|
552
559
|
|
|
560
|
+
function changesEnding(piece: BatchPiece, baseSeparators: LineEnding[]): boolean {
|
|
561
|
+
if (piece.separators === undefined) return false;
|
|
562
|
+
for (let index = 0; index < piece.separators.length; index++) {
|
|
563
|
+
const ending = piece.separators[index];
|
|
564
|
+
if (ending === undefined) continue;
|
|
565
|
+
const baseIndex = piece.start - 1 + index;
|
|
566
|
+
if (baseIndex >= baseSeparators.length || baseSeparators[baseIndex] !== ending) return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
553
571
|
function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
|
|
554
572
|
const byAnchor = new Map<number, BatchPiece[]>();
|
|
555
573
|
for (const piece of pieces) {
|
|
@@ -624,11 +642,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
624
642
|
}
|
|
625
643
|
}
|
|
626
644
|
const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
|
|
627
|
-
|
|
645
|
+
const candidatePieces = runtime.pieces.filter((piece) => !piece.noop || changesEnding(piece, base.separators));
|
|
646
|
+
if (candidatePieces.length === 0) {
|
|
628
647
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
629
648
|
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
630
649
|
}
|
|
631
|
-
const effectivePieces = mergeInsertPairs(
|
|
650
|
+
const effectivePieces = mergeInsertPairs(candidatePieces);
|
|
632
651
|
const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
|
|
633
652
|
for (let i = 1; i < ordered.length; i++) {
|
|
634
653
|
const prev = ordered[i - 1]!;
|
|
@@ -649,11 +668,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
649
668
|
})));
|
|
650
669
|
const warnings = [...runtime.warnings];
|
|
651
670
|
if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
671
|
+
const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
|
|
672
|
+
const originalBytes = base.bom + joinSeparators(base.content, base.separators);
|
|
652
673
|
try {
|
|
653
674
|
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
654
675
|
assertNotEmpty(base.content, composed);
|
|
655
676
|
assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
|
|
656
|
-
const finalBytes = base.bom + joinSeparators(composed, resultSeparators);
|
|
657
677
|
assertByteLimit(finalBytes, paths.displayPath);
|
|
658
678
|
} catch (error) {
|
|
659
679
|
discardBatchState(runtime);
|
|
@@ -662,7 +682,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
662
682
|
}
|
|
663
683
|
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
664
684
|
if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
|
|
665
|
-
if (
|
|
685
|
+
if (finalBytes === originalBytes) {
|
|
666
686
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
667
687
|
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
668
688
|
}
|
|
@@ -756,8 +776,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
756
776
|
batchVerb(runtime),
|
|
757
777
|
await getDiffContextLines(),
|
|
758
778
|
);
|
|
759
|
-
changed.details.diff
|
|
760
|
-
|
|
779
|
+
if (changed.details.diff.length > 0) {
|
|
780
|
+
changed.details.diff = `${header}\n${changed.details.diff}`;
|
|
781
|
+
changed.details.diffLineNumbers?.unshift(null);
|
|
782
|
+
}
|
|
761
783
|
try {
|
|
762
784
|
serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
|
|
763
785
|
} catch (error) {
|
package/src/commit.ts
CHANGED
|
@@ -33,7 +33,22 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
33
33
|
const readReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
34
34
|
if (readReclaim !== undefined) warnings.push(readReclaim);
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
|
|
37
|
+
if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
|
|
38
|
+
const resultSeparators = span
|
|
39
|
+
? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
|
|
40
|
+
: undefined;
|
|
41
|
+
if (resultSeparators !== undefined && span !== undefined) {
|
|
42
|
+
applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
|
|
43
|
+
applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
|
|
44
|
+
}
|
|
45
|
+
const finalFileBytes = pipe.bom + (resultSeparators !== undefined
|
|
46
|
+
? joinSeparators(pipe.result, resultSeparators)
|
|
47
|
+
: restoreEndings(pipe.result, pipe.originalEnding));
|
|
48
|
+
const contentUnchanged = pipe.result === pipe.originalNormalized;
|
|
49
|
+
const bytesUnchanged = contentUnchanged && (span === undefined || finalFileBytes === pipe.bom + joinSeparators(pipe.originalNormalized, pipe.originalSeparators));
|
|
50
|
+
|
|
51
|
+
if (bytesUnchanged) {
|
|
37
52
|
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
38
53
|
return buildNoop(
|
|
39
54
|
{
|
|
@@ -52,18 +67,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
52
67
|
);
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
const span = pipe.spans?.[0] ?? (meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined);
|
|
56
|
-
if (span && meta.anchorCarry !== undefined) span.carry = meta.anchorCarry;
|
|
57
|
-
const resultSeparators = span
|
|
58
|
-
? separatorsForSpans(pipe.originalSeparators, pipe.originalHashes.length, [span], pipe.result, pipe.originalEnding)
|
|
59
|
-
: undefined;
|
|
60
|
-
if (resultSeparators !== undefined && span !== undefined) {
|
|
61
|
-
applyEndingOverrides(resultSeparators, span.start, pipe.contentSeparators);
|
|
62
|
-
applyEndingOverrides(resultSeparators, span.start, meta.endingOverrides);
|
|
63
|
-
}
|
|
64
|
-
const finalFileBytes = pipe.bom + (resultSeparators !== undefined
|
|
65
|
-
? joinSeparators(pipe.result, resultSeparators)
|
|
66
|
-
: restoreEndings(pipe.result, pipe.originalEnding));
|
|
67
70
|
assertByteLimit(finalFileBytes, path);
|
|
68
71
|
|
|
69
72
|
if (pipe.hadUtf8DecodeErrors) {
|
package/src/config-ui.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
24
24
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
25
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
26
26
|
{ key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
|
|
27
|
-
{ key: "requirePath", label: "Require path", hint: "replace
|
|
27
|
+
{ key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
|
|
28
28
|
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
29
29
|
];
|
|
30
30
|
}
|
package/src/copy-move.ts
CHANGED
|
@@ -544,6 +544,7 @@ async function executeCrossFile(
|
|
|
544
544
|
accessMode: constants.R_OK | constants.W_OK,
|
|
545
545
|
signal,
|
|
546
546
|
preloadedNorm: prepared.destinationPreload,
|
|
547
|
+
endingOverrides: prepared.endingOverrides,
|
|
547
548
|
});
|
|
548
549
|
if (kind === "copy") {
|
|
549
550
|
return commitEdit(destinationPipe, {
|
|
@@ -610,6 +611,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
610
611
|
noPersist: true,
|
|
611
612
|
preloadedNorm: preload,
|
|
612
613
|
served: plan.servedOverride,
|
|
614
|
+
endingOverrides: plan.endingOverrides,
|
|
613
615
|
signal,
|
|
614
616
|
});
|
|
615
617
|
return previewFromPipe(pipe);
|
|
@@ -619,6 +621,7 @@ export async function transferPreview(kind: TransferKind, request: unknown, cwd:
|
|
|
619
621
|
accessMode: constants.R_OK,
|
|
620
622
|
noPersist: true,
|
|
621
623
|
preloadedNorm: prepared.destinationPreload,
|
|
624
|
+
endingOverrides: prepared.endingOverrides,
|
|
622
625
|
signal,
|
|
623
626
|
});
|
|
624
627
|
if (kind === "copy") return previewFromPipe(destinationPipe);
|
|
@@ -764,6 +767,7 @@ export function buildTransferToolDef(kind: TransferKind, flags: EditToolFlags =
|
|
|
764
767
|
signal,
|
|
765
768
|
preloadedNorm: preload,
|
|
766
769
|
served: plan.servedOverride,
|
|
770
|
+
endingOverrides: plan.endingOverrides,
|
|
767
771
|
});
|
|
768
772
|
return commitEdit(pipe, {
|
|
769
773
|
path: pipe.path,
|
package/src/grep.ts
CHANGED
|
@@ -298,14 +298,30 @@ export async function resolveRgPath(): Promise<string> {
|
|
|
298
298
|
throw new Error("[E_ACCESS] ripgrep (rg) is required for grep but was not found. Install ripgrep or ensure pi can download it to ~/.pi/agent/bin.");
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async function insideGitRepo(start: string): Promise<boolean> {
|
|
302
|
+
let current = start;
|
|
303
|
+
for (;;) {
|
|
304
|
+
try {
|
|
305
|
+
await stat(join(current, ".git"));
|
|
306
|
+
return true;
|
|
307
|
+
} catch {
|
|
308
|
+
const parent = dirname(current);
|
|
309
|
+
if (parent === current) return false;
|
|
310
|
+
current = parent;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
301
315
|
async function collectRgMatches(
|
|
302
316
|
rgPath: string,
|
|
303
317
|
pattern: string,
|
|
304
318
|
searchPath: string,
|
|
305
319
|
req: GrepReq,
|
|
306
|
-
signal
|
|
320
|
+
signal: AbortSignal | undefined,
|
|
321
|
+
repoRooted: boolean,
|
|
307
322
|
): Promise<Map<string, number[]>> {
|
|
308
323
|
const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
|
|
324
|
+
if (!repoRooted) args.push("--no-require-git");
|
|
309
325
|
const wanted = req.limit ?? 100;
|
|
310
326
|
args.push("--max-count", String(wanted + 1));
|
|
311
327
|
if (typeof req.glob === "string" && req.glob.length > 0) {
|
|
@@ -583,7 +599,8 @@ export function regGrep(pi: ExtensionAPI): void {
|
|
|
583
599
|
};
|
|
584
600
|
const readGrepFile = makeGrepReader("real");
|
|
585
601
|
const readGrepFileShadow = makeGrepReader("shadow");
|
|
586
|
-
const
|
|
602
|
+
const repoRooted = await insideGitRepo(globRoot);
|
|
603
|
+
const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
|
|
587
604
|
const sortedFiles = [...rgMatches.keys()].sort(cmp);
|
|
588
605
|
for (let f = 0; f < sortedFiles.length; f++) {
|
|
589
606
|
abortIf(signal);
|
package/src/hashline/apply.ts
CHANGED
|
@@ -68,12 +68,12 @@ function resToSpan(
|
|
|
68
68
|
const startLine = edit.hash_bounds[0].line;
|
|
69
69
|
const endLine = edit.hash_bounds[1].line;
|
|
70
70
|
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
71
|
-
|
|
71
|
+
const contentUnchanged =
|
|
72
72
|
originalLines.length === edit.content_lines.length &&
|
|
73
73
|
originalLines.every(
|
|
74
74
|
(line, lineIndex) => line === edit.content_lines[lineIndex],
|
|
75
|
-
)
|
|
76
|
-
) {
|
|
75
|
+
);
|
|
76
|
+
if (contentUnchanged && edit.content_separators === undefined) {
|
|
77
77
|
return {
|
|
78
78
|
kind: "noop",
|
|
79
79
|
loc: edit.hash_bounds[0].hash,
|
|
@@ -82,7 +82,9 @@ function resToSpan(
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
if (edit.content_lines.length > 0) {
|
|
85
|
-
const
|
|
85
|
+
const lastIndex = edit.content_lines.length - 1;
|
|
86
|
+
const lastReplacementLine = edit.content_lines[lastIndex]!;
|
|
87
|
+
const lastEnding = edit.content_separators?.[lastIndex];
|
|
86
88
|
const endsWithBlank = lastReplacementLine.length === 0;
|
|
87
89
|
const endsAtEofWithoutNewline =
|
|
88
90
|
endLine === fileLines.length && !content.endsWith("\n");
|
|
@@ -92,7 +94,7 @@ function resToSpan(
|
|
|
92
94
|
start: lineStarts[startLine - 1]!,
|
|
93
95
|
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
94
96
|
replacement:
|
|
95
|
-
endsAtEofWithoutNewline && endsWithBlank
|
|
97
|
+
endsAtEofWithoutNewline && (endsWithBlank || lastEnding !== undefined)
|
|
96
98
|
? `${replacement}\n`
|
|
97
99
|
: replacement,
|
|
98
100
|
};
|
package/src/hashline/parse.ts
CHANGED
|
@@ -81,10 +81,14 @@ export function parseTextWithSeparators(edit: string[]): ParsedText {
|
|
|
81
81
|
}
|
|
82
82
|
const lines: string[] = [];
|
|
83
83
|
const separators: (LineEnding | undefined)[] = [];
|
|
84
|
-
for (const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
for (const element of edit) {
|
|
85
|
+
const text = unwrapJsonEnvelope(element);
|
|
86
|
+
const parsed = splitWithEndings(text);
|
|
87
|
+
const lineCount = text.endsWith("\n") || text.endsWith("\r") ? parsed.lines.length - 1 : parsed.lines.length;
|
|
88
|
+
for (let index = 0; index < lineCount; index++) {
|
|
89
|
+
lines.push(parsed.lines[index]!);
|
|
90
|
+
separators.push(parsed.endings[index]!);
|
|
91
|
+
}
|
|
88
92
|
}
|
|
89
93
|
return { lines, separators };
|
|
90
94
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type HEdit = {
|
|
|
18
18
|
export type RHEdit = {
|
|
19
19
|
content_lines: string[];
|
|
20
20
|
hash_bounds: [RAnchor, RAnchor];
|
|
21
|
+
content_separators?: (LineEnding | undefined)[];
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
interface HMismatch {
|
|
@@ -288,6 +289,7 @@ export function valEdit(
|
|
|
288
289
|
resolved: {
|
|
289
290
|
content_lines: edit.content_lines,
|
|
290
291
|
hash_bounds: [startResolved, endResolved],
|
|
292
|
+
...(edit.content_separators !== undefined ? { content_separators: edit.content_separators } : {}),
|
|
291
293
|
},
|
|
292
294
|
mismatches,
|
|
293
295
|
};
|
package/src/insert.ts
CHANGED
|
@@ -125,13 +125,14 @@ export async function insertPreview(request: unknown, cwd: string, signal?: Abor
|
|
|
125
125
|
allocation: "shadow",
|
|
126
126
|
signal,
|
|
127
127
|
});
|
|
128
|
-
const { editParams, anchorLine } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
128
|
+
const { editParams, anchorLine, contentSeparators } = buildInsertEdit(normalized, preload, ref, targetPath);
|
|
129
129
|
const pipe = await execPipeline(targetPath, editParams, cwd, {
|
|
130
130
|
accessMode: constants.R_OK,
|
|
131
131
|
noPersist: true,
|
|
132
132
|
preloadedNorm: preload,
|
|
133
133
|
signal,
|
|
134
134
|
stripWarning: insertStripWarning(anchorLine, normalized.direction),
|
|
135
|
+
endingOverrides: contentSeparators,
|
|
135
136
|
});
|
|
136
137
|
return previewFromPipe(pipe);
|
|
137
138
|
} catch (error: unknown) {
|
|
@@ -253,6 +254,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
253
254
|
signal,
|
|
254
255
|
preloadedNorm: preload,
|
|
255
256
|
stripWarning: insertStripWarning(anchorLine, req.direction),
|
|
257
|
+
endingOverrides: contentSeparators,
|
|
256
258
|
});
|
|
257
259
|
return commitEdit(pipe, {
|
|
258
260
|
path: pipe.path,
|
package/src/replace.ts
CHANGED
|
@@ -83,6 +83,7 @@ export interface ExecPipelineOptions {
|
|
|
83
83
|
served?: ReadonlyMap<string, string>;
|
|
84
84
|
allowEmpty?: boolean;
|
|
85
85
|
stripWarning?: StripWarningLocation;
|
|
86
|
+
endingOverrides?: (LineEnding | undefined)[];
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
export function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
|
|
@@ -134,6 +135,17 @@ export function buildReplaceHEdit(params: ReqParams): { edit: HEdit; warnings: s
|
|
|
134
135
|
return { edit, warnings: editWarnings };
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[] | undefined): HEdit {
|
|
139
|
+
if (overrides === undefined) return edit;
|
|
140
|
+
const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
|
|
141
|
+
const separators: (LineEnding | undefined)[] = new Array(length);
|
|
142
|
+
for (let index = 0; index < length; index++) {
|
|
143
|
+
separators[index] = overrides[index] ?? edit.content_separators?.[index];
|
|
144
|
+
}
|
|
145
|
+
if (separators.every((ending) => ending === undefined)) return edit;
|
|
146
|
+
return { ...edit, content_separators: separators };
|
|
147
|
+
}
|
|
148
|
+
|
|
137
149
|
export async function execPipeline(
|
|
138
150
|
targetPath: string,
|
|
139
151
|
params: ReqParams,
|
|
@@ -142,6 +154,7 @@ export async function execPipeline(
|
|
|
142
154
|
): Promise<PipelineResult> {
|
|
143
155
|
|
|
144
156
|
const { edit, warnings: editWarnings } = buildReplaceHEdit(params);
|
|
157
|
+
const anchoredEdit = withEndingOverrides(edit, options?.endingOverrides);
|
|
145
158
|
const hashStore = options?.store ?? await loadHashStore();
|
|
146
159
|
const preResolvedPath = await resolveTarget(toCwd(targetPath, cwd));
|
|
147
160
|
const served = options?.served ?? servedForPath(preResolvedPath);
|
|
@@ -154,7 +167,7 @@ export async function execPipeline(
|
|
|
154
167
|
try {
|
|
155
168
|
anchorResult = applyEdit(
|
|
156
169
|
originalNormalized,
|
|
157
|
-
|
|
170
|
+
anchoredEdit,
|
|
158
171
|
options?.signal,
|
|
159
172
|
originalHashes,
|
|
160
173
|
displayPath,
|
|
@@ -202,12 +215,15 @@ export async function execPipeline(
|
|
|
202
215
|
totalRemovedLines,
|
|
203
216
|
identity,
|
|
204
217
|
...(pipeSpans ? { spans: pipeSpans } : {}),
|
|
205
|
-
...(
|
|
218
|
+
...(anchoredEdit.content_separators !== undefined ? { contentSeparators: anchoredEdit.content_separators } : {}),
|
|
206
219
|
};
|
|
207
220
|
}
|
|
208
221
|
|
|
209
222
|
export function previewFromPipe(pipe: PipelineResult): RPreview {
|
|
210
223
|
if (pipe.originalNormalized === pipe.result) {
|
|
224
|
+
if (pipe.contentSeparators !== undefined) {
|
|
225
|
+
return { diff: "", path: pipe.path };
|
|
226
|
+
}
|
|
211
227
|
return {
|
|
212
228
|
error: `No changes made to ${pipe.path}. The edit produced identical content.`,
|
|
213
229
|
path: pipe.path,
|