pi-hashline-edit-pro 4.2.10 → 4.2.11
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 -8
- package/index.ts +0 -2
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/replace.md +1 -1
- package/src/batch.ts +169 -81
- package/src/commit.ts +16 -7
- package/src/insert.ts +2 -4
- package/src/replace-diff.ts +32 -7
- package/src/replace-render.ts +37 -4
- package/src/replace-response.ts +6 -11
- package/src/replace-undo.ts +1 -1
- package/src/replace.ts +22 -49
- package/src/boundary-bypass.ts +0 -51
package/README.md
CHANGED
|
@@ -94,13 +94,13 @@ Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before
|
|
|
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. When a noop happened because the boundary anti-duplication cut a line from the replacement,
|
|
97
|
+
An edit that produces identical content reports `No changes made` and leaves the anchors alone. When a noop happened because the boundary anti-duplication cut a line from the replacement, the result states how many lines were removed; sending the same replacement again goes through the same dedup. To apply such lines literally, turn Boundary dedup off in `/hashline-config`.
|
|
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
|
|
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. A member whose boundary dedup cuts it to a noop contributes no hunk to the combined diff: the batch warning names it instead of pointing at `dedup│` rows. The `dedup│` rows in a batch diff cover its applied members only.
|
|
102
102
|
Tools in one message run concurrently, so a `read` or shell `cat` issued alongside an edit can observe the pre-commit state; verify in the next message with the post-edit diff or a fresh `read`.
|
|
103
|
-
Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch unwritten. A
|
|
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
|
|
|
105
105
|
### insert
|
|
106
106
|
|
|
@@ -114,7 +114,7 @@ Batched calls must target disjoint ranges; overlapping ranges, or any failing ca
|
|
|
114
114
|
|
|
115
115
|
Lines are applied literally: nothing is removed, and a line that duplicates its neighbor is kept. `replace`'s boundary anti-duplication never runs for `insert`. Inserting nothing (`lines: []`) reports a noop. To seed an empty file, read it and insert after the `anchor│` empty-line row.
|
|
116
116
|
|
|
117
|
-
The same safety machinery as `replace` applies: undo is saved before the write (a failed write restores the previous undo record), line endings and BOMs survive
|
|
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
|
|
|
119
119
|
### anchor_grep
|
|
120
120
|
|
|
@@ -154,7 +154,7 @@ Output is capped at `limit` matched lines, 2000 rows, and 50KB of text, whicheve
|
|
|
154
154
|
|
|
155
155
|
Auto-read is enabled by default. After a successful `write`, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block, so you get fresh `anchor│content` anchors without a separate `read` call.
|
|
156
156
|
|
|
157
|
-
After `replace`, `insert`, 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
|
|
157
|
+
After `replace`, `insert`, 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.
|
|
158
158
|
|
|
159
159
|
Auto-read keeps the same 50KB and 2000-line budget as `read`. Change it in `/hashline-config`; both settings 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.
|
|
160
160
|
|
|
@@ -233,15 +233,14 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
|
|
|
233
233
|
| `[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. |
|
|
234
234
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
235
235
|
| `[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
|
-
| `[W_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
236
|
| `[E_BOUNDARY_STRICT]` | Strict boundary dedup rejected the edit because replacement lines re-include edge lines; resend without those lines. |
|
|
238
237
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 1,353,139-line hashline limit or the 100MB size limit. |
|
|
239
238
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
240
239
|
| `[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. |
|
|
241
240
|
| `[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
241
|
| `[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_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
|
|
244
|
-
| `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. |
|
|
242
|
+
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. One `before` plus one `after` insert on the same anchor line is not an overlap. Retry with disjoint ranges. |
|
|
243
|
+
| `[E_OP_ABORTED]` | An edit aborted (a same-message batch member failed, or the file changed or was deleted after the edit started). Nothing was written. Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. The abort names the failing call and its error code when one is known. |
|
|
245
244
|
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
246
245
|
|
|
247
246
|
## Troubleshooting
|
package/index.ts
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
import { loadHashStore, persistSnapshot, pruneMissing } from "./src/hash-store";
|
|
23
23
|
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, markServed as markServedScoped, sessionKeyFor, withAnchorSession, releaseRegistrySession } from "./src/anchor-registry";
|
|
24
24
|
import { buildServedMap } from "./src/served";
|
|
25
|
-
import { clearBoundaryBypass } from "./src/boundary-bypass";
|
|
26
25
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
27
26
|
import { currentEditFlags } from "./src/edit-common";
|
|
28
27
|
import { HashlineConfigOverlay } from "./src/config-ui";
|
|
@@ -167,7 +166,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
167
166
|
resolvedPath = (await resolveInCwd(writtenPath, ctx.cwd)).resolved;
|
|
168
167
|
freeAnchors(resolvedPath);
|
|
169
168
|
await clearUndo(resolvedPath);
|
|
170
|
-
clearBoundaryBypass(resolvedPath);
|
|
171
169
|
} catch (error) {
|
|
172
170
|
console.error("Failed to clear undo after write:", error);
|
|
173
171
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.11",
|
|
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",
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
- `insert`: the anchor must have been shown by `read`, a post-edit diff (`+anchor│`/` anchor│`), or any served `anchor│content` row. Empty file: `read` shows one `anchor│` row — insert `after` it.
|
|
2
|
-
- `insert`: same-file calls in one message join the file's batch: earlier calls reply `In batch
|
|
2
|
+
- `insert`: same-file calls in one message join the file's batch: earlier calls reply `In batch N`, the last call shows the combined diff.
|
|
3
|
+
- `insert`: a batch may pair one `before` and one `after` on the same anchor line; the pair composes into a single insertion. Any other same-line pair is an overlap.
|
package/prompts/replace.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from read output. 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 and no embedded newlines; `[]` deletes the range. Every line in the range must match what was last shown; if the file drifted, the edit is refused and the current range is returned with fresh anchors, so retry without re-reading. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. Multiple `replace`/`insert` calls on the same file in one message form one batch per file: earlier calls reply `In batch
|
|
1
|
+
Replace a range of lines (or a single line) in a text file, targeted by 4-character anchors from read output. 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 and no embedded newlines; `[]` deletes the range. Every line in the range must match what was last shown; if the file drifted, the edit is refused and the current range is returned with fresh anchors, so retry without re-reading. Anchor follow-up edits on the `+anchor│` and ` anchor│` rows of the post-edit diff instead of re-reading. 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.
|
package/src/batch.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { relative } from "node:path";
|
|
4
4
|
import { readConfig, getDiffContextLines } from "./config";
|
|
5
|
-
import {
|
|
5
|
+
import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
|
|
6
6
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
7
7
|
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
8
8
|
import {
|
|
@@ -16,8 +16,7 @@ import {
|
|
|
16
16
|
type HEdit,
|
|
17
17
|
type PlannedEdit,
|
|
18
18
|
} from "./hashline";
|
|
19
|
-
import {
|
|
20
|
-
import { boundaryDedupWarning } from "./commit";
|
|
19
|
+
import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
|
|
21
20
|
import { adoptAnchors, markServed, servedForPath } from "./anchor-registry";
|
|
22
21
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
23
22
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
@@ -56,6 +55,7 @@ interface BatchBase {
|
|
|
56
55
|
export interface BatchPiece {
|
|
57
56
|
order: number;
|
|
58
57
|
kind: BatchKind;
|
|
58
|
+
direction?: "before" | "after";
|
|
59
59
|
start: number;
|
|
60
60
|
end: number;
|
|
61
61
|
fromHash: string;
|
|
@@ -63,14 +63,15 @@ export interface BatchPiece {
|
|
|
63
63
|
newLines: string[];
|
|
64
64
|
warnings: string[];
|
|
65
65
|
autoFixes: number;
|
|
66
|
+
dedupAbove: string[];
|
|
67
|
+
dedupBelow: string[];
|
|
66
68
|
noop: boolean;
|
|
67
|
-
noopPayload?: string;
|
|
68
69
|
foldedLines: number;
|
|
69
|
-
bypassConsumed?: boolean;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export interface BatchMemberInput {
|
|
73
73
|
kind: BatchKind;
|
|
74
|
+
direction?: "before" | "after";
|
|
74
75
|
member: PlannedMember;
|
|
75
76
|
targetPath: string;
|
|
76
77
|
mutationTargetPath: string;
|
|
@@ -80,9 +81,13 @@ export interface BatchMemberInput {
|
|
|
80
81
|
extraWarnings: string[];
|
|
81
82
|
skipBoundaryDedup: boolean;
|
|
82
83
|
strictBoundaryDedup: boolean;
|
|
83
|
-
noopPayload?: string;
|
|
84
84
|
foldedLines?: number;
|
|
85
|
-
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BatchFailure {
|
|
88
|
+
kind: BatchKind;
|
|
89
|
+
order: number;
|
|
90
|
+
code?: string;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
interface BatchState {
|
|
@@ -100,7 +105,7 @@ interface BatchState {
|
|
|
100
105
|
failures: number;
|
|
101
106
|
failed: boolean;
|
|
102
107
|
firstError?: unknown;
|
|
103
|
-
|
|
108
|
+
failure?: BatchFailure;
|
|
104
109
|
warnings: string[];
|
|
105
110
|
}
|
|
106
111
|
|
|
@@ -119,6 +124,30 @@ const MAX_TRACKED_BATCHES = 256;
|
|
|
119
124
|
const plan = new Map<string, PlannedMember>();
|
|
120
125
|
const batches = new Map<number, BatchState>();
|
|
121
126
|
let nextBatchKey = 1;
|
|
127
|
+
const abortedMembers = new Map<string, { display: number; message: string }>();
|
|
128
|
+
const ABORTED_MEMBERS_LIMIT = 1024;
|
|
129
|
+
|
|
130
|
+
function markBatchMembersAborted(runtime: BatchState): void {
|
|
131
|
+
const message = abortedBatchMessage(runtime);
|
|
132
|
+
for (const id of runtime.memberIds) {
|
|
133
|
+
abortedMembers.delete(id);
|
|
134
|
+
abortedMembers.set(id, { display: runtime.display, message });
|
|
135
|
+
}
|
|
136
|
+
while (abortedMembers.size > ABORTED_MEMBERS_LIMIT) {
|
|
137
|
+
const oldest = abortedMembers.keys().next().value;
|
|
138
|
+
if (oldest === undefined) break;
|
|
139
|
+
abortedMembers.delete(oldest);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function abortedBatchMessageFor(toolCallId: string): string | undefined {
|
|
144
|
+
const marked = abortedMembers.get(toolCallId);
|
|
145
|
+
if (marked !== undefined) return marked.message;
|
|
146
|
+
const member = plan.get(toolCallId);
|
|
147
|
+
if (!member) return undefined;
|
|
148
|
+
const runtime = batches.get(member.batchKey);
|
|
149
|
+
return runtime?.failed ? abortedBatchMessage(runtime) : undefined;
|
|
150
|
+
}
|
|
122
151
|
|
|
123
152
|
export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
|
|
124
153
|
return plan.get(toolCallId);
|
|
@@ -127,6 +156,7 @@ export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
|
|
|
127
156
|
export function resetBatchStateForTests(): void {
|
|
128
157
|
plan.clear();
|
|
129
158
|
batches.clear();
|
|
159
|
+
abortedMembers.clear();
|
|
130
160
|
nextBatchKey = 1;
|
|
131
161
|
}
|
|
132
162
|
|
|
@@ -154,18 +184,6 @@ function anchorTargetFor(args: unknown): string | undefined {
|
|
|
154
184
|
if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
|
|
155
185
|
return tryResolveEditTarget(normalized.anchor);
|
|
156
186
|
}
|
|
157
|
-
function unresolvedErrorFor(call: EditCall): Error {
|
|
158
|
-
const normalized = normalizeEditArgs(call.args);
|
|
159
|
-
if (!normalized) return new Error(`sibling invalid`);
|
|
160
|
-
const refs = normalized.kind === "replace" ? [normalized.removeFrom, normalized.removeTo].filter((ref): ref is string => typeof ref === "string").join("→") : normalized.anchor;
|
|
161
|
-
try {
|
|
162
|
-
if (normalized.kind === "replace") resolveEditTarget(normalized.removeFrom, normalized.removeTo);
|
|
163
|
-
else resolveEditTarget(normalized.anchor);
|
|
164
|
-
} catch {
|
|
165
|
-
return new Error(`sibling stale (${refs})`);
|
|
166
|
-
}
|
|
167
|
-
return new Error(`sibling stale (${refs})`);
|
|
168
|
-
}
|
|
169
187
|
async function inferredTargetFor(args: unknown, cwd: string, requirePath: boolean): Promise<string | undefined> {
|
|
170
188
|
const normalized = normalizeEditArgs(args);
|
|
171
189
|
if (!normalized) return undefined;
|
|
@@ -245,22 +263,10 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
245
263
|
} else {
|
|
246
264
|
finalGroups.push(...multi);
|
|
247
265
|
}
|
|
248
|
-
const resolvedIds = new Set(resolved.map((item) => item.id));
|
|
249
|
-
const unplanned = calls.filter((call) => !resolvedIds.has(call.id));
|
|
250
|
-
let poison: { target: string; error: unknown; callId: string } | undefined;
|
|
251
|
-
if (unplanned.length > 0 && groups.size === 1) {
|
|
252
|
-
const sole = [...groups.values()][0]!;
|
|
253
|
-
const poisonTarget = sole[0]!.target;
|
|
254
|
-
const poisonCall = unplanned[0]!;
|
|
255
|
-
poison = { target: poisonTarget, error: unresolvedErrorFor(poisonCall), callId: poisonCall.id };
|
|
256
|
-
if (!finalGroups.some((group) => group[0]!.target === poisonTarget)) finalGroups.push(sole);
|
|
257
|
-
}
|
|
258
266
|
let display = 0;
|
|
259
267
|
for (const group of finalGroups) {
|
|
260
268
|
display += 1;
|
|
261
269
|
const key = nextBatchKey++;
|
|
262
|
-
const matchingPoison = poison !== undefined && group[0]!.target === poison.target ? poison : undefined;
|
|
263
|
-
const poisoned = matchingPoison !== undefined;
|
|
264
270
|
batches.set(key, {
|
|
265
271
|
display,
|
|
266
272
|
target: group[0]!.target,
|
|
@@ -270,9 +276,8 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
270
276
|
pieces: [],
|
|
271
277
|
applied: 0,
|
|
272
278
|
noops: 0,
|
|
273
|
-
failures:
|
|
274
|
-
failed:
|
|
275
|
-
...(matchingPoison ? { firstError: matchingPoison.error, poisonedBy: matchingPoison.callId } : {}),
|
|
279
|
+
failures: 0,
|
|
280
|
+
failed: false,
|
|
276
281
|
warnings: [],
|
|
277
282
|
});
|
|
278
283
|
group.forEach((item, index) => {
|
|
@@ -309,7 +314,7 @@ function batchVerb(runtime: BatchState): string {
|
|
|
309
314
|
return "replaced";
|
|
310
315
|
}
|
|
311
316
|
function batchHeader(member: PlannedMember): string {
|
|
312
|
-
return
|
|
317
|
+
return `batch ${member.display}:`;
|
|
313
318
|
}
|
|
314
319
|
|
|
315
320
|
function formatBatchLines(start: number, end: number): string {
|
|
@@ -337,7 +342,7 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
337
342
|
content: [
|
|
338
343
|
{
|
|
339
344
|
type: "text",
|
|
340
|
-
text:
|
|
345
|
+
text: `In batch ${member.display}`,
|
|
341
346
|
},
|
|
342
347
|
],
|
|
343
348
|
details: {
|
|
@@ -357,12 +362,15 @@ export function withAbortSuffix(message: string, display: number): string {
|
|
|
357
362
|
if (message.includes(suffix)) return message;
|
|
358
363
|
return message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
|
|
359
364
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
+
|
|
366
|
+
const ERROR_CODE_RE = /\[(E_[A-Z0-9_]+)\]/;
|
|
367
|
+
|
|
368
|
+
function errorCodeOf(error: unknown): string | undefined {
|
|
369
|
+
if (!(error instanceof Error)) return undefined;
|
|
370
|
+
const code = ERROR_CODE_RE.exec(error.message)?.[1];
|
|
371
|
+
return code === "E_OP_ABORTED" ? undefined : code;
|
|
365
372
|
}
|
|
373
|
+
|
|
366
374
|
export function noteBatchFailure(member: PlannedMember, error: unknown): void {
|
|
367
375
|
const runtime = batches.get(member.batchKey);
|
|
368
376
|
if (!runtime) return;
|
|
@@ -371,18 +379,39 @@ export function noteBatchFailure(member: PlannedMember, error: unknown): void {
|
|
|
371
379
|
if (!runtime.failed) {
|
|
372
380
|
runtime.failed = true;
|
|
373
381
|
runtime.firstError = error;
|
|
382
|
+
const code = errorCodeOf(error);
|
|
383
|
+
runtime.failure = { kind: member.kind, order: member.order, ...(code !== undefined ? { code } : {}) };
|
|
374
384
|
}
|
|
385
|
+
markBatchMembersAborted(runtime);
|
|
375
386
|
}
|
|
376
387
|
|
|
377
|
-
function
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
function firstFailureCause(runtime: BatchState): string | undefined {
|
|
389
|
+
const error = runtime.firstError;
|
|
390
|
+
if (!(error instanceof Error)) return undefined;
|
|
391
|
+
const suffix = ` Aborts batch ${runtime.display}.`;
|
|
392
|
+
const message = error.message.endsWith(suffix) ? error.message.slice(0, -suffix.length) : error.message;
|
|
393
|
+
const firstLine = message.split("\n")[0]?.trim() ?? "";
|
|
394
|
+
if (firstLine.length === 0) return undefined;
|
|
395
|
+
if (!firstLine.endsWith(":")) return firstLine;
|
|
396
|
+
const sentenceEnd = firstLine.lastIndexOf(". ");
|
|
397
|
+
return sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine.slice(0, -1);
|
|
380
398
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
399
|
+
|
|
400
|
+
function abortedBatchMessage(runtime: BatchState): string {
|
|
401
|
+
const failure = runtime.failure;
|
|
402
|
+
if (failure?.code !== undefined) {
|
|
403
|
+
return `[E_OP_ABORTED] Batch ${runtime.display} aborted: [${failure.kind}] Call Nr ${failure.order} errored [${failure.code}]`;
|
|
384
404
|
}
|
|
385
|
-
|
|
405
|
+
const cause = firstFailureCause(runtime);
|
|
406
|
+
return cause ? `[E_OP_ABORTED] Batch ${runtime.display} aborted: ${cause}` : `[E_OP_ABORTED] Batch ${runtime.display} aborted.`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function batchAbortedError(runtime: BatchState): Error {
|
|
410
|
+
discardBatchState(runtime);
|
|
411
|
+
return new Error(abortedBatchMessage(runtime));
|
|
412
|
+
}
|
|
413
|
+
function discardBatchState(runtime: BatchState): void {
|
|
414
|
+
markBatchMembersAborted(runtime);
|
|
386
415
|
}
|
|
387
416
|
|
|
388
417
|
export async function ensureBatchBase(input: {
|
|
@@ -426,7 +455,7 @@ export async function ensureBatchBase(input: {
|
|
|
426
455
|
export async function executeBatchMember(input: BatchMemberInput): Promise<TResult> {
|
|
427
456
|
const runtime = batches.get(input.member.batchKey);
|
|
428
457
|
if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
429
|
-
if (runtime.failed) throw batchAbortedError(runtime
|
|
458
|
+
if (runtime.failed) throw batchAbortedError(runtime);
|
|
430
459
|
let base: BatchBase;
|
|
431
460
|
try {
|
|
432
461
|
base = await ensureBatchBase({
|
|
@@ -438,13 +467,13 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
438
467
|
});
|
|
439
468
|
} catch (error) {
|
|
440
469
|
noteBatchFailure(input.member, error);
|
|
441
|
-
|
|
470
|
+
discardBatchState(runtime);
|
|
442
471
|
throw error;
|
|
443
472
|
}
|
|
444
473
|
if (input.mutationTargetPath !== input.member.target) {
|
|
445
474
|
const error = new Error(`[E_STALE_ANCHOR] "${input.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
|
|
446
475
|
noteBatchFailure(input.member, error);
|
|
447
|
-
|
|
476
|
+
discardBatchState(runtime);
|
|
448
477
|
throw error;
|
|
449
478
|
}
|
|
450
479
|
const displayPath = runtime.paths?.displayPath ?? input.targetPath;
|
|
@@ -464,11 +493,11 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
464
493
|
else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
|
|
465
494
|
const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
|
|
466
495
|
noteBatchFailure(input.member, indexed);
|
|
467
|
-
|
|
496
|
+
discardBatchState(runtime);
|
|
468
497
|
throw indexed;
|
|
469
498
|
}
|
|
470
499
|
noteBatchFailure(input.member, error);
|
|
471
|
-
|
|
500
|
+
discardBatchState(runtime);
|
|
472
501
|
throw error;
|
|
473
502
|
}
|
|
474
503
|
const start = planned.resolved.hash_bounds[0].line;
|
|
@@ -477,21 +506,23 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
477
506
|
const baseLines = base.baseLines;
|
|
478
507
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
479
508
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
480
|
-
const
|
|
509
|
+
const fixes = planned.autoFixes ?? [];
|
|
510
|
+
const dedupRows = dedupRowsFromFixes(fixes);
|
|
481
511
|
const piece: BatchPiece = {
|
|
482
512
|
order: input.member.order,
|
|
483
513
|
kind: input.kind,
|
|
514
|
+
...(input.direction !== undefined ? { direction: input.direction } : {}),
|
|
484
515
|
start,
|
|
485
516
|
end,
|
|
486
517
|
fromHash: input.hedit.hash_bounds[0].hash,
|
|
487
518
|
toHash: input.hedit.hash_bounds[1].hash,
|
|
488
519
|
newLines: [...newLines],
|
|
489
520
|
warnings: [...input.extraWarnings, ...planned.warnings],
|
|
490
|
-
autoFixes,
|
|
521
|
+
autoFixes: fixes.length,
|
|
522
|
+
dedupAbove: dedupRows.above,
|
|
523
|
+
dedupBelow: dedupRows.below,
|
|
491
524
|
noop,
|
|
492
|
-
...(input.noopPayload !== undefined ? { noopPayload: input.noopPayload } : {}),
|
|
493
525
|
foldedLines: input.foldedLines ?? 0,
|
|
494
|
-
...(input.bypassConsumed ? { bypassConsumed: true as const } : {}),
|
|
495
526
|
};
|
|
496
527
|
runtime.pieces.push(piece);
|
|
497
528
|
if (input.kind === "replace") runtime.replaceCount += 1;
|
|
@@ -512,46 +543,100 @@ function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
|
|
|
512
543
|
return composed;
|
|
513
544
|
}
|
|
514
545
|
|
|
546
|
+
function mergeInsertPairs(pieces: BatchPiece[]): BatchPiece[] {
|
|
547
|
+
const byAnchor = new Map<number, BatchPiece[]>();
|
|
548
|
+
for (const piece of pieces) {
|
|
549
|
+
if (piece.kind !== "insert" || piece.start !== piece.end || piece.foldedLines === 0) continue;
|
|
550
|
+
const group = byAnchor.get(piece.start) ?? [];
|
|
551
|
+
group.push(piece);
|
|
552
|
+
byAnchor.set(piece.start, group);
|
|
553
|
+
}
|
|
554
|
+
const partnerOf = new Map<BatchPiece, BatchPiece>();
|
|
555
|
+
for (const group of byAnchor.values()) {
|
|
556
|
+
if (group.length !== 2) continue;
|
|
557
|
+
const [first, second] = group;
|
|
558
|
+
if (first.direction === undefined || second.direction === undefined || first.direction === second.direction) continue;
|
|
559
|
+
partnerOf.set(first, second);
|
|
560
|
+
partnerOf.set(second, first);
|
|
561
|
+
}
|
|
562
|
+
const merged: BatchPiece[] = [];
|
|
563
|
+
const consumed = new Set<BatchPiece>();
|
|
564
|
+
for (const piece of pieces) {
|
|
565
|
+
if (consumed.has(piece)) continue;
|
|
566
|
+
const partner = partnerOf.get(piece);
|
|
567
|
+
if (partner === undefined) {
|
|
568
|
+
merged.push(piece);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
consumed.add(piece);
|
|
572
|
+
consumed.add(partner);
|
|
573
|
+
const before = piece.direction === "before" ? piece : partner;
|
|
574
|
+
const after = piece.direction === "before" ? partner : piece;
|
|
575
|
+
merged.push({
|
|
576
|
+
...before,
|
|
577
|
+
order: Math.min(before.order, after.order),
|
|
578
|
+
newLines: [...before.newLines, ...after.newLines.slice(1)],
|
|
579
|
+
warnings: [...before.warnings, ...after.warnings],
|
|
580
|
+
autoFixes: before.autoFixes + after.autoFixes,
|
|
581
|
+
dedupAbove: [...before.dedupAbove, ...after.dedupAbove],
|
|
582
|
+
dedupBelow: [...before.dedupBelow, ...after.dedupBelow],
|
|
583
|
+
foldedLines: before.foldedLines + after.foldedLines - 1,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return merged;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function dedupCutNoops(pieces: BatchPiece[]): BatchPiece[] {
|
|
590
|
+
return pieces.filter((piece) => piece.noop && piece.autoFixes > 0).sort((a, b) => a.order - b.order);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function dedupCutNoopWarning(noops: BatchPiece[]): string {
|
|
594
|
+
return boundaryDedupNoopWarning(noops.map((piece) => piece.order), noops.reduce((sum, piece) => sum + piece.autoFixes, 0));
|
|
595
|
+
}
|
|
596
|
+
|
|
515
597
|
async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
|
|
516
598
|
const runtime = batches.get(member.batchKey);
|
|
517
599
|
if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
518
600
|
const base = runtime.base;
|
|
519
601
|
const paths = runtime.paths;
|
|
520
602
|
if (runtime.failed) throw batchAbortedError(runtime);
|
|
603
|
+
const executedOrders = new Set(runtime.pieces.map((piece) => piece.order));
|
|
521
604
|
for (const id of runtime.memberIds) {
|
|
522
605
|
const planned = plan.get(id);
|
|
523
606
|
if (!planned) continue;
|
|
607
|
+
if (executedOrders.has(planned.order)) continue;
|
|
524
608
|
try {
|
|
525
609
|
const normalized = normReq(planned.args);
|
|
526
610
|
if (planned.kind === "replace") assertReq(normalized);
|
|
527
611
|
else assertInsertReq(normalized);
|
|
528
612
|
} catch (error) {
|
|
529
|
-
noteBatchFailure(
|
|
613
|
+
noteBatchFailure(planned, error);
|
|
530
614
|
throw batchAbortedError(runtime);
|
|
531
615
|
}
|
|
532
616
|
}
|
|
533
617
|
const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
|
|
534
618
|
if (appliedPieces.length === 0) {
|
|
535
|
-
|
|
536
|
-
if (piece.autoFixes > 0 && piece.noopPayload !== undefined) markBoundaryNoop(runtime.target, piece.noopPayload);
|
|
537
|
-
}
|
|
619
|
+
const dedupNoops = dedupCutNoops(runtime.pieces);
|
|
538
620
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
539
|
-
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
621
|
+
return combinedNoop(paths.displayPath, member, runtime, snapshotId, dedupNoops);
|
|
540
622
|
}
|
|
541
|
-
const
|
|
623
|
+
const effectivePieces = mergeInsertPairs(appliedPieces);
|
|
624
|
+
const ordered = [...effectivePieces].sort((a, b) => a.start - b.start);
|
|
542
625
|
for (let i = 1; i < ordered.length; i++) {
|
|
543
626
|
const prev = ordered[i - 1]!;
|
|
544
627
|
const current = ordered[i]!;
|
|
545
628
|
if (current.start <= prev.end) {
|
|
546
|
-
|
|
629
|
+
discardBatchState(runtime);
|
|
547
630
|
throw new Error(`[E_BATCH_OVERLAP] Batch ${runtime.display} has overlapping ranges: ${formatBatchPiece(prev)} overlaps ${formatBatchPiece(current)}`);
|
|
548
631
|
}
|
|
549
632
|
}
|
|
550
|
-
const composed = composeBatchLines(base.content,
|
|
633
|
+
const composed = composeBatchLines(base.content, effectivePieces);
|
|
551
634
|
const warnings = [...runtime.warnings];
|
|
552
635
|
if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
553
|
-
const dedupTotal =
|
|
636
|
+
const dedupTotal = effectivePieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
|
|
554
637
|
if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
|
|
638
|
+
const dedupNoops = dedupCutNoops(runtime.pieces);
|
|
639
|
+
if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
|
|
555
640
|
try {
|
|
556
641
|
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
557
642
|
assertNotEmpty(base.content, composed);
|
|
@@ -561,13 +646,13 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
561
646
|
throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${paths.displayPath} (exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit). For very large files, use write.`);
|
|
562
647
|
}
|
|
563
648
|
} catch (error) {
|
|
564
|
-
|
|
649
|
+
discardBatchState(runtime);
|
|
565
650
|
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
566
651
|
throw error;
|
|
567
652
|
}
|
|
568
653
|
if (composed === base.content) {
|
|
569
654
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
570
|
-
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
655
|
+
return combinedNoop(paths.displayPath, member, runtime, snapshotId, dedupNoops);
|
|
571
656
|
}
|
|
572
657
|
abortIf(signal);
|
|
573
658
|
let currentRaw: string;
|
|
@@ -575,14 +660,14 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
575
660
|
currentRaw = await readFile(runtime.target, "utf-8");
|
|
576
661
|
} catch (error) {
|
|
577
662
|
if (errCode(error) !== "ENOENT") throw error;
|
|
578
|
-
|
|
663
|
+
discardBatchState(runtime);
|
|
579
664
|
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started.`);
|
|
580
665
|
}
|
|
581
666
|
if (toLF(stripBOM(currentRaw).text) !== base.content) {
|
|
582
|
-
|
|
667
|
+
discardBatchState(runtime);
|
|
583
668
|
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
|
|
584
669
|
}
|
|
585
|
-
const preflightSpans =
|
|
670
|
+
const preflightSpans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
|
|
586
671
|
try {
|
|
587
672
|
await lineHashes(composed, runtime.target, {
|
|
588
673
|
content: base.content,
|
|
@@ -590,7 +675,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
590
675
|
spans: preflightSpans,
|
|
591
676
|
}, undefined, false, true);
|
|
592
677
|
} catch (error) {
|
|
593
|
-
|
|
678
|
+
discardBatchState(runtime);
|
|
594
679
|
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
595
680
|
throw error;
|
|
596
681
|
}
|
|
@@ -602,7 +687,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
602
687
|
resultContent: composed,
|
|
603
688
|
});
|
|
604
689
|
if (!undo.persisted) {
|
|
605
|
-
|
|
690
|
+
discardBatchState(runtime);
|
|
606
691
|
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${paths.displayPath}. Aborts batch ${runtime.display}.`);
|
|
607
692
|
}
|
|
608
693
|
try {
|
|
@@ -610,13 +695,12 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
610
695
|
await writeAtomic(paths.absolutePath, base.bom + restoreEndings(composed, base.ending), base.identity);
|
|
611
696
|
} catch (error) {
|
|
612
697
|
await undo.restore();
|
|
613
|
-
|
|
698
|
+
discardBatchState(runtime);
|
|
614
699
|
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
615
700
|
throw error;
|
|
616
701
|
}
|
|
617
|
-
clearBoundaryBypass(runtime.target);
|
|
618
702
|
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
619
|
-
const spans =
|
|
703
|
+
const spans = effectivePieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length, dedupAbove: piece.dedupAbove, dedupBelow: piece.dedupBelow }));
|
|
620
704
|
let resultHashes: string[];
|
|
621
705
|
try {
|
|
622
706
|
resultHashes = await lineHashes(composed, runtime.target, {
|
|
@@ -636,6 +720,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
636
720
|
const gross = Math.max(0, piece.newLines.length - piece.autoFixes);
|
|
637
721
|
added += piece.kind === "insert" ? Math.max(0, gross - piece.foldedLines) : gross;
|
|
638
722
|
}
|
|
723
|
+
const dedupAboveRows = ordered.flatMap((piece) => piece.dedupAbove);
|
|
724
|
+
const dedupBelowRows = ordered.flatMap((piece) => piece.dedupBelow);
|
|
639
725
|
const header = batchHeader(member);
|
|
640
726
|
const changed = buildChanged(
|
|
641
727
|
{
|
|
@@ -654,8 +740,8 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
654
740
|
addedLines: added,
|
|
655
741
|
removedLines: removed,
|
|
656
742
|
},
|
|
657
|
-
boundaryDedupAbove:
|
|
658
|
-
boundaryDedupBelow:
|
|
743
|
+
boundaryDedupAbove: dedupAboveRows,
|
|
744
|
+
boundaryDedupBelow: dedupBelowRows,
|
|
659
745
|
spans,
|
|
660
746
|
},
|
|
661
747
|
batchVerb(runtime),
|
|
@@ -674,8 +760,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
674
760
|
return changed;
|
|
675
761
|
}
|
|
676
762
|
|
|
677
|
-
async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
|
|
763
|
+
async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined, dedupNoops: BatchPiece[]): Promise<TResult> {
|
|
678
764
|
const executed = runtime.applied + runtime.noops;
|
|
765
|
+
const warnings = [...runtime.warnings];
|
|
766
|
+
if (dedupNoops.length > 0) warnings.push(dedupCutNoopWarning(dedupNoops));
|
|
679
767
|
const noop = buildNoop(
|
|
680
768
|
{
|
|
681
769
|
path,
|
|
@@ -687,7 +775,7 @@ async function combinedNoop(path: string, member: PlannedMember, runtime: BatchS
|
|
|
687
775
|
addedLines: 0,
|
|
688
776
|
removedLines: 0,
|
|
689
777
|
},
|
|
690
|
-
warnings: dedupeWarnings(
|
|
778
|
+
warnings: dedupeWarnings(warnings),
|
|
691
779
|
boundaryRemovedLines: 0,
|
|
692
780
|
},
|
|
693
781
|
"Batch",
|
package/src/commit.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { getDiffContextLines } from "./config";
|
|
|
9
9
|
import { safeSnapId } from "./file-reader";
|
|
10
10
|
import { writeAtomic } from "./fs-write";
|
|
11
11
|
import { servedHashesFromDiff, buildServedMap } from "./served";
|
|
12
|
-
import { lineHashes } from "./hashline";
|
|
12
|
+
import { lineHashes, type AutoFix } from "./hashline";
|
|
13
13
|
import { hashSpan } from "./replace";
|
|
14
14
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
15
15
|
import { markServed as markServedScoped } from "./anchor-registry";
|
|
@@ -22,10 +22,7 @@ export interface CommitMeta {
|
|
|
22
22
|
verb?: string;
|
|
23
23
|
noopNoun?: string;
|
|
24
24
|
prefixWarnings?: string[];
|
|
25
|
-
appliedWarnings?: string[];
|
|
26
25
|
foldedAnchorLines?: number;
|
|
27
|
-
onApplied?: () => void;
|
|
28
|
-
onNoopDedup?: () => void;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
28
|
export function boundaryDedupWarning(count: number): string {
|
|
@@ -34,6 +31,21 @@ export function boundaryDedupWarning(count: number): string {
|
|
|
34
31
|
return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
|
|
35
32
|
}
|
|
36
33
|
|
|
34
|
+
export function boundaryDedupNoopWarning(orders: number[], removedLines: number): string {
|
|
35
|
+
const noun = removedLines === 1 ? "1 line" : `${removedLines} lines`;
|
|
36
|
+
if (orders.length === 1) {
|
|
37
|
+
return `Boundary dedup: edit #${orders[0]!} produced no change (${noun} not added again).`;
|
|
38
|
+
}
|
|
39
|
+
return `Boundary dedup: edits ${orders.map((order) => `#${order}`).join(", ")} produced no change (${noun} not added again).`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function dedupRowsFromFixes(fixes: AutoFix[]): { removedTexts: string[]; above: string[]; below: string[] } {
|
|
43
|
+
const sorted = [...fixes].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
|
|
44
|
+
const above = sorted.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before").map((fix) => fix.removedLine);
|
|
45
|
+
const below = sorted.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after").map((fix) => fix.removedLine);
|
|
46
|
+
return { removedTexts: sorted.map((fix) => fix.removedLine), above, below };
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
|
|
38
50
|
const { path, absolutePath, mutationTargetPath, signal } = meta;
|
|
39
51
|
const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
|
|
@@ -41,7 +53,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
41
53
|
|
|
42
54
|
if (pipe.result === pipe.originalNormalized) {
|
|
43
55
|
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
44
|
-
if (pipe.hadBoundaryDedup) meta.onNoopDedup?.();
|
|
45
56
|
return buildNoop(
|
|
46
57
|
{
|
|
47
58
|
path,
|
|
@@ -60,7 +71,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
60
71
|
);
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
warnings.push(...(meta.appliedWarnings ?? []));
|
|
64
74
|
if (pipe.hadUtf8DecodeErrors) {
|
|
65
75
|
warnings.push(
|
|
66
76
|
"Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
|
|
@@ -110,7 +120,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
110
120
|
await undo.restore();
|
|
111
121
|
throw error;
|
|
112
122
|
}
|
|
113
|
-
meta.onApplied?.();
|
|
114
123
|
const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
|
|
115
124
|
|
|
116
125
|
const editMeta: RMeta = {
|
package/src/insert.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
4
|
import { execPipeline, type ReqParams, type ReplaceDetails, previewFromPipe, previewError } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
|
-
import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure
|
|
6
|
+
import { batchMemberFor, ensureBatchBase, executeBatchMember, noteBatchFailure } from "./batch";
|
|
7
7
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
8
8
|
import { MAX_HASH_LINES, parseHashRef, resEdit, resolveAnchorLine, type Anchor, type HEdit } from "./hashline";
|
|
9
9
|
import { stripAnchorRow } from "./hashline/resolve";
|
|
@@ -11,7 +11,6 @@ import { withAnchorSession } from "./anchor-registry";
|
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { assertInsertReq, normReq, type InsertReq } from "./payload-contract";
|
|
13
13
|
import { decodeStringArray, isRec, splitLines } from "./utils";
|
|
14
|
-
import { clearBoundaryBypass } from "./boundary-bypass";
|
|
15
14
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, withInsertPrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
16
15
|
import type { RPreview, RRState } from "./replace-render";
|
|
17
16
|
export { assertInsertReq, type InsertReq };
|
|
@@ -189,7 +188,6 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
189
188
|
}).catch((error: unknown) => {
|
|
190
189
|
const member = batchMemberFor(_toolCallId);
|
|
191
190
|
if (member) noteBatchFailure(member, error);
|
|
192
|
-
else suffixPoisonCause(_toolCallId, error);
|
|
193
191
|
throw error;
|
|
194
192
|
});
|
|
195
193
|
let ref: Anchor;
|
|
@@ -218,6 +216,7 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
218
216
|
}
|
|
219
217
|
return executeBatchMember({
|
|
220
218
|
kind: "insert",
|
|
219
|
+
direction: req.direction,
|
|
221
220
|
member,
|
|
222
221
|
targetPath,
|
|
223
222
|
mutationTargetPath,
|
|
@@ -251,7 +250,6 @@ export function buildInsertToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): I
|
|
|
251
250
|
noopNoun: "Insertion",
|
|
252
251
|
foldedAnchorLines: anchorLine === undefined ? 0 : 1,
|
|
253
252
|
prefixWarnings: [...anchorWarnings, ...insertWarnings],
|
|
254
|
-
onApplied: () => clearBoundaryBypass(mutationTargetPath),
|
|
255
253
|
});
|
|
256
254
|
});
|
|
257
255
|
});
|
package/src/replace-diff.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
HASH_SEP,
|
|
7
7
|
changedRange,
|
|
8
8
|
} from "./hashline";
|
|
9
|
-
import { MAX_DIFF_INPUT_BYTES } from "./constants";
|
|
9
|
+
import { MAX_DIFF_INPUT_BYTES, DEDUP_ANCHOR } from "./constants";
|
|
10
10
|
import { splitLines } from "./utils";
|
|
11
11
|
import {
|
|
12
12
|
detectEnding,
|
|
@@ -22,6 +22,8 @@ export interface DiffSpan {
|
|
|
22
22
|
start: number;
|
|
23
23
|
end: number;
|
|
24
24
|
replacementCount: number;
|
|
25
|
+
dedupAbove?: string[];
|
|
26
|
+
dedupBelow?: string[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
interface PlacedSpan {
|
|
@@ -29,6 +31,16 @@ interface PlacedSpan {
|
|
|
29
31
|
oldEnd: number;
|
|
30
32
|
newStart: number;
|
|
31
33
|
newEnd: number;
|
|
34
|
+
dedupAbove?: string[];
|
|
35
|
+
dedupBelow?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fmtDedupRow(line: string): string {
|
|
39
|
+
const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
|
|
40
|
+
if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
|
|
41
|
+
const size = formatSize(Buffer.byteLength(row, "utf-8"));
|
|
42
|
+
const limit = formatSize(DEFAULT_MAX_BYTES);
|
|
43
|
+
return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
function fmtDiffLine(
|
|
@@ -155,7 +167,7 @@ function placeSpans(
|
|
|
155
167
|
if (span.replacementCount === 0) {
|
|
156
168
|
if (newEnd !== newStart - 1) return undefined;
|
|
157
169
|
} else if (newEnd >= newLen) return undefined;
|
|
158
|
-
placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd });
|
|
170
|
+
placed.push({ oldStart: span.start, oldEnd: span.end, newStart, newEnd, dedupAbove: span.dedupAbove, dedupBelow: span.dedupBelow });
|
|
159
171
|
offset += span.replacementCount - spanLen;
|
|
160
172
|
removedSum += spanLen;
|
|
161
173
|
addedSum += span.replacementCount;
|
|
@@ -184,7 +196,7 @@ function trimPlacedSpans(
|
|
|
184
196
|
newEnd -= 1;
|
|
185
197
|
}
|
|
186
198
|
if (oldStart > oldEnd && newStart > newEnd) continue;
|
|
187
|
-
out.push({ oldStart, oldEnd, newStart, newEnd });
|
|
199
|
+
out.push({ oldStart, oldEnd, newStart, newEnd, dedupAbove: span.dedupAbove, dedupBelow: span.dedupBelow });
|
|
188
200
|
}
|
|
189
201
|
return out;
|
|
190
202
|
}
|
|
@@ -198,7 +210,7 @@ function genSpanDiff(
|
|
|
198
210
|
maxLineBytes: number,
|
|
199
211
|
maxBytes: number,
|
|
200
212
|
spans: DiffSpan[],
|
|
201
|
-
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[] } | undefined {
|
|
213
|
+
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number | undefined)[]; dedupEmitted: boolean } | undefined {
|
|
202
214
|
const oldLines = splitLines(oldContent);
|
|
203
215
|
const newLines = splitLines(newContent);
|
|
204
216
|
if (oldHashes.length !== oldLines.length || newHashes.length !== newLines.length) return undefined;
|
|
@@ -220,6 +232,7 @@ function genSpanDiff(
|
|
|
220
232
|
return undefined;
|
|
221
233
|
}
|
|
222
234
|
const output: string[] = [];
|
|
235
|
+
let dedupEmitted = false;
|
|
223
236
|
const lineNumbers: (number | undefined)[] = [];
|
|
224
237
|
let firstChangedLine: number | undefined;
|
|
225
238
|
let outBytes = 0;
|
|
@@ -310,12 +323,24 @@ function genSpanDiff(
|
|
|
310
323
|
emitGap(newPos, gapLen, spanIdx === 0 ? "leading" : "middle");
|
|
311
324
|
if (stopped) break;
|
|
312
325
|
if (firstChangedLine === undefined) firstChangedLine = span.newStart + 1;
|
|
326
|
+
for (const dedup of span.dedupAbove ?? []) {
|
|
327
|
+
if (stopped) break;
|
|
328
|
+
dedupEmitted = true;
|
|
329
|
+
output.push(fmtDedupRow(dedup));
|
|
330
|
+
lineNumbers.push(undefined);
|
|
331
|
+
}
|
|
313
332
|
for (let oldIdx = span.oldStart; oldIdx <= span.oldEnd && !stopped; oldIdx++) {
|
|
314
333
|
emitRow("-", oldLines[oldIdx]!, oldHashes[oldIdx], oldIdx + 1);
|
|
315
334
|
}
|
|
316
335
|
for (let newIdx = span.newStart; newIdx <= span.newEnd && !stopped; newIdx++) {
|
|
317
336
|
emitRow("+", newLines[newIdx]!, newHashes[newIdx], newIdx + 1);
|
|
318
337
|
}
|
|
338
|
+
for (const dedup of span.dedupBelow ?? []) {
|
|
339
|
+
if (stopped) break;
|
|
340
|
+
dedupEmitted = true;
|
|
341
|
+
output.push(fmtDedupRow(dedup));
|
|
342
|
+
lineNumbers.push(undefined);
|
|
343
|
+
}
|
|
319
344
|
oldPos = span.oldEnd + 1;
|
|
320
345
|
newPos = span.newEnd + 1;
|
|
321
346
|
}
|
|
@@ -329,7 +354,7 @@ function genSpanDiff(
|
|
|
329
354
|
output.push(`[diff truncated at ${formatSize(maxBytes)}; use read to see the rest.]`);
|
|
330
355
|
lineNumbers.push(undefined);
|
|
331
356
|
}
|
|
332
|
-
return { diff: output.join("\n"), firstChangedLine, lineNumbers };
|
|
357
|
+
return { diff: output.join("\n"), firstChangedLine, lineNumbers, dedupEmitted };
|
|
333
358
|
}
|
|
334
359
|
|
|
335
360
|
function overDiffInputLimit(oldContent: string, newContent: string): boolean {
|
|
@@ -344,14 +369,14 @@ export function genDiff(
|
|
|
344
369
|
oldContentHashes?: string[],
|
|
345
370
|
limits?: DiffLimits,
|
|
346
371
|
spans?: DiffSpan[],
|
|
347
|
-
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[] } {
|
|
372
|
+
): { diff: string; firstChangedLine: number | undefined; lineNumbers: (number|undefined)[]; spanDedupEmitted?: boolean } {
|
|
348
373
|
const maxLineBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxLineBytes ?? DEFAULT_MAX_BYTES);
|
|
349
374
|
const maxBytes = limits?.unlimited ? Number.POSITIVE_INFINITY : (limits?.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
350
375
|
if (spans && newContentHashes && oldContentHashes) {
|
|
351
376
|
const anchored = genSpanDiff(oldContent, newContent, contextLines, newContentHashes, oldContentHashes, maxLineBytes, maxBytes, spans);
|
|
352
377
|
if (anchored) {
|
|
353
378
|
const diff = disambiguateDuplicateAnchors(anchored.diff);
|
|
354
|
-
return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers };
|
|
379
|
+
return { diff, firstChangedLine: anchored.firstChangedLine, lineNumbers: anchored.lineNumbers, ...(anchored.dedupEmitted ? { spanDedupEmitted: true as const } : {}) };
|
|
355
380
|
}
|
|
356
381
|
}
|
|
357
382
|
if (!limits?.unlimited && overDiffInputLimit(oldContent, newContent)) {
|
package/src/replace-render.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
2
|
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { ReplaceDetails } from "./replace";
|
|
4
|
+
import { abortedBatchMessageFor } from "./batch";
|
|
4
5
|
import { withLineNumbers } from "./utils";
|
|
5
6
|
import { isDedupRow } from "./replace-response";
|
|
6
7
|
import { getPreviewInput } from "./payload-contract";
|
|
@@ -66,6 +67,21 @@ export function fmtResult(diff: string, theme: FgT): string {
|
|
|
66
67
|
return colorLines(diff.split("\n"), theme).join("\n");
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
export function highlightBatchRefs(text: string, theme: FgT): string {
|
|
71
|
+
const pattern = /\b([Bb]atch \d+)\b/g;
|
|
72
|
+
const sections: string[] = [];
|
|
73
|
+
let cursor = 0;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const match = pattern.exec(text);
|
|
76
|
+
if (!match) break;
|
|
77
|
+
if (match.index > cursor) sections.push(theme.fg("error", text.slice(cursor, match.index)));
|
|
78
|
+
sections.push(theme.fg("warning", match[0]));
|
|
79
|
+
cursor = match.index + match[0].length;
|
|
80
|
+
}
|
|
81
|
+
if (cursor < text.length) sections.push(theme.fg("error", text.slice(cursor)));
|
|
82
|
+
return sections.join("");
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
export function fmtCall(
|
|
70
86
|
args: { path?: string; remove_from?: string; remove_to?: string; anchor?: string } | undefined,
|
|
71
87
|
state: RRState,
|
|
@@ -144,6 +160,14 @@ function extractSummary(text: string | undefined): string | undefined {
|
|
|
144
160
|
return summary.length > 0 ? summary : undefined;
|
|
145
161
|
}
|
|
146
162
|
|
|
163
|
+
const BATCH_HEADER_RE = /^batch \d+:\n/;
|
|
164
|
+
|
|
165
|
+
function splitBatchHeader(diff: string, lineNumbers: (number | undefined)[] | undefined): { header: string | undefined; body: string; lineNumbers: (number | undefined)[] | undefined } {
|
|
166
|
+
const match = diff.match(BATCH_HEADER_RE);
|
|
167
|
+
if (!match) return { header: undefined, body: diff, lineNumbers };
|
|
168
|
+
return { header: match[0]!.slice(0, -1), body: diff.slice(match[0].length), lineNumbers: lineNumbers?.slice(1) };
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
export function buildAppliedText(
|
|
148
172
|
text: string | undefined,
|
|
149
173
|
details: ReplaceDetails | undefined,
|
|
@@ -154,8 +178,9 @@ export function buildAppliedText(
|
|
|
154
178
|
const summary = extractSummary(text);
|
|
155
179
|
if (summary) sections.push(summary);
|
|
156
180
|
if (details?.diff) {
|
|
157
|
-
const
|
|
158
|
-
const
|
|
181
|
+
const { header, body, lineNumbers } = splitBatchHeader(details.diff, details.diffLineNumbers);
|
|
182
|
+
const rawDiff = lineNumbers ? toNumberedDiff(body, lineNumbers) : body;
|
|
183
|
+
const diffLines = body.split("\n");
|
|
159
184
|
const diffSection = expanded
|
|
160
185
|
? fmtResult(rawDiff, theme)
|
|
161
186
|
: fmtPreview(rawDiff, false, theme);
|
|
@@ -163,7 +188,7 @@ export function buildAppliedText(
|
|
|
163
188
|
!expanded && diffLines.length > RESULT_PREVIEW_LINES
|
|
164
189
|
? ` (${expandHint()})`
|
|
165
190
|
: "";
|
|
166
|
-
sections.push(`${diffSection}${hint}`);
|
|
191
|
+
sections.push(header !== undefined ? `${theme.fg("warning", header)}\n${diffSection}${hint}` : `${diffSection}${hint}`);
|
|
167
192
|
}
|
|
168
193
|
const warnings = details?.warnings?.length ? `Warnings:\n${details.warnings.join("\n")}` : extractWarnings(text);
|
|
169
194
|
if (warnings) sections.push(warnings);
|
|
@@ -343,9 +368,17 @@ export function renderEditResult(
|
|
|
343
368
|
renderState.preview = undefined;
|
|
344
369
|
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
345
370
|
}
|
|
371
|
+
const batch = result.details?.batch;
|
|
372
|
+
if (batch && batch.last === false) {
|
|
373
|
+
const abortedMessage = typeof context?.toolCallId === "string" ? abortedBatchMessageFor(context.toolCallId) : undefined;
|
|
374
|
+
if (abortedMessage !== undefined) {
|
|
375
|
+
return reuseText(context, `\n${highlightBatchRefs(abortedMessage, theme)}`);
|
|
376
|
+
}
|
|
377
|
+
return reuseText(context, theme.fg("warning", `In batch ${batch.id}`));
|
|
378
|
+
}
|
|
346
379
|
if (context.isError) {
|
|
347
380
|
return renderedText
|
|
348
|
-
? reuseText(context, `\n${
|
|
381
|
+
? reuseText(context, `\n${highlightBatchRefs(renderedText, theme)}`)
|
|
349
382
|
: new Text("", 0, 0);
|
|
350
383
|
}
|
|
351
384
|
if (isApplied(result.details)) {
|
package/src/replace-response.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { formatSize, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import type { NEdit } from "./hashline";
|
|
3
2
|
import { HASH_SEP } from "./hashline";
|
|
4
3
|
import type { ReplaceDetails } from "./replace";
|
|
5
|
-
import { genDiff, genPatch, type DiffSpan } from "./replace-diff";
|
|
4
|
+
import { genDiff, genPatch, fmtDedupRow, type DiffSpan } from "./replace-diff";
|
|
6
5
|
import { visLines, clipLine } from "./utils";
|
|
7
6
|
import { DEDUP_ANCHOR } from "./constants";
|
|
8
7
|
|
|
@@ -106,7 +105,7 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
|
106
105
|
: "The edit produced identical content.";
|
|
107
106
|
const dedupNote =
|
|
108
107
|
boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
|
|
109
|
-
? `\nBoundary dedup removed ${boundaryRemovedLines} line(s)
|
|
108
|
+
? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) that duplicated adjacent lines.`
|
|
110
109
|
: "";
|
|
111
110
|
|
|
112
111
|
const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
|
|
@@ -131,13 +130,7 @@ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
|
|
|
131
130
|
},
|
|
132
131
|
};
|
|
133
132
|
}
|
|
134
|
-
export
|
|
135
|
-
const row = `${DEDUP_ANCHOR}${HASH_SEP}${line}`;
|
|
136
|
-
if (Buffer.byteLength(row, "utf-8") <= DEFAULT_MAX_BYTES) return row;
|
|
137
|
-
const size = formatSize(Buffer.byteLength(row, "utf-8"));
|
|
138
|
-
const limit = formatSize(DEFAULT_MAX_BYTES);
|
|
139
|
-
return `${DEDUP_ANCHOR}${HASH_SEP}[Row is ${size}, exceeds ${limit}; content not shown. Use read to see the full line.]`;
|
|
140
|
-
}
|
|
133
|
+
export { fmtDedupRow };
|
|
141
134
|
|
|
142
135
|
export function isDedupRow(line: string): boolean {
|
|
143
136
|
return line.startsWith(`${DEDUP_ANCHOR}${HASH_SEP}`);
|
|
@@ -175,7 +168,9 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
|
|
|
175
168
|
const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes, boundaryDedupAbove, boundaryDedupBelow, spans } = input;
|
|
176
169
|
const resultLines = visLines(result);
|
|
177
170
|
const baseDiff = genDiff(originalNormalized, result, diffContextLines, resultHashes, originalHashes, undefined, spans);
|
|
178
|
-
const diffResult =
|
|
171
|
+
const diffResult = baseDiff.spanDedupEmitted
|
|
172
|
+
? baseDiff
|
|
173
|
+
: withDedupRows(baseDiff.diff, baseDiff.lineNumbers, boundaryDedupAbove, boundaryDedupBelow);
|
|
179
174
|
const addedLines = editMeta.addedLines;
|
|
180
175
|
const removedLines = editMeta.removedLines;
|
|
181
176
|
const warningsBlock = warnBlock(warnings);
|
package/src/replace-undo.ts
CHANGED
|
@@ -172,7 +172,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
172
172
|
content: [
|
|
173
173
|
{
|
|
174
174
|
type: "text",
|
|
175
|
-
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted
|
|
175
|
+
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file was modified after the edit, so nothing was reverted and the file was left untouched. The undo record is kept. Do not edit the file to force the undo. Call read() to inspect the current state.`
|
|
176
176
|
},
|
|
177
177
|
],
|
|
178
178
|
isError: true,
|
package/src/replace.ts
CHANGED
|
@@ -32,10 +32,9 @@ import { loadHashStore, type HashStore } from "./hash-store";
|
|
|
32
32
|
import { adoptAnchors, servedForPath, withAnchorSession } from "./anchor-registry";
|
|
33
33
|
import { resolveTarget } from "./fs-write";
|
|
34
34
|
import { toCwd } from "./paths";
|
|
35
|
-
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
36
35
|
import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper, resolveEditTargetWithRequirement, throwIfStrictInput, getBoundaryDedupMode, withReplacePrompts, DEFAULT_EDIT_FLAGS, type EditToolFlags } from "./edit-common";
|
|
37
|
-
import { commitEdit } from "./commit";
|
|
38
|
-
import { batchMemberFor, executeBatchMember, noteBatchFailure
|
|
36
|
+
import { commitEdit, dedupRowsFromFixes } from "./commit";
|
|
37
|
+
import { batchMemberFor, executeBatchMember, noteBatchFailure } from "./batch";
|
|
39
38
|
|
|
40
39
|
export { editToolSchema, type ReqParams, assertReq };
|
|
41
40
|
|
|
@@ -67,7 +66,6 @@ export interface PipelineResult {
|
|
|
67
66
|
resultHashes: string[];
|
|
68
67
|
totalAddedLines: number;
|
|
69
68
|
totalRemovedLines: number;
|
|
70
|
-
hadBoundaryDedup: boolean;
|
|
71
69
|
boundaryRemovedLines: number;
|
|
72
70
|
boundaryRemovedLineTexts: string[];
|
|
73
71
|
boundaryDedupAbove: string[];
|
|
@@ -180,9 +178,7 @@ export async function execPipeline(
|
|
|
180
178
|
edit, originalHashes, isNoop, anchorResult.autoFixes?.length ?? 0,
|
|
181
179
|
);
|
|
182
180
|
|
|
183
|
-
const sortedFixes =
|
|
184
|
-
const aboveFixes = sortedFixes.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before");
|
|
185
|
-
const belowFixes = sortedFixes.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after");
|
|
181
|
+
const sortedFixes = dedupRowsFromFixes(anchorResult.autoFixes ?? []);
|
|
186
182
|
const pipeSpan = isNoop ? undefined : hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
|
|
187
183
|
const pipeResultCount = splitLines(result).length;
|
|
188
184
|
const pipeSpans = pipeSpan ? [{ start: pipeSpan[0], end: pipeSpan[1], replacementCount: pipeResultCount - (originalHashes.length - (pipeSpan[1] - pipeSpan[0] + 1)) }] : undefined;
|
|
@@ -201,11 +197,10 @@ export async function execPipeline(
|
|
|
201
197
|
originalHashes,
|
|
202
198
|
totalAddedLines,
|
|
203
199
|
totalRemovedLines,
|
|
204
|
-
hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
|
|
205
200
|
boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
|
|
206
|
-
boundaryRemovedLineTexts: sortedFixes.
|
|
207
|
-
boundaryDedupAbove:
|
|
208
|
-
boundaryDedupBelow:
|
|
201
|
+
boundaryRemovedLineTexts: sortedFixes.removedTexts,
|
|
202
|
+
boundaryDedupAbove: sortedFixes.above,
|
|
203
|
+
boundaryDedupBelow: sortedFixes.below,
|
|
209
204
|
identity,
|
|
210
205
|
...(pipeSpans ? { spans: pipeSpans } : {}),
|
|
211
206
|
};
|
|
@@ -287,54 +282,34 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
287
282
|
}).catch((error: unknown) => {
|
|
288
283
|
const member = batchMemberFor(_toolCallId);
|
|
289
284
|
if (member) noteBatchFailure(member, error);
|
|
290
|
-
else suffixPoisonCause(_toolCallId, error);
|
|
291
285
|
throw error;
|
|
292
286
|
});
|
|
293
287
|
return queuedEdit(targetPath, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
|
|
294
288
|
const dedupMode = await getBoundaryDedupMode();
|
|
295
|
-
const
|
|
296
|
-
const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
|
|
297
|
-
const boundaryBypass = dedupOn ? consumeBoundaryBypass(mutationTargetPath, noopPayload) : false;
|
|
298
|
-
const strictBoundaryDedup = dedupMode === "strict" && !boundaryBypass;
|
|
289
|
+
const strictBoundaryDedup = dedupMode === "strict";
|
|
299
290
|
const member = batchMemberFor(_toolCallId);
|
|
300
291
|
if (!member) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
mutationTargetPath,
|
|
315
|
-
editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
|
|
316
|
-
signal,
|
|
317
|
-
appliedWarnings,
|
|
318
|
-
onApplied: () => { if (dedupOn) clearBoundaryBypass(mutationTargetPath); },
|
|
319
|
-
onNoopDedup: dedupOn ? () => markBoundaryNoop(mutationTargetPath, noopPayload) : undefined,
|
|
320
|
-
});
|
|
321
|
-
} catch (error) {
|
|
322
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
323
|
-
if (boundaryBypass && !detail.includes("File was written;")) markBoundaryNoop(mutationTargetPath, noopPayload);
|
|
324
|
-
throw error;
|
|
325
|
-
}
|
|
292
|
+
const pipe = await execPipeline(
|
|
293
|
+
targetPath,
|
|
294
|
+
normalizedParams,
|
|
295
|
+
ctx.cwd,
|
|
296
|
+
{ accessMode: constants.R_OK | constants.W_OK, signal },
|
|
297
|
+
);
|
|
298
|
+
return await commitEdit(pipe, {
|
|
299
|
+
path: pipe.path,
|
|
300
|
+
absolutePath,
|
|
301
|
+
mutationTargetPath,
|
|
302
|
+
editAnchors: [normalizedParams.remove_from, normalizedParams.remove_to],
|
|
303
|
+
signal,
|
|
304
|
+
});
|
|
326
305
|
}
|
|
327
306
|
let built: { edit: HEdit; warnings: string[] };
|
|
328
307
|
try {
|
|
329
308
|
built = buildReplaceHEdit(normalizedParams);
|
|
330
309
|
} catch (error) {
|
|
331
310
|
noteBatchFailure(member, error);
|
|
332
|
-
if (boundaryBypass) markBoundaryNoop(mutationTargetPath, noopPayload);
|
|
333
311
|
throw error;
|
|
334
312
|
}
|
|
335
|
-
const appliedWarnings = boundaryBypass
|
|
336
|
-
? ["[W_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
|
|
337
|
-
: [];
|
|
338
313
|
return executeBatchMember({
|
|
339
314
|
kind: "replace",
|
|
340
315
|
member,
|
|
@@ -343,11 +318,9 @@ export function buildToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLAGS): ToolDef
|
|
|
343
318
|
cwd: ctx.cwd,
|
|
344
319
|
signal,
|
|
345
320
|
hedit: built.edit,
|
|
346
|
-
extraWarnings:
|
|
347
|
-
skipBoundaryDedup:
|
|
321
|
+
extraWarnings: built.warnings,
|
|
322
|
+
skipBoundaryDedup: false,
|
|
348
323
|
strictBoundaryDedup,
|
|
349
|
-
noopPayload,
|
|
350
|
-
bypassConsumed: boundaryBypass,
|
|
351
324
|
});
|
|
352
325
|
});
|
|
353
326
|
});
|
package/src/boundary-bypass.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { parseText } from "./hashline/parse";
|
|
2
|
-
import { ANCHOR_ROW_RE } from "./hashline/resolve";
|
|
3
|
-
import { stripRowPrefix } from "./hashline/hash";
|
|
4
|
-
|
|
5
|
-
function canonRef(ref: string): string {
|
|
6
|
-
const trimmed = ref.trim();
|
|
7
|
-
const match = trimmed.match(ANCHOR_ROW_RE);
|
|
8
|
-
return match ? match[2]! : trimmed;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function canonLines(lines: string[]): string[] {
|
|
12
|
-
return parseText(lines).map((line) => stripRowPrefix(line).text);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const boundaryBypassTracker = new Map<string, string>();
|
|
16
|
-
const BOUNDARY_BYPASS_LIMIT = 256;
|
|
17
|
-
|
|
18
|
-
export function noopPayloadKey(
|
|
19
|
-
absolutePath: string,
|
|
20
|
-
removeFrom: string,
|
|
21
|
-
removeTo: string,
|
|
22
|
-
replacementLines: string[],
|
|
23
|
-
): string {
|
|
24
|
-
return JSON.stringify([
|
|
25
|
-
absolutePath,
|
|
26
|
-
canonRef(removeFrom),
|
|
27
|
-
canonRef(removeTo),
|
|
28
|
-
canonLines(replacementLines),
|
|
29
|
-
]);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function markBoundaryNoop(absolutePath: string, payload: string): void {
|
|
33
|
-
boundaryBypassTracker.delete(absolutePath);
|
|
34
|
-
boundaryBypassTracker.set(absolutePath, payload);
|
|
35
|
-
if (boundaryBypassTracker.size > BOUNDARY_BYPASS_LIMIT) {
|
|
36
|
-
const oldest = boundaryBypassTracker.keys().next().value;
|
|
37
|
-
if (oldest !== undefined) boundaryBypassTracker.delete(oldest);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
|
|
42
|
-
if (boundaryBypassTracker.get(absolutePath) === payload) {
|
|
43
|
-
boundaryBypassTracker.delete(absolutePath);
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function clearBoundaryBypass(absolutePath: string): void {
|
|
50
|
-
boundaryBypassTracker.delete(absolutePath);
|
|
51
|
-
}
|