pi-hashline-edit-pro 5.1.0 → 6.0.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 +47 -48
- package/index.ts +10 -10
- package/package.json +1 -1
- package/prompts/copy-guidelines.md +0 -1
- package/prompts/copy.md +2 -0
- package/prompts/insert-snippet.md +1 -1
- package/prompts/insert.md +1 -1
- package/prompts/move-guidelines.md +0 -2
- package/prompts/move.md +2 -0
- package/prompts/read-guidelines.md +0 -2
- package/prompts/replace-guidelines.md +4 -4
- package/prompts/replace-match-guidelines.md +1 -0
- package/prompts/replace-match-snippet.md +1 -0
- package/prompts/replace-match.md +7 -0
- package/prompts/replace-snippet.md +1 -1
- package/prompts/replace.md +3 -3
- package/prompts/transfer-guidelines.md +2 -0
- package/prompts/undo-last-change-guidelines.md +1 -1
- package/prompts/undo-last-change-snippet.md +1 -1
- package/prompts/undo-last-change.md +1 -1
- package/src/batch.ts +307 -58
- package/src/commit.ts +4 -2
- package/src/config-ui.ts +2 -2
- package/src/config.ts +6 -6
- package/src/constants.ts +4 -4
- package/src/copy-move.ts +303 -99
- package/src/edit-common.ts +78 -64
- package/src/edit-fidelity.ts +1 -5
- package/src/glob.ts +2 -20
- package/src/grep.ts +2 -9
- package/src/hash-store.ts +0 -3
- package/src/hashline/apply.ts +0 -9
- package/src/hashline/index.ts +0 -1
- package/src/hashline/parse.ts +1 -1
- package/src/hashline/resolve.ts +18 -19
- package/src/insert.ts +11 -11
- package/src/payload-contract.ts +60 -87
- package/src/replace-diff.ts +1 -5
- package/src/replace-match.ts +259 -0
- package/src/replace-response.ts +10 -4
- package/src/replace.ts +4 -6
- package/src/utils.ts +6 -29
- package/src/write-hook.ts +3 -5
- package/prompts/replace-within-guidelines.md +0 -3
- package/prompts/replace-within-snippet.md +0 -1
- package/prompts/replace-within.md +0 -5
- package/src/replace-within.ts +0 -224
package/src/batch.ts
CHANGED
|
@@ -8,7 +8,6 @@ import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
|
8
8
|
import {
|
|
9
9
|
AnchorMismatchError,
|
|
10
10
|
RangeStaleError,
|
|
11
|
-
assertNotEmpty,
|
|
12
11
|
changedRange,
|
|
13
12
|
lineHashes,
|
|
14
13
|
planEdit,
|
|
@@ -19,9 +18,11 @@ import {
|
|
|
19
18
|
type StripWarningLocation,
|
|
20
19
|
} from "./hashline";
|
|
21
20
|
import { adoptAnchors, servedForPath, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
22
|
-
import { stripBOM, toLF, type LineEnding } from "./normalize";
|
|
21
|
+
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
23
22
|
import { applySpanEndings, joinSeparators, separatorsForSpans } from "./line-endings";
|
|
24
|
-
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
23
|
+
import { assertInsertReq, assertReplaceMatchReq, assertReq, assertTransferReq, normReq } from "./payload-contract";
|
|
24
|
+
import type { PipelineResult } from "./replace";
|
|
25
|
+
import { genPatch } from "./replace-diff";
|
|
25
26
|
import { saveUndo } from "./replace-undo";
|
|
26
27
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
27
28
|
import { serveRows, servedHashesFromDiff } from "./served";
|
|
@@ -33,6 +34,7 @@ export interface PlannedMember {
|
|
|
33
34
|
display: number;
|
|
34
35
|
total: number;
|
|
35
36
|
target: string;
|
|
37
|
+
sourceTarget?: string;
|
|
36
38
|
kind: BatchKind;
|
|
37
39
|
args: unknown;
|
|
38
40
|
order: number;
|
|
@@ -40,9 +42,9 @@ export interface PlannedMember {
|
|
|
40
42
|
last: boolean;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export type BatchKind = "replace" | "insert";
|
|
45
|
+
export type BatchKind = "replace" | "insert" | "replace_match" | "copy" | "move";
|
|
44
46
|
|
|
45
|
-
interface BatchBase {
|
|
47
|
+
export interface BatchBase {
|
|
46
48
|
content: string;
|
|
47
49
|
hashes: string[];
|
|
48
50
|
bom: string;
|
|
@@ -55,6 +57,19 @@ interface BatchBase {
|
|
|
55
57
|
baseLines: string[];
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
export interface BatchSourceMove {
|
|
61
|
+
displayPath: string;
|
|
62
|
+
mutationTargetPath: string;
|
|
63
|
+
pipe: PipelineResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SourcePreparation {
|
|
67
|
+
move: BatchSourceMove;
|
|
68
|
+
separators?: LineEnding[];
|
|
69
|
+
bytes: string;
|
|
70
|
+
originalBytes: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
export interface BatchPiece {
|
|
59
74
|
order: number;
|
|
60
75
|
kind: BatchKind;
|
|
@@ -84,6 +99,9 @@ export interface BatchMemberInput {
|
|
|
84
99
|
foldedLines?: number;
|
|
85
100
|
stripWarning?: StripWarningLocation;
|
|
86
101
|
contentSeparators?: (LineEnding | undefined)[];
|
|
102
|
+
carryIndex?: number;
|
|
103
|
+
servedOverride?: ReadonlyMap<string, string>;
|
|
104
|
+
sourceMove?: BatchSourceMove;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
interface BatchFailure {
|
|
@@ -96,12 +114,12 @@ interface BatchState {
|
|
|
96
114
|
display: number;
|
|
97
115
|
target: string;
|
|
98
116
|
memberIds: string[];
|
|
99
|
-
|
|
100
|
-
insertCount: number;
|
|
117
|
+
stale: boolean;
|
|
101
118
|
base?: BatchBase;
|
|
102
119
|
paths?: { absolutePath: string; mutationTargetPath: string; displayPath: string };
|
|
103
120
|
served?: ReadonlyMap<string, string>;
|
|
104
121
|
pieces: BatchPiece[];
|
|
122
|
+
sources: BatchSourceMove[];
|
|
105
123
|
applied: number;
|
|
106
124
|
noops: number;
|
|
107
125
|
failures: number;
|
|
@@ -117,12 +135,39 @@ interface EditCall {
|
|
|
117
135
|
args: unknown;
|
|
118
136
|
}
|
|
119
137
|
|
|
138
|
+
interface ResolvedCall {
|
|
139
|
+
id: string;
|
|
140
|
+
target: string;
|
|
141
|
+
altTarget?: string;
|
|
142
|
+
sourceTarget?: string;
|
|
143
|
+
kind: BatchKind;
|
|
144
|
+
args: unknown;
|
|
145
|
+
path?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function demoteBlockedMoveCalls(resolved: ResolvedCall[]): ResolvedCall[] {
|
|
149
|
+
const targets = new Set(resolved.map((item) => item.target));
|
|
150
|
+
const sourceCounts = new Map<string, number>();
|
|
151
|
+
for (const item of resolved) {
|
|
152
|
+
if (item.kind !== "move" || item.sourceTarget === undefined || item.sourceTarget === item.target) continue;
|
|
153
|
+
sourceCounts.set(item.sourceTarget, (sourceCounts.get(item.sourceTarget) ?? 0) + 1);
|
|
154
|
+
}
|
|
155
|
+
return resolved.filter((item) => {
|
|
156
|
+
if (item.kind !== "move" || item.sourceTarget === undefined || item.sourceTarget === item.target) return true;
|
|
157
|
+
if (targets.has(item.sourceTarget)) return false;
|
|
158
|
+
return (sourceCounts.get(item.sourceTarget) ?? 0) === 1;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
120
162
|
type NormalizedEditArgs =
|
|
121
163
|
| { kind: "replace"; removeFrom: string; removeTo?: string; path?: string }
|
|
122
|
-
| { kind: "insert"; anchor: string; path?: string }
|
|
164
|
+
| { kind: "insert"; anchor: string; path?: string }
|
|
165
|
+
| { kind: "replace_match"; replaceFrom: string; replaceTo?: string; path?: string }
|
|
166
|
+
| { kind: "copy" | "move"; sourceFrom: string; sourceTo?: string; insertAfter?: string; path?: string };
|
|
123
167
|
|
|
124
168
|
const MAX_TRACKED_BATCHES = 256;
|
|
125
169
|
const BATCH_DISCARDED_NOTE = "Nothing was written; the whole batch was discarded.";
|
|
170
|
+
const BATCHABLE_CALLS = new Set(["replace", "insert", "replace_match", "copy", "move"]);
|
|
126
171
|
|
|
127
172
|
const plan = new Map<string, PlannedMember>();
|
|
128
173
|
const batches = new Map<number, BatchState>();
|
|
@@ -162,6 +207,21 @@ export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
|
|
|
162
207
|
return plan.get(toolCallId);
|
|
163
208
|
}
|
|
164
209
|
|
|
210
|
+
export function pendingBatchMemberFor(path: string): PlannedMember | undefined {
|
|
211
|
+
for (const runtime of batches.values()) {
|
|
212
|
+
if (runtime.target !== path || runtime.stale || runtime.failed) continue;
|
|
213
|
+
for (const id of runtime.memberIds) {
|
|
214
|
+
const member = plan.get(id);
|
|
215
|
+
if (member) return member;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function batchServedFor(member: PlannedMember): ReadonlyMap<string, string> | undefined {
|
|
222
|
+
return batches.get(member.batchKey)?.served;
|
|
223
|
+
}
|
|
224
|
+
|
|
165
225
|
export function resetBatchStateForTests(): void {
|
|
166
226
|
plan.clear();
|
|
167
227
|
batches.clear();
|
|
@@ -170,11 +230,30 @@ export function resetBatchStateForTests(): void {
|
|
|
170
230
|
nextBatchKey = 1;
|
|
171
231
|
}
|
|
172
232
|
|
|
173
|
-
function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
|
|
233
|
+
function normalizeEditArgs(name: string, args: unknown): NormalizedEditArgs | undefined {
|
|
174
234
|
if (!isRec(args)) return undefined;
|
|
175
235
|
const normalized = normReq(args);
|
|
176
236
|
if (!isRec(normalized)) return undefined;
|
|
177
237
|
const path = typeof normalized.path === "string" ? normalized.path : undefined;
|
|
238
|
+
if (name === "copy" || name === "move") {
|
|
239
|
+
if (typeof normalized.source_from !== "string") return undefined;
|
|
240
|
+
return {
|
|
241
|
+
kind: name,
|
|
242
|
+
sourceFrom: normalized.source_from,
|
|
243
|
+
...(typeof normalized.source_to === "string" ? { sourceTo: normalized.source_to } : {}),
|
|
244
|
+
...(typeof normalized.insert_after === "string" ? { insertAfter: normalized.insert_after } : {}),
|
|
245
|
+
...(path ? { path } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (name === "replace_match") {
|
|
249
|
+
if (typeof normalized.replace_from !== "string") return undefined;
|
|
250
|
+
return {
|
|
251
|
+
kind: "replace_match",
|
|
252
|
+
replaceFrom: normalized.replace_from,
|
|
253
|
+
...(typeof normalized.replace_to === "string" ? { replaceTo: normalized.replace_to } : {}),
|
|
254
|
+
...(path ? { path } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
178
257
|
if (typeof normalized.remove_from === "string") {
|
|
179
258
|
return {
|
|
180
259
|
kind: "replace",
|
|
@@ -190,10 +269,10 @@ function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
|
|
|
190
269
|
}
|
|
191
270
|
|
|
192
271
|
async function verifyPaths(
|
|
193
|
-
group: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>,
|
|
272
|
+
group: Array<{ id: string; target: string; altTarget?: string; kind: BatchKind; args: unknown; path?: string }>,
|
|
194
273
|
cwd: string,
|
|
195
|
-
): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }>> {
|
|
196
|
-
const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown; path?: string }> = [];
|
|
274
|
+
): Promise<Array<{ id: string; target: string; altTarget?: string; kind: BatchKind; args: unknown; path?: string }>> {
|
|
275
|
+
const verified: Array<{ id: string; target: string; altTarget?: string; kind: BatchKind; args: unknown; path?: string }> = [];
|
|
197
276
|
for (const item of group) {
|
|
198
277
|
if (!item.path) continue;
|
|
199
278
|
let resolved: string | undefined;
|
|
@@ -202,11 +281,52 @@ async function verifyPaths(
|
|
|
202
281
|
} catch {
|
|
203
282
|
resolved = undefined;
|
|
204
283
|
}
|
|
205
|
-
if (resolved === item.target) verified.push(item);
|
|
284
|
+
if (resolved === item.target || (item.altTarget !== undefined && resolved === item.altTarget)) verified.push(item);
|
|
206
285
|
}
|
|
207
286
|
return verified;
|
|
208
287
|
}
|
|
209
288
|
|
|
289
|
+
async function resolveBatchCallTarget(
|
|
290
|
+
normalized: NormalizedEditArgs,
|
|
291
|
+
requirePath: boolean,
|
|
292
|
+
cwd: string,
|
|
293
|
+
): Promise<{ target: string; altTarget?: string; sourceTarget?: string } | undefined> {
|
|
294
|
+
const transfer = normalized.kind === "copy" || normalized.kind === "move";
|
|
295
|
+
if (normalized.kind === "replace" || normalized.kind === "replace_match") {
|
|
296
|
+
const from = normalized.kind === "replace" ? normalized.removeFrom : normalized.replaceFrom;
|
|
297
|
+
const to = normalized.kind === "replace" ? normalized.removeTo : normalized.replaceTo;
|
|
298
|
+
const target = tryResolveEditTarget(from, to)
|
|
299
|
+
?? tryResolveEditTarget(from)
|
|
300
|
+
?? (to !== undefined ? tryResolveEditTarget(to) : undefined);
|
|
301
|
+
if (target !== undefined) return { target };
|
|
302
|
+
} else if (normalized.kind === "insert") {
|
|
303
|
+
const target = tryResolveEditTarget(normalized.anchor);
|
|
304
|
+
if (target !== undefined) return { target };
|
|
305
|
+
} else {
|
|
306
|
+
const sourceTarget = normalized.sourceTo !== undefined
|
|
307
|
+
? tryResolveEditTarget(normalized.sourceFrom, normalized.sourceTo)
|
|
308
|
+
: tryResolveEditTarget(normalized.sourceFrom);
|
|
309
|
+
const destinationTarget = normalized.insertAfter !== undefined ? tryResolveEditTarget(normalized.insertAfter) : undefined;
|
|
310
|
+
if (sourceTarget === undefined) return undefined;
|
|
311
|
+
if (normalized.kind === "move") {
|
|
312
|
+
if (sourceTarget === destinationTarget) return { target: sourceTarget, sourceTarget };
|
|
313
|
+
if (destinationTarget !== undefined) return { target: destinationTarget, altTarget: sourceTarget, sourceTarget };
|
|
314
|
+
}
|
|
315
|
+
if (sourceTarget === destinationTarget) return { target: sourceTarget, sourceTarget };
|
|
316
|
+
if (destinationTarget !== undefined) {
|
|
317
|
+
return { target: destinationTarget, altTarget: sourceTarget, sourceTarget };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (requirePath && normalized.path !== undefined && !transfer) {
|
|
321
|
+
try {
|
|
322
|
+
return { target: (await resolveInCwd(normalized.path, cwd)).resolved };
|
|
323
|
+
} catch {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
210
330
|
function enforceCap(): void {
|
|
211
331
|
while (batches.size > MAX_TRACKED_BATCHES) {
|
|
212
332
|
const oldest = batches.keys().next().value;
|
|
@@ -227,34 +347,32 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
227
347
|
const calls: EditCall[] = [];
|
|
228
348
|
for (const block of message.content) {
|
|
229
349
|
if (!isRec(block) || block.type !== "toolCall") continue;
|
|
230
|
-
if (block.name !== "
|
|
350
|
+
if (typeof block.name !== "string" || !BATCHABLE_CALLS.has(block.name)) continue;
|
|
231
351
|
if (typeof block.id !== "string") continue;
|
|
232
352
|
calls.push({ id: block.id, name: block.name, args: block.arguments });
|
|
233
353
|
}
|
|
234
354
|
if (calls.length < 2) return;
|
|
235
355
|
const earlyConfig = await readConfig();
|
|
236
356
|
const requirePath = earlyConfig.requirePath === true;
|
|
237
|
-
interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown; path?: string }
|
|
238
357
|
const resolved: ResolvedCall[] = [];
|
|
239
358
|
for (const call of calls) {
|
|
240
|
-
const normalized = normalizeEditArgs(call.args);
|
|
359
|
+
const normalized = normalizeEditArgs(call.name, call.args);
|
|
241
360
|
if (!normalized) continue;
|
|
242
|
-
|
|
243
|
-
if (!
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args, ...(normalized.path ? { path: normalized.path } : {}) });
|
|
361
|
+
const resolvedTarget = await resolveBatchCallTarget(normalized, requirePath, cwd);
|
|
362
|
+
if (!resolvedTarget) continue;
|
|
363
|
+
resolved.push({
|
|
364
|
+
id: call.id,
|
|
365
|
+
target: resolvedTarget.target,
|
|
366
|
+
kind: normalized.kind,
|
|
367
|
+
args: call.args,
|
|
368
|
+
...(resolvedTarget.altTarget !== undefined ? { altTarget: resolvedTarget.altTarget } : {}),
|
|
369
|
+
...(resolvedTarget.sourceTarget !== undefined ? { sourceTarget: resolvedTarget.sourceTarget } : {}),
|
|
370
|
+
...(normalized.path ? { path: normalized.path } : {}),
|
|
371
|
+
});
|
|
255
372
|
}
|
|
373
|
+
const allowed = demoteBlockedMoveCalls(resolved);
|
|
256
374
|
const groups = new Map<string, ResolvedCall[]>();
|
|
257
|
-
for (const item of
|
|
375
|
+
for (const item of allowed) {
|
|
258
376
|
const group = groups.get(item.target) ?? [];
|
|
259
377
|
group.push(item);
|
|
260
378
|
groups.set(item.target, group);
|
|
@@ -269,6 +387,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
269
387
|
} else {
|
|
270
388
|
finalGroups.push(...multi);
|
|
271
389
|
}
|
|
390
|
+
for (const runtime of batches.values()) runtime.stale = true;
|
|
272
391
|
let display = 0;
|
|
273
392
|
for (const group of finalGroups) {
|
|
274
393
|
display += 1;
|
|
@@ -277,9 +396,9 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
277
396
|
display,
|
|
278
397
|
target: group[0]!.target,
|
|
279
398
|
memberIds: group.map((item) => item.id),
|
|
280
|
-
|
|
281
|
-
insertCount: 0,
|
|
399
|
+
stale: false,
|
|
282
400
|
pieces: [],
|
|
401
|
+
sources: [],
|
|
283
402
|
applied: 0,
|
|
284
403
|
noops: 0,
|
|
285
404
|
failures: 0,
|
|
@@ -293,6 +412,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
293
412
|
display,
|
|
294
413
|
total: finalGroups.length,
|
|
295
414
|
target: item.target,
|
|
415
|
+
...(item.sourceTarget !== undefined ? { sourceTarget: item.sourceTarget } : {}),
|
|
296
416
|
kind: item.kind,
|
|
297
417
|
args: item.args,
|
|
298
418
|
order: index + 1,
|
|
@@ -316,9 +436,15 @@ function dedupeWarnings(warnings: string[]): string[] {
|
|
|
316
436
|
}
|
|
317
437
|
|
|
318
438
|
function batchVerb(runtime: BatchState): string {
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
439
|
+
const kinds = new Set(runtime.pieces.map((piece) => piece.kind));
|
|
440
|
+
if (kinds.size === 1) {
|
|
441
|
+
const only = [...kinds][0];
|
|
442
|
+
if (only === "insert") return "inserted";
|
|
443
|
+
if (only === "copy") return "copied";
|
|
444
|
+
if (only === "move") return "moved";
|
|
445
|
+
return "replaced";
|
|
446
|
+
}
|
|
447
|
+
return "edited";
|
|
322
448
|
}
|
|
323
449
|
function batchHeader(member: PlannedMember): string {
|
|
324
450
|
return `batch ${member.display}:`;
|
|
@@ -331,12 +457,14 @@ function formatBatchLines(start: number, end: number): string {
|
|
|
331
457
|
function formatBatchPiece(piece: BatchPiece): string {
|
|
332
458
|
const lines = formatBatchLines(piece.start, piece.end);
|
|
333
459
|
if (piece.kind === "insert") return `edit #${piece.order} (insert at ${piece.fromHash}, ${lines})`;
|
|
460
|
+
if (piece.kind === "copy") return `edit #${piece.order} (copy at ${piece.fromHash}, ${lines})`;
|
|
461
|
+
if (piece.kind === "move") return `edit #${piece.order} (move ${piece.fromHash}→${piece.toHash}, ${lines})`;
|
|
334
462
|
if (piece.fromHash === piece.toHash) return `edit #${piece.order} (replace ${piece.fromHash}, ${lines})`;
|
|
335
463
|
return `edit #${piece.order} (replace ${piece.fromHash}→${piece.toHash}, ${lines})`;
|
|
336
464
|
}
|
|
337
465
|
|
|
338
466
|
function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId: string | undefined): TResult {
|
|
339
|
-
const added =
|
|
467
|
+
const added = Math.max(0, piece.newLines.length - piece.foldedLines);
|
|
340
468
|
const metrics: RMetrics = {
|
|
341
469
|
edits_attempted: 1,
|
|
342
470
|
edits_noop: piece.noop ? 1 : 0,
|
|
@@ -455,7 +583,8 @@ export async function ensureBatchBase(input: {
|
|
|
455
583
|
baseLines: splitLines(file.normalized),
|
|
456
584
|
};
|
|
457
585
|
runtime.base = base;
|
|
458
|
-
|
|
586
|
+
const served = servedForPath(file.absolutePath);
|
|
587
|
+
runtime.served = served ? new Map(served) : undefined;
|
|
459
588
|
runtime.paths = {
|
|
460
589
|
absolutePath: file.absolutePath,
|
|
461
590
|
mutationTargetPath: input.mutationTargetPath,
|
|
@@ -494,7 +623,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
494
623
|
try {
|
|
495
624
|
planned = planEdit(base.content, effectiveHedit, base.hashes, {
|
|
496
625
|
filePath: displayPath,
|
|
497
|
-
servedHashes: runtime.served,
|
|
626
|
+
servedHashes: input.servedOverride ?? runtime.served,
|
|
498
627
|
signal: input.signal,
|
|
499
628
|
baseFileLines: base.baseLines,
|
|
500
629
|
stripWarning: input.stripWarning,
|
|
@@ -515,11 +644,13 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
515
644
|
const foldedLines = input.foldedLines ?? 0;
|
|
516
645
|
const separators = input.contentSeparators ?? input.hedit.content_separators;
|
|
517
646
|
const carryIndex =
|
|
518
|
-
input.
|
|
519
|
-
? input.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
647
|
+
input.carryIndex !== undefined
|
|
648
|
+
? input.carryIndex
|
|
649
|
+
: input.kind === "insert" && foldedLines > 0
|
|
650
|
+
? input.direction === "after"
|
|
651
|
+
? 0
|
|
652
|
+
: newLines.length - 1
|
|
653
|
+
: undefined;
|
|
523
654
|
const piece: BatchPiece = {
|
|
524
655
|
order: input.member.order,
|
|
525
656
|
kind: input.kind,
|
|
@@ -536,11 +667,10 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
536
667
|
foldedLines,
|
|
537
668
|
};
|
|
538
669
|
runtime.pieces.push(piece);
|
|
539
|
-
if (input.kind === "replace") runtime.replaceCount += 1;
|
|
540
|
-
else runtime.insertCount += 1;
|
|
541
670
|
if (noop) runtime.noops += 1;
|
|
542
671
|
else runtime.applied += 1;
|
|
543
672
|
runtime.warnings.push(...piece.warnings);
|
|
673
|
+
if (input.sourceMove !== undefined) runtime.sources.push(input.sourceMove);
|
|
544
674
|
if (!input.member.last) {
|
|
545
675
|
const placeholder = batchPlaceholder(input.member, piece, base.snapshotId);
|
|
546
676
|
placeholderResults.set(input.member.id, placeholder);
|
|
@@ -642,8 +772,10 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
642
772
|
if (executedOrders.has(planned.order)) continue;
|
|
643
773
|
try {
|
|
644
774
|
const normalized = normReq(planned.args);
|
|
645
|
-
if (planned.kind === "
|
|
646
|
-
else
|
|
775
|
+
if (planned.kind === "insert") assertInsertReq(normalized);
|
|
776
|
+
else if (planned.kind === "replace") assertReq(normalized);
|
|
777
|
+
else if (planned.kind === "replace_match") assertReplaceMatchReq(normalized);
|
|
778
|
+
else assertTransferReq(normalized);
|
|
647
779
|
} catch (error) {
|
|
648
780
|
noteBatchFailure(planned, error);
|
|
649
781
|
throw batchAbortedError(runtime);
|
|
@@ -651,7 +783,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
651
783
|
}
|
|
652
784
|
const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
|
|
653
785
|
const candidatePieces = runtime.pieces.filter((piece) => !piece.noop || changesEnding(piece, base.separators));
|
|
654
|
-
if (candidatePieces.length === 0) {
|
|
786
|
+
if (candidatePieces.length === 0 && runtime.sources.length === 0) {
|
|
655
787
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
656
788
|
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
657
789
|
}
|
|
@@ -666,7 +798,9 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
666
798
|
}
|
|
667
799
|
}
|
|
668
800
|
const composed = composeBatchLines(base.content, effectivePieces);
|
|
669
|
-
const spans =
|
|
801
|
+
const spans = composed.length === 0
|
|
802
|
+
? [{ start: 0, end: base.hashes.length - 1, replacementCount: 1 }]
|
|
803
|
+
: pieceMappingSpans(effectivePieces);
|
|
670
804
|
const resultSeparators = separatorsForSpans(base.separators, base.hashes.length, spans, composed, base.ending);
|
|
671
805
|
applySpanEndings(resultSeparators, effectivePieces.map((piece) => ({
|
|
672
806
|
start: piece.start - 1,
|
|
@@ -680,7 +814,6 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
680
814
|
const originalBytes = base.bom + joinSeparators(base.content, base.separators);
|
|
681
815
|
try {
|
|
682
816
|
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
683
|
-
assertNotEmpty(base.content, composed);
|
|
684
817
|
assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
|
|
685
818
|
assertByteLimit(finalBytes, paths.displayPath);
|
|
686
819
|
} catch (error) {
|
|
@@ -690,7 +823,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
690
823
|
}
|
|
691
824
|
const reclaimNotice = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
692
825
|
if (reclaimNotice !== undefined) warnings.push(reclaimNotice);
|
|
693
|
-
if (finalBytes === originalBytes) {
|
|
826
|
+
if (finalBytes === originalBytes && runtime.sources.length === 0) {
|
|
694
827
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
695
828
|
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
696
829
|
}
|
|
@@ -718,6 +851,47 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
718
851
|
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
719
852
|
throw error;
|
|
720
853
|
}
|
|
854
|
+
|
|
855
|
+
const sourcePreparations: SourcePreparation[] = [];
|
|
856
|
+
for (const source of runtime.sources) {
|
|
857
|
+
let sourceRaw: string | undefined;
|
|
858
|
+
try {
|
|
859
|
+
sourceRaw = await readFile(source.mutationTargetPath, "utf-8");
|
|
860
|
+
} catch (error) {
|
|
861
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
862
|
+
sourceRaw = undefined;
|
|
863
|
+
}
|
|
864
|
+
if (sourceRaw === undefined || toLF(stripBOM(sourceRaw).text) !== source.pipe.originalNormalized) {
|
|
865
|
+
discardBatchState(runtime);
|
|
866
|
+
throw new Error(withAbortSuffix(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the move source ${source.displayPath} changed after the move started. Call read for fresh anchors and retry.`, runtime.display));
|
|
867
|
+
}
|
|
868
|
+
const sourceSpan = source.pipe.spans?.[0];
|
|
869
|
+
const sourceSeparators = sourceSpan
|
|
870
|
+
? separatorsForSpans(source.pipe.originalSeparators, source.pipe.originalHashes.length, [sourceSpan], source.pipe.result, source.pipe.originalEnding)
|
|
871
|
+
: undefined;
|
|
872
|
+
const sourceBytes = source.pipe.bom + (sourceSeparators !== undefined
|
|
873
|
+
? joinSeparators(source.pipe.result, sourceSeparators)
|
|
874
|
+
: restoreEndings(source.pipe.result, source.pipe.originalEnding));
|
|
875
|
+
const sourceOriginalBytes = source.pipe.bom + joinSeparators(source.pipe.originalNormalized, source.pipe.originalSeparators);
|
|
876
|
+
try {
|
|
877
|
+
assertByteLimit(sourceBytes, source.displayPath);
|
|
878
|
+
await lineHashes(source.pipe.result, source.mutationTargetPath, {
|
|
879
|
+
content: source.pipe.originalNormalized,
|
|
880
|
+
hashes: source.pipe.originalHashes,
|
|
881
|
+
...(source.pipe.spans !== undefined ? { spans: source.pipe.spans } : {}),
|
|
882
|
+
}, undefined, false, true);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
discardBatchState(runtime);
|
|
885
|
+
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
888
|
+
sourcePreparations.push({
|
|
889
|
+
move: source,
|
|
890
|
+
...(sourceSeparators !== undefined ? { separators: sourceSeparators } : {}),
|
|
891
|
+
bytes: sourceBytes,
|
|
892
|
+
originalBytes: sourceOriginalBytes,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
721
895
|
const undo = await saveUndo(runtime.target, {
|
|
722
896
|
content: base.content,
|
|
723
897
|
bom: base.bom,
|
|
@@ -731,11 +905,43 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
731
905
|
discardBatchState(runtime);
|
|
732
906
|
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${paths.displayPath}. Aborts batch ${runtime.display}.`);
|
|
733
907
|
}
|
|
908
|
+
const undoRestores: Array<() => Promise<void>> = [undo.restore];
|
|
909
|
+
for (const preparation of sourcePreparations) {
|
|
910
|
+
const sourceUndo = await saveUndo(preparation.move.mutationTargetPath, {
|
|
911
|
+
content: preparation.move.pipe.originalNormalized,
|
|
912
|
+
bom: preparation.move.pipe.bom,
|
|
913
|
+
originalEnding: preparation.move.pipe.originalEnding,
|
|
914
|
+
separators: preparation.move.pipe.originalSeparators,
|
|
915
|
+
hashes: preparation.move.pipe.originalHashes,
|
|
916
|
+
resultContent: preparation.move.pipe.result,
|
|
917
|
+
...(preparation.separators !== undefined ? { resultSeparators: preparation.separators } : {}),
|
|
918
|
+
});
|
|
919
|
+
if (!sourceUndo.persisted) {
|
|
920
|
+
for (const restore of [...undoRestores].reverse()) await restore();
|
|
921
|
+
discardBatchState(runtime);
|
|
922
|
+
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${preparation.move.displayPath}. Nothing was written. Aborts batch ${runtime.display}.`);
|
|
923
|
+
}
|
|
924
|
+
undoRestores.push(sourceUndo.restore);
|
|
925
|
+
}
|
|
926
|
+
const writtenFiles: Array<{ path: string; bytes: string }> = [];
|
|
734
927
|
try {
|
|
735
928
|
abortIf(signal);
|
|
736
|
-
await writeAtomic(paths.absolutePath,
|
|
929
|
+
await writeAtomic(paths.absolutePath, finalBytes, base.identity);
|
|
930
|
+
writtenFiles.push({ path: paths.absolutePath, bytes: originalBytes });
|
|
931
|
+
for (const preparation of sourcePreparations) {
|
|
932
|
+
abortIf(signal);
|
|
933
|
+
await writeAtomic(preparation.move.mutationTargetPath, preparation.bytes, preparation.move.pipe.identity);
|
|
934
|
+
writtenFiles.push({ path: preparation.move.mutationTargetPath, bytes: preparation.originalBytes });
|
|
935
|
+
}
|
|
737
936
|
} catch (error) {
|
|
738
|
-
|
|
937
|
+
for (const written of [...writtenFiles].reverse()) {
|
|
938
|
+
try {
|
|
939
|
+
await writeAtomic(written.path, written.bytes);
|
|
940
|
+
} catch (rollbackError) {
|
|
941
|
+
console.error("Failed to roll back a file after a batched cross-file move failed:", rollbackError);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
for (const restore of [...undoRestores].reverse()) await restore();
|
|
739
945
|
discardBatchState(runtime);
|
|
740
946
|
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
741
947
|
throw error;
|
|
@@ -750,7 +956,23 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
750
956
|
});
|
|
751
957
|
} catch (error) {
|
|
752
958
|
const detail = error instanceof Error ? error.message : String(error);
|
|
753
|
-
|
|
959
|
+
const written = sourcePreparations.length > 0
|
|
960
|
+
? "Files were written; anchor finalization failed. One undo per file reverts."
|
|
961
|
+
: "File was written; anchor finalization failed. One undo reverts.";
|
|
962
|
+
throw new Error(`${detail} ${written} Call read for fresh anchors.`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
for (const preparation of sourcePreparations) {
|
|
966
|
+
try {
|
|
967
|
+
await lineHashes(preparation.move.pipe.result, preparation.move.mutationTargetPath, {
|
|
968
|
+
content: preparation.move.pipe.originalNormalized,
|
|
969
|
+
hashes: preparation.move.pipe.originalHashes,
|
|
970
|
+
...(preparation.move.pipe.spans !== undefined ? { spans: preparation.move.pipe.spans } : {}),
|
|
971
|
+
});
|
|
972
|
+
} catch (error) {
|
|
973
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
974
|
+
throw new Error(`${detail} Files were written; anchor finalization failed. One undo per file reverts. Call read for fresh anchors.`);
|
|
975
|
+
}
|
|
754
976
|
}
|
|
755
977
|
const writeReclaim = formatAnchorReclaimNotice(takeReclaimedPaths());
|
|
756
978
|
if (writeReclaim !== undefined) warnings.push(writeReclaim);
|
|
@@ -759,8 +981,9 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
759
981
|
let removed = 0;
|
|
760
982
|
for (const piece of appliedPieces) {
|
|
761
983
|
removed += piece.end - piece.start + 1;
|
|
762
|
-
added +=
|
|
984
|
+
added += Math.max(0, piece.newLines.length - piece.foldedLines);
|
|
763
985
|
}
|
|
986
|
+
const verb = batchVerb(runtime);
|
|
764
987
|
const header = batchHeader(member);
|
|
765
988
|
const changed = buildChanged(
|
|
766
989
|
{
|
|
@@ -781,20 +1004,46 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
781
1004
|
},
|
|
782
1005
|
spans,
|
|
783
1006
|
},
|
|
784
|
-
|
|
1007
|
+
verb,
|
|
785
1008
|
await getDiffContextLines(),
|
|
1009
|
+
{
|
|
1010
|
+
separatorMoved:
|
|
1011
|
+
runtime.pieces.some((piece) => piece.kind === "insert") &&
|
|
1012
|
+
!runtime.pieces.some((piece) => piece.kind === "copy" || piece.kind === "move"),
|
|
1013
|
+
indentHints: !runtime.pieces.some((piece) => piece.kind === "copy" || piece.kind === "move"),
|
|
1014
|
+
},
|
|
786
1015
|
);
|
|
1016
|
+
|
|
1017
|
+
if (sourcePreparations.length > 0) {
|
|
1018
|
+
const patches = [changed.details.patch ?? ""];
|
|
1019
|
+
let patchTruncated = changed.details.patchTruncated === true;
|
|
1020
|
+
for (const preparation of sourcePreparations) {
|
|
1021
|
+
const sourcePatch = genPatch(preparation.move.displayPath, preparation.move.pipe.originalNormalized, preparation.move.pipe.result);
|
|
1022
|
+
if (sourcePatch.patch.length > 0) patches.push(sourcePatch.patch);
|
|
1023
|
+
if (sourcePatch.truncated) patchTruncated = true;
|
|
1024
|
+
}
|
|
1025
|
+
changed.details.patch = patches.filter((patch) => patch.length > 0).join("\n");
|
|
1026
|
+
if (patchTruncated) changed.details.patchTruncated = true;
|
|
1027
|
+
}
|
|
787
1028
|
if (changed.details.diff.length > 0) {
|
|
788
1029
|
changed.details.diff = `${header}\n${changed.details.diff}`;
|
|
789
1030
|
changed.details.diffLineNumbers?.unshift(null);
|
|
790
1031
|
}
|
|
791
1032
|
try {
|
|
792
|
-
|
|
1033
|
+
const wanted = servedHashesFromDiff(changed.details.diff);
|
|
1034
|
+
if (composed.length === 0) wanted.push(...resultHashes);
|
|
1035
|
+
serveRows(runtime.target, resultHashes, splitLines(composed), wanted);
|
|
793
1036
|
} catch (error) {
|
|
794
1037
|
console.error("Failed to mark batch diff served:", error);
|
|
795
1038
|
}
|
|
796
1039
|
const executed = runtime.applied + runtime.noops;
|
|
797
|
-
|
|
1040
|
+
const movedLines = sourcePreparations.reduce((total, preparation) => total + preparation.move.pipe.totalRemovedLines, 0);
|
|
1041
|
+
const sourceFiles = sourcePreparations.map((preparation) => preparation.move.displayPath);
|
|
1042
|
+
const sourceNote = sourceFiles.length > 0
|
|
1043
|
+
? `\nMoved ${movedLines} line(s) out of ${sourceFiles.join(", ")}; each source file keeps its own undo. Read ${sourceFiles.join(", ")} for fresh anchors.`
|
|
1044
|
+
: "";
|
|
1045
|
+
const undoNote = sourceFiles.length > 0 ? "one undo reverts the destination edits" : "one undo reverts them";
|
|
1046
|
+
changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; ${undoNote}.${sourceNote}`;
|
|
798
1047
|
changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
799
1048
|
return changed;
|
|
800
1049
|
}
|
package/src/commit.ts
CHANGED
|
@@ -153,8 +153,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
153
153
|
...(span ? { spans: [span] } : {}),
|
|
154
154
|
};
|
|
155
155
|
const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
|
|
156
|
-
if (changed.details.diff) {
|
|
157
|
-
|
|
156
|
+
if (changed.details.diff || pipe.result.length === 0) {
|
|
157
|
+
const wanted = servedHashesFromDiff(changed.details.diff);
|
|
158
|
+
if (pipe.result.length === 0) wanted.push(...resultHashes);
|
|
159
|
+
serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), wanted);
|
|
158
160
|
}
|
|
159
161
|
return changed;
|
|
160
162
|
}
|
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" | "copyMoveEnabled" | "
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "autoReadAllIgnore" | "anchorGrepEnabled" | "copyMoveEnabled" | "replaceMatchEnabled" | "requirePath" | "strictInput" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -26,7 +26,7 @@ export function configRows(config: Config): ConfigRow[] {
|
|
|
26
26
|
{ key: "copyMoveEnabled", label: "Copy/move", hint: "copy and move tools (both off while disabled)", enabled: config.copyMoveEnabled !== false },
|
|
27
27
|
{ key: "requirePath", label: "Require path", hint: "replace, insert, copy, move need path (RPC visibility)", enabled: config.requirePath === true },
|
|
28
28
|
{ key: "strictInput", label: "Strict input", hint: "Reject auto-fixable slips instead of warnings", enabled: config.strictInput === true },
|
|
29
|
-
{ key: "
|
|
29
|
+
{ key: "replaceMatchEnabled", label: "Replace match", hint: "replace_match tool (off while disabled)", enabled: config.replaceMatchEnabled !== false },
|
|
30
30
|
];
|
|
31
31
|
}
|
|
32
32
|
|