pi-hashline-edit-pro 4.3.8 → 4.4.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/README.md +5 -7
- package/index.ts +1 -3
- package/package.json +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/replace-guidelines.md +0 -1
- package/src/batch.ts +6 -49
- package/src/commit.ts +1 -30
- package/src/config-ui.ts +1 -2
- package/src/config.ts +0 -24
- package/src/constants.ts +0 -2
- package/src/edit-common.ts +1 -15
- package/src/hashline/apply.ts +4 -69
- package/src/hashline/index.ts +0 -3
- package/src/hashline/resolve.ts +4 -159
- package/src/insert.ts +0 -4
- package/src/replace-diff.ts +7 -44
- package/src/replace-render.ts +0 -2
- package/src/replace-response.ts +5 -86
- package/src/replace.ts +6 -26
package/README.md
CHANGED
|
@@ -89,16 +89,16 @@ Single line: use the same anchor for `remove_from` and `remove_to`. `replace_fro
|
|
|
89
89
|
|
|
90
90
|
The request is checked before any file I/O, so a bad request never touches the file.
|
|
91
91
|
|
|
92
|
-
Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), diff-preview rows pasted into the replacement
|
|
92
|
+
Common copy-paste slips are fixed automatically: a leftover `anchor│` prefix in `replacement_lines` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`), and diff-preview rows pasted into the replacement are reported as warnings, while a reversed range, stringified array text (even with a trailing JS method call, for example `[…].map(s => s)`), and embedded newlines are corrected silently.
|
|
93
93
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to `replace`'s `replacement_lines` and `insert`'s `lines`.
|
|
94
94
|
|
|
95
95
|
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`.
|
|
96
96
|
|
|
97
|
-
An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
97
|
+
An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
98
98
|
|
|
99
99
|
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`.
|
|
100
100
|
|
|
101
|
-
Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call.
|
|
101
|
+
Multiple `replace` and `insert` calls on the same file in one assistant message are grouped per file into one batch. The batch unit is the message, not the turn: calls from separate messages in the same turn run solo, one after another. A solo edit commits before its result returns; a batch validates every call against the pre-batch state and commits once, during the batch's last call: earlier calls reply `In batch N` and the batch's last call shows the combined diff, with one undo reverting the whole batch. If the batch aborts, an earlier member's row renders the abort message instead of the placeholder. Nothing commits at turn end. A batch member accepts the same request shapes and auto-fixes as a solo call.
|
|
102
102
|
The hashline tools are sequential in pi, so a message that contains one runs all of its tool calls one at a time in the order given; a `read` or shell `cat` issued before the edit commits can still observe the pre-commit state, so verify in the next message with the post-edit diff or a fresh `read`.
|
|
103
103
|
Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. One `insert` with `direction: "before"` and one with `direction: "after"` may target the same anchor line: the pair composes into a single insertion. A batch member that fails aborts its batch-mates with `[E_OP_ABORTED]`. A call whose anchors resolve nowhere never joins a batch: it runs solo and fails with its own error (`[E_STALE_ANCHOR]`, or `[E_BAD_SHAPE]` when its request cannot be parsed), while the same-file batch in the message still commits. Calls with one stale anchor and a valid co-anchor, or with a `requirePath` path hint, join their file's batch and abort it instead of applying partially. An error that aborts a batch ends with `Aborts batch N.`; an aborted call reads `[E_OP_ABORTED] Batch N aborted: [<kind>] Call Nr <X> errored [<code>]`, naming the failing call and its error code (or `[E_OP_ABORTED] Batch N aborted.` when the failing error carries no code). Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
|
|
104
104
|
|
|
@@ -112,7 +112,7 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
|
|
|
112
112
|
| `direction` | `"after"` inserts below the anchor line, `"before"` above it. |
|
|
113
113
|
| `lines` | Lines to insert, one element per line. `[""]` is a blank line. Never include the anchor line, and never embed `\n` inside an element. A lone string is split on newlines, and stringified array text is unwrapped. |
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
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.
|
|
116
116
|
|
|
117
117
|
The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), and line endings and BOMs survive.
|
|
118
118
|
|
|
@@ -184,7 +184,7 @@ All five tools return machine-readable metadata in `details` alongside the model
|
|
|
184
184
|
|
|
185
185
|
| Command | Description |
|
|
186
186
|
| --- | --- |
|
|
187
|
-
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, strict input
|
|
187
|
+
| `/hashline-config` | Open the settings window: auto-read anchors, auto-read all mode, ignore folders/files, diff context lines, `anchor_grep` tool, required `path`, and strict input. Persists across sessions. |
|
|
188
188
|
| `/clear-anchors` | Clear the session's anchor claims. Anchors are re-claimed on the next `read`. |
|
|
189
189
|
|
|
190
190
|
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a setting is first changed in `/hashline-config`:
|
|
@@ -197,7 +197,6 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created when a se
|
|
|
197
197
|
"anchorGrepEnabled": true,
|
|
198
198
|
"requirePath": false,
|
|
199
199
|
"strictInput": false,
|
|
200
|
-
"boundaryDedupMode": "on",
|
|
201
200
|
"diffContextLines": 1
|
|
202
201
|
}
|
|
203
202
|
```
|
|
@@ -245,7 +244,6 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
245
244
|
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
|
|
246
245
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
247
246
|
| `[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. |
|
|
248
|
-
| `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
|
|
249
247
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
250
248
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
251
249
|
| `[E_STORE_UNAVAILABLE]` | No SQLite runtime could be loaded: the host exposes neither `node:sqlite` (Node 22.19+) nor `bun:sqlite`. The pi release binary's bundled Bun lacks `node:sqlite`; run pi under Node or a Bun build that ships SQLite. |
|
package/index.ts
CHANGED
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
toggleAnchorGrep,
|
|
21
21
|
toggleRequirePath,
|
|
22
22
|
toggleStrictInput,
|
|
23
|
-
cycleBoundaryDedupMode,
|
|
24
23
|
adjustDiffContextLines,
|
|
25
24
|
setAutoReadAllIgnoreFromText,
|
|
26
25
|
} from "./src/config";
|
|
@@ -126,7 +125,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
126
125
|
}));
|
|
127
126
|
|
|
128
127
|
pi.registerCommand("hashline-config", {
|
|
129
|
-
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input
|
|
128
|
+
description: "Open the hashline settings window (auto-read, auto-read all, ignore folders/files, diff context, grep, path, strict input)",
|
|
130
129
|
handler: async (_args, ctx) => {
|
|
131
130
|
if (!ctx.hasUI) {
|
|
132
131
|
ctx.ui.notify("/hashline-config requires interactive mode", "error");
|
|
@@ -149,7 +148,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
149
148
|
}
|
|
150
149
|
else if (key === "requirePath") await toggleRequirePath();
|
|
151
150
|
else if (key === "strictInput") await toggleStrictInput();
|
|
152
|
-
else await cycleBoundaryDedupMode();
|
|
153
151
|
await refreshEditTools();
|
|
154
152
|
},
|
|
155
153
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
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 +1 @@
|
|
|
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, no embedded newlines.
|
|
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, no embedded newlines. Inserted lines are written exactly as given; nothing else in the file changes. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch N` and the last call shows the combined diff, with one undo for the whole batch.
|
|
@@ -2,4 +2,3 @@
|
|
|
2
2
|
- `replace`: `replacement_lines` `[""]` is one blank line; pasted `anchor│` prefixes are stripped automatically (single line: same anchor for `remove_from` and `remove_to`).
|
|
3
3
|
- `replace`: never anchor on `-anchor│` rows of the post-edit diff, those anchors were freed by the edit. Check each batch diff before the next turn's edits on that file.
|
|
4
4
|
- `replace`: same-file same-message batches must target disjoint ranges; an overlap or member failure aborts only that file's batch unwritten. Unresolvable calls stay solo and don't abort.
|
|
5
|
-
- `replace`: if `replacement_lines` re-include the boundary line adjacent to the range, it is deduplicated automatically (not editable, never use `dedup` as an anchor).
|
package/src/batch.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
type HEdit,
|
|
17
17
|
type PlannedEdit,
|
|
18
18
|
} from "./hashline";
|
|
19
|
-
import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
|
|
20
19
|
import { adoptAnchors, servedForPath } from "./anchor-registry";
|
|
21
20
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
22
21
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
@@ -62,9 +61,6 @@ export interface BatchPiece {
|
|
|
62
61
|
toHash: string;
|
|
63
62
|
newLines: string[];
|
|
64
63
|
warnings: string[];
|
|
65
|
-
autoFixes: number;
|
|
66
|
-
dedupAbove: string[];
|
|
67
|
-
dedupBelow: string[];
|
|
68
64
|
noop: boolean;
|
|
69
65
|
foldedLines: number;
|
|
70
66
|
}
|
|
@@ -79,8 +75,6 @@ export interface BatchMemberInput {
|
|
|
79
75
|
signal?: AbortSignal;
|
|
80
76
|
hedit: HEdit;
|
|
81
77
|
extraWarnings: string[];
|
|
82
|
-
skipBoundaryDedup: boolean;
|
|
83
|
-
strictBoundaryDedup: boolean;
|
|
84
78
|
foldedLines?: number;
|
|
85
79
|
}
|
|
86
80
|
|
|
@@ -333,8 +327,7 @@ function formatBatchPiece(piece: BatchPiece): string {
|
|
|
333
327
|
}
|
|
334
328
|
|
|
335
329
|
function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId: string | undefined): TResult {
|
|
336
|
-
const
|
|
337
|
-
const added = piece.kind === "insert" ? Math.max(0, grossAdded - piece.foldedLines) : grossAdded;
|
|
330
|
+
const added = piece.kind === "insert" ? Math.max(0, piece.newLines.length - piece.foldedLines) : piece.newLines.length;
|
|
338
331
|
const metrics: RMetrics = {
|
|
339
332
|
edits_attempted: 1,
|
|
340
333
|
edits_noop: piece.noop ? 1 : 0,
|
|
@@ -486,20 +479,12 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
486
479
|
planned = planEdit(base.content, input.hedit, base.hashes, {
|
|
487
480
|
filePath: displayPath,
|
|
488
481
|
servedHashes: runtime.served,
|
|
489
|
-
skipBoundaryDedup: input.skipBoundaryDedup,
|
|
490
|
-
strictBoundaryDedup: input.strictBoundaryDedup,
|
|
491
482
|
signal: input.signal,
|
|
492
483
|
baseFileLines: base.baseLines,
|
|
493
484
|
});
|
|
494
485
|
} catch (error) {
|
|
495
486
|
if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
|
|
496
487
|
else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
|
|
497
|
-
else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
|
|
498
|
-
const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
|
|
499
|
-
noteBatchFailure(input.member, indexed);
|
|
500
|
-
discardBatchState(runtime);
|
|
501
|
-
throw indexed;
|
|
502
|
-
}
|
|
503
488
|
noteBatchFailure(input.member, error);
|
|
504
489
|
discardBatchState(runtime);
|
|
505
490
|
throw error;
|
|
@@ -510,8 +495,6 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
510
495
|
const baseLines = base.baseLines;
|
|
511
496
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
512
497
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
513
|
-
const fixes = planned.autoFixes ?? [];
|
|
514
|
-
const dedupRows = dedupRowsFromFixes(fixes);
|
|
515
498
|
const piece: BatchPiece = {
|
|
516
499
|
order: input.member.order,
|
|
517
500
|
kind: input.kind,
|
|
@@ -522,9 +505,6 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
522
505
|
toHash: input.hedit.hash_bounds[1].hash,
|
|
523
506
|
newLines: [...newLines],
|
|
524
507
|
warnings: [...input.extraWarnings, ...planned.warnings],
|
|
525
|
-
autoFixes: fixes.length,
|
|
526
|
-
dedupAbove: dedupRows.above,
|
|
527
|
-
dedupBelow: dedupRows.below,
|
|
528
508
|
noop,
|
|
529
509
|
foldedLines: input.foldedLines ?? 0,
|
|
530
510
|
};
|
|
@@ -585,23 +565,12 @@ function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
|
|
|
585
565
|
order: Math.min(before.order, after.order),
|
|
586
566
|
newLines: [...before.newLines, ...after.newLines.slice(1)],
|
|
587
567
|
warnings: [...before.warnings, ...after.warnings],
|
|
588
|
-
autoFixes: before.autoFixes + after.autoFixes,
|
|
589
|
-
dedupAbove: [...before.dedupAbove, ...after.dedupAbove],
|
|
590
|
-
dedupBelow: [...before.dedupBelow, ...after.dedupBelow],
|
|
591
568
|
foldedLines: before.foldedLines + after.foldedLines - 1,
|
|
592
569
|
});
|
|
593
570
|
}
|
|
594
571
|
return merged;
|
|
595
572
|
}
|
|
596
573
|
|
|
597
|
-
function dedupCutNoops(pieces: BatchPiece[]): BatchPiece[] {
|
|
598
|
-
return pieces.filter((piece) => piece.noop && piece.autoFixes > 0).sort((a, b) => a.order - b.order);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
function dedupCutNoopWarning(noops: BatchPiece[]): string {
|
|
602
|
-
return boundaryDedupNoopWarning(noops.map((piece) => piece.order), noops.reduce((sum, piece) => sum + piece.autoFixes, 0));
|
|
603
|
-
}
|
|
604
|
-
|
|
605
574
|
async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
|
|
606
575
|
const runtime = batches.get(member.batchKey);
|
|
607
576
|
if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
@@ -624,9 +593,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
624
593
|
}
|
|
625
594
|
const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
|
|
626
595
|
if (appliedPieces.length === 0) {
|
|
627
|
-
const dedupNoops = dedupCutNoops(runtime.pieces);
|
|
628
596
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
629
|
-
return combinedNoop(paths.displayPath, member, runtime, snapshotId
|
|
597
|
+
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
630
598
|
}
|
|
631
599
|
const effectivePieces = mergeInsertPairs(appliedPieces);
|
|
632
600
|
const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
|
|
@@ -641,10 +609,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
641
609
|
const composed = composeBatchLines(base.content, effectivePieces);
|
|
642
610
|
const warnings = [...runtime.warnings];
|
|
643
611
|
if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
644
|
-
const dedupTotal = effectivePieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
|
|
645
|
-
if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
|
|
646
|
-
const dedupNoops = dedupCutNoops(runtime.pieces);
|
|
647
|
-
if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
|
|
648
612
|
try {
|
|
649
613
|
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
650
614
|
assertNotEmpty(base.content, composed);
|
|
@@ -658,7 +622,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
658
622
|
}
|
|
659
623
|
if (composed === base.content) {
|
|
660
624
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
661
|
-
return combinedNoop(paths.displayPath, member, runtime, snapshotId
|
|
625
|
+
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
662
626
|
}
|
|
663
627
|
abortIf(signal);
|
|
664
628
|
let currentRaw: string;
|
|
@@ -706,7 +670,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
706
670
|
throw error;
|
|
707
671
|
}
|
|
708
672
|
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
709
|
-
const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length
|
|
673
|
+
const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
|
|
710
674
|
let resultHashes: string[];
|
|
711
675
|
try {
|
|
712
676
|
resultHashes = await lineHashes(composed, runtime.target, {
|
|
@@ -723,11 +687,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
723
687
|
let removed = 0;
|
|
724
688
|
for (const piece of appliedPieces) {
|
|
725
689
|
removed += piece.end - piece.start + 1;
|
|
726
|
-
|
|
727
|
-
added += piece.kind === "insert" ? Math.max(0, gross - piece.foldedLines) : gross;
|
|
690
|
+
added += piece.kind === "insert" ? Math.max(0, piece.newLines.length - piece.foldedLines) : piece.newLines.length;
|
|
728
691
|
}
|
|
729
|
-
const dedupAboveRows = ordered.flatMap((piece) => piece.dedupAbove);
|
|
730
|
-
const dedupBelowRows = ordered.flatMap((piece) => piece.dedupBelow);
|
|
731
692
|
const header = batchHeader(member);
|
|
732
693
|
const changed = buildChanged(
|
|
733
694
|
{
|
|
@@ -746,8 +707,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
746
707
|
addedLines: added,
|
|
747
708
|
removedLines: removed,
|
|
748
709
|
},
|
|
749
|
-
boundaryDedupAbove: dedupAboveRows,
|
|
750
|
-
boundaryDedupBelow: dedupBelowRows,
|
|
751
710
|
spans,
|
|
752
711
|
},
|
|
753
712
|
batchVerb(runtime),
|
|
@@ -766,10 +725,9 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
766
725
|
return changed;
|
|
767
726
|
}
|
|
768
727
|
|
|
769
|
-
async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined
|
|
728
|
+
async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
|
|
770
729
|
const executed = runtime.applied + runtime.noops;
|
|
771
730
|
const warnings = [...runtime.warnings];
|
|
772
|
-
if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
|
|
773
731
|
const noop = buildNoop(
|
|
774
732
|
{
|
|
775
733
|
path,
|
|
@@ -782,7 +740,6 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
|
|
|
782
740
|
removedLines: 0,
|
|
783
741
|
},
|
|
784
742
|
warnings: dedupeWarnings(warnings),
|
|
785
|
-
boundaryRemovedLines: 0,
|
|
786
743
|
},
|
|
787
744
|
"Batch",
|
|
788
745
|
);
|
package/src/commit.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import type { PipelineResult } from "./replace";
|
|
3
3
|
import { abortIf, assertByteLimit, errCode, splitLines } from "./utils";
|
|
4
|
-
import { DEDUP_ANCHOR } from "./constants";
|
|
5
|
-
import { HASH_SEP } from "./hashline";
|
|
6
4
|
import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
|
|
7
5
|
import { saveUndo } from "./replace-undo";
|
|
8
6
|
import { getDiffContextLines } from "./config";
|
|
9
7
|
import { safeSnapId } from "./file-reader";
|
|
10
8
|
import { writeAtomic } from "./fs-write";
|
|
11
9
|
import { servedHashesFromDiff, serveRows } from "./served";
|
|
12
|
-
import { lineHashes
|
|
10
|
+
import { lineHashes } from "./hashline";
|
|
13
11
|
import { spanForEdit } from "./replace";
|
|
14
12
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
15
13
|
export interface CommitMeta {
|
|
@@ -24,27 +22,6 @@ export interface CommitMeta {
|
|
|
24
22
|
foldedAnchorLines?: number;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
export function boundaryDedupWarning(count: number): string {
|
|
28
|
-
const noun = count === 1 ? "1 line" : `${count} lines`;
|
|
29
|
-
const row = count === 1 ? "row" : "rows";
|
|
30
|
-
return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function boundaryDedupNoopWarning(orders: number[], removedLines: number): string {
|
|
34
|
-
const noun = removedLines === 1 ? "1 line" : `${removedLines} lines`;
|
|
35
|
-
if (orders.length === 1) {
|
|
36
|
-
return `Boundary dedup: edit #${orders[0]!} produced no change (${noun} not added again).`;
|
|
37
|
-
}
|
|
38
|
-
return `Boundary dedup: edits ${orders.map((order) => `#${order}`).join(", ")} produced no change (${noun} not added again).`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function dedupRowsFromFixes(fixes: AutoFix[]): { removedTexts: string[]; above: string[]; below: string[] } {
|
|
42
|
-
const sorted = [...fixes].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
|
|
43
|
-
const above = sorted.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before").map((fix) => fix.removedLine);
|
|
44
|
-
const below = sorted.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after").map((fix) => fix.removedLine);
|
|
45
|
-
return { removedTexts: sorted.map((fix) => fix.removedLine), above, below };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
25
|
export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
|
|
49
26
|
const { path, absolutePath, mutationTargetPath, signal } = meta;
|
|
50
27
|
const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
|
|
@@ -64,7 +41,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
64
41
|
removedLines: 0,
|
|
65
42
|
},
|
|
66
43
|
warnings,
|
|
67
|
-
boundaryRemovedLines: pipe.boundaryRemovedLines,
|
|
68
44
|
},
|
|
69
45
|
meta.noopNoun,
|
|
70
46
|
);
|
|
@@ -78,9 +54,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
78
54
|
"Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
|
|
79
55
|
);
|
|
80
56
|
}
|
|
81
|
-
if (pipe.boundaryRemovedLineTexts.length > 0) {
|
|
82
|
-
warnings.push(boundaryDedupWarning(pipe.boundaryRemovedLineTexts.length));
|
|
83
|
-
}
|
|
84
57
|
|
|
85
58
|
abortIf(signal);
|
|
86
59
|
let currentRaw: string | undefined;
|
|
@@ -154,8 +127,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
154
127
|
warnings,
|
|
155
128
|
snapshotId: updatedSnapshotId,
|
|
156
129
|
editMeta,
|
|
157
|
-
boundaryDedupAbove: pipe.boundaryDedupAbove,
|
|
158
|
-
boundaryDedupBelow: pipe.boundaryDedupBelow,
|
|
159
130
|
...(span ? { spans: [span] } : {}),
|
|
160
131
|
};
|
|
161
132
|
const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
|
package/src/config-ui.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readConfig, type Config } from "./config";
|
|
4
4
|
|
|
5
|
-
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -25,7 +25,6 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
25
25
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
26
26
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|
|
27
27
|
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
28
|
-
{ key: "boundaryDedupMode", label: "Boundary dedup", hint: "Strip edge lines: on, strict (reject), off (literal)", enabled: (config.boundaryDedupMode ?? "on") !== "off", mode: config.boundaryDedupMode ?? "on", cycle: ["on", "strict", "off"] },
|
|
29
28
|
];
|
|
30
29
|
}
|
|
31
30
|
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { configPath } from "./paths";
|
|
4
4
|
import { errCode, isRec } from "./utils";
|
|
5
5
|
import { writeAtomic } from "./fs-write";
|
|
6
|
-
export type BoundaryDedupMode = "on" | "off" | "strict";
|
|
7
6
|
export type AutoReadAllMode = "off" | "on" | "git";
|
|
8
7
|
const AUTO_READ_ALL_MODES: AutoReadAllMode[] = ["off", "on", "git"];
|
|
9
8
|
|
|
@@ -18,7 +17,6 @@ export interface Config {
|
|
|
18
17
|
autoReadAllIgnore?: string[];
|
|
19
18
|
requirePath?: boolean;
|
|
20
19
|
strictInput?: boolean;
|
|
21
|
-
boundaryDedupMode?: BoundaryDedupMode;
|
|
22
20
|
diffContextLines?: number;
|
|
23
21
|
}
|
|
24
22
|
|
|
@@ -29,19 +27,9 @@ const DEFAULT_CONFIG: Config = {
|
|
|
29
27
|
autoReadAllIgnore: [],
|
|
30
28
|
requirePath: false,
|
|
31
29
|
strictInput: false,
|
|
32
|
-
boundaryDedupMode: "on",
|
|
33
30
|
diffContextLines: DEFAULT_DIFF_CONTEXT_LINES
|
|
34
31
|
};
|
|
35
32
|
|
|
36
|
-
const BOUNDARY_DEDUP_MODES: BoundaryDedupMode[] = ["on", "strict", "off"];
|
|
37
|
-
|
|
38
|
-
function parseBoundaryDedupMode(mode: unknown, legacy: unknown): BoundaryDedupMode {
|
|
39
|
-
if (mode === "on" || mode === "strict" || mode === "off") return mode;
|
|
40
|
-
if (legacy === true) return "on";
|
|
41
|
-
if (legacy === false) return "off";
|
|
42
|
-
return DEFAULT_CONFIG.boundaryDedupMode ?? "on";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
33
|
function parseAutoReadAllMode(value: unknown): AutoReadAllMode {
|
|
46
34
|
if (value === "off" || value === "on" || value === "git") return value;
|
|
47
35
|
if (value === true) return "on";
|
|
@@ -87,8 +75,6 @@ function parseConfig(content: string): Config {
|
|
|
87
75
|
const autoReadAll = parsed.autoReadAll;
|
|
88
76
|
const requirePath = parsed.requirePath;
|
|
89
77
|
const strictInput = parsed.strictInput;
|
|
90
|
-
const boundaryDedupMode = parsed.boundaryDedupMode;
|
|
91
|
-
const legacyBoundaryDedup = parsed.boundaryDedupEnabled;
|
|
92
78
|
const diffContextLines = parsed.diffContextLines;
|
|
93
79
|
const autoReadAllIgnore = parsed.autoReadAllIgnore;
|
|
94
80
|
return {
|
|
@@ -97,7 +83,6 @@ function parseConfig(content: string): Config {
|
|
|
97
83
|
autoReadAll: parseAutoReadAllMode(autoReadAll),
|
|
98
84
|
requirePath: typeof requirePath === "boolean" ? requirePath : DEFAULT_CONFIG.requirePath,
|
|
99
85
|
strictInput: typeof strictInput === "boolean" ? strictInput : DEFAULT_CONFIG.strictInput,
|
|
100
|
-
boundaryDedupMode: parseBoundaryDedupMode(boundaryDedupMode, legacyBoundaryDedup),
|
|
101
86
|
diffContextLines: normalizeDiffContextLines(diffContextLines),
|
|
102
87
|
autoReadAllIgnore: parseAutoReadAllIgnore(autoReadAllIgnore),
|
|
103
88
|
};
|
|
@@ -226,15 +211,6 @@ export async function cycleAutoReadAllMode(): Promise<AutoReadAllMode> {
|
|
|
226
211
|
}
|
|
227
212
|
export const toggleRequirePath = (): Promise<boolean> => toggleFlag("requirePath");
|
|
228
213
|
export const toggleStrictInput = (): Promise<boolean> => toggleFlag("strictInput");
|
|
229
|
-
export async function cycleBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
230
|
-
let next: BoundaryDedupMode = "on";
|
|
231
|
-
await updateConfig((c) => {
|
|
232
|
-
const current = c.boundaryDedupMode ?? "on";
|
|
233
|
-
next = BOUNDARY_DEDUP_MODES[(BOUNDARY_DEDUP_MODES.indexOf(current) + 1) % BOUNDARY_DEDUP_MODES.length] ?? "on";
|
|
234
|
-
c.boundaryDedupMode = next;
|
|
235
|
-
});
|
|
236
|
-
return next;
|
|
237
|
-
}
|
|
238
214
|
export async function getDiffContextLines(): Promise<number> {
|
|
239
215
|
return normalizeDiffContextLines((await readConfig()).diffContextLines);
|
|
240
216
|
}
|
package/src/constants.ts
CHANGED
|
@@ -19,8 +19,6 @@ export const NUL_CONTENT_MSG =
|
|
|
19
19
|
export const ANCHOR_POOL_EXHAUSTED_PREFIX =
|
|
20
20
|
"[E_FILE_TOO_LARGE] The session's anchor pool is exhausted";
|
|
21
21
|
|
|
22
|
-
export const DEDUP_ANCHOR = "dedup";
|
|
23
|
-
|
|
24
22
|
export const AUTO_READ_ALL_CUSTOM_TYPE = "hashline-auto-read-all";
|
|
25
23
|
export const AUTO_READ_ALL_MAX_FILES = 500;
|
|
26
24
|
export const AUTO_READ_ALL_MAX_FILE_BYTES = 200_000;
|
package/src/edit-common.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { resolveInCwd } from "./fs-write";
|
|
|
3
3
|
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
4
|
import { ownerOf } from "./anchor-registry";
|
|
5
5
|
import { parseHashRef, stripAnchorRow } from "./hashline";
|
|
6
|
-
import { readConfig
|
|
6
|
+
import { readConfig } from "./config";
|
|
7
7
|
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
8
8
|
import type { ReplaceDetails } from "./replace";
|
|
9
9
|
export const editPrepare = makePrepareArguments();
|
|
@@ -11,7 +11,6 @@ export const editPrepare = makePrepareArguments();
|
|
|
11
11
|
export interface EditToolFlags {
|
|
12
12
|
requirePath: boolean;
|
|
13
13
|
strictInput: boolean;
|
|
14
|
-
boundaryDedupMode: BoundaryDedupMode;
|
|
15
14
|
autoRead: boolean;
|
|
16
15
|
autoReadAllActive: boolean;
|
|
17
16
|
}
|
|
@@ -19,7 +18,6 @@ export interface EditToolFlags {
|
|
|
19
18
|
export const DEFAULT_EDIT_FLAGS: EditToolFlags = {
|
|
20
19
|
requirePath: false,
|
|
21
20
|
strictInput: false,
|
|
22
|
-
boundaryDedupMode: "on",
|
|
23
21
|
autoRead: true,
|
|
24
22
|
autoReadAllActive: false
|
|
25
23
|
};
|
|
@@ -29,7 +27,6 @@ export async function currentEditFlags(): Promise<EditToolFlags> {
|
|
|
29
27
|
return {
|
|
30
28
|
requirePath: config.requirePath === true,
|
|
31
29
|
strictInput: config.strictInput === true,
|
|
32
|
-
boundaryDedupMode: config.boundaryDedupMode ?? "on",
|
|
33
30
|
autoRead: config.autoRead !== false,
|
|
34
31
|
autoReadAllActive: (config.autoReadAll ?? "off") !== "off"
|
|
35
32
|
};
|
|
@@ -57,13 +54,6 @@ export function withReplacePrompts(base: { description: string; snippet: string;
|
|
|
57
54
|
descriptionParts.push("Strict-input mode is on: auto-fixable slips are rejected instead of fixed with warnings.");
|
|
58
55
|
guidelines.push("`replace`: strict-input is on: auto-fixable slips are rejected instead of fixed.");
|
|
59
56
|
}
|
|
60
|
-
if (flags.boundaryDedupMode === "off") {
|
|
61
|
-
descriptionParts.push("Boundary dedup is off: edits apply literally.");
|
|
62
|
-
guidelines.push("`replace`: boundary dedup is off: edits apply literally.");
|
|
63
|
-
} else if (flags.boundaryDedupMode === "strict") {
|
|
64
|
-
descriptionParts.push("Boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
|
|
65
|
-
guidelines.push("`replace`: boundary dedup is strict: edits that re-include edge lines are rejected instead of stripped.");
|
|
66
|
-
}
|
|
67
57
|
return { description: descriptionParts.join(" "), snippet: snippetParts.join(""), guidelines };
|
|
68
58
|
}
|
|
69
59
|
|
|
@@ -169,10 +159,6 @@ export async function throwIfStrictInput(warnings: string[]): Promise<void> {
|
|
|
169
159
|
}
|
|
170
160
|
}
|
|
171
161
|
|
|
172
|
-
export async function getBoundaryDedupMode(): Promise<BoundaryDedupMode> {
|
|
173
|
-
return (await readConfig()).boundaryDedupMode ?? "on";
|
|
174
|
-
}
|
|
175
|
-
|
|
176
162
|
export function editRenderCallWrapper(
|
|
177
163
|
preview: (args: unknown, cwd: string, signal?: AbortSignal) => Promise<RPreview>,
|
|
178
164
|
getInput?: (args: unknown) => { path?: string } | null,
|