pi-hashline-edit-pro 4.2.10 → 4.3.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 +26 -19
- package/index.ts +29 -10
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/replace.md +1 -1
- package/src/anchor-registry.ts +21 -31
- package/src/auto-read-all.ts +305 -0
- package/src/batch.ts +172 -84
- package/src/commit.ts +26 -26
- package/src/config-ui.ts +2 -1
- package/src/config.ts +52 -29
- package/src/constants.ts +6 -0
- package/src/fs-write.ts +1 -1
- package/src/grep.ts +12 -18
- package/src/hash-store/cache.ts +0 -18
- package/src/hash-store/retry.ts +0 -4
- package/src/hash-store/validation.ts +0 -31
- package/src/hash-store.ts +22 -32
- package/src/hashline/apply.ts +2 -2
- package/src/hashline/hash.ts +7 -8
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +3 -16
- package/src/hashline/resolve.ts +19 -39
- package/src/insert.ts +2 -4
- package/src/payload-contract.ts +3 -11
- package/src/read.ts +3 -3
- package/src/replace-diff.ts +131 -107
- package/src/replace-render.ts +37 -4
- package/src/replace-response.ts +42 -13
- package/src/replace-undo.ts +10 -10
- package/src/replace.ts +33 -54
- package/src/served.ts +7 -3
- package/src/utils.ts +9 -8
- package/src/write-hook.ts +0 -4
- package/src/boundary-bypass.ts +0 -51
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,14 +16,13 @@ import {
|
|
|
16
16
|
type HEdit,
|
|
17
17
|
type PlannedEdit,
|
|
18
18
|
} from "./hashline";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { adoptAnchors, markServed, servedForPath } from "./anchor-registry";
|
|
19
|
+
import { boundaryDedupNoopWarning, boundaryDedupWarning, dedupRowsFromFixes } from "./commit";
|
|
20
|
+
import { adoptAnchors, servedForPath } from "./anchor-registry";
|
|
22
21
|
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
23
22
|
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
24
23
|
import { saveUndo } from "./replace-undo";
|
|
25
24
|
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
26
|
-
import {
|
|
25
|
+
import { serveRows, servedHashesFromDiff } from "./served";
|
|
27
26
|
import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
28
27
|
import { MAX_BYTES } from "./constants";
|
|
29
28
|
|
|
@@ -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),
|
|
@@ -664,7 +750,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
664
750
|
changed.details.diff = `${header}\n${changed.details.diff}`;
|
|
665
751
|
changed.details.diffLineNumbers?.unshift(undefined);
|
|
666
752
|
try {
|
|
667
|
-
|
|
753
|
+
serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
|
|
668
754
|
} catch (error) {
|
|
669
755
|
console.error("Failed to mark batch diff served:", error);
|
|
670
756
|
}
|
|
@@ -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
|
@@ -8,11 +8,10 @@ import { saveUndo } from "./replace-undo";
|
|
|
8
8
|
import { getDiffContextLines } from "./config";
|
|
9
9
|
import { safeSnapId } from "./file-reader";
|
|
10
10
|
import { writeAtomic } from "./fs-write";
|
|
11
|
-
import { servedHashesFromDiff,
|
|
12
|
-
import { lineHashes } from "./hashline";
|
|
13
|
-
import {
|
|
11
|
+
import { servedHashesFromDiff, serveRows } from "./served";
|
|
12
|
+
import { lineHashes, type AutoFix } from "./hashline";
|
|
13
|
+
import { spanForEdit } from "./replace";
|
|
14
14
|
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
15
|
-
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
15
|
export interface CommitMeta {
|
|
17
16
|
editAnchors?: [string, string];
|
|
18
17
|
path: string;
|
|
@@ -22,10 +21,7 @@ export interface CommitMeta {
|
|
|
22
21
|
verb?: string;
|
|
23
22
|
noopNoun?: string;
|
|
24
23
|
prefixWarnings?: string[];
|
|
25
|
-
appliedWarnings?: string[];
|
|
26
24
|
foldedAnchorLines?: number;
|
|
27
|
-
onApplied?: () => void;
|
|
28
|
-
onNoopDedup?: () => void;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
export function boundaryDedupWarning(count: number): string {
|
|
@@ -34,6 +30,21 @@ export function boundaryDedupWarning(count: number): string {
|
|
|
34
30
|
return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
|
|
35
31
|
}
|
|
36
32
|
|
|
33
|
+
export function boundaryDedupNoopWarning(orders: number[], removedLines: number): string {
|
|
34
|
+
const noun = removedLines === 1 ? "1 line" : `${removedLines} lines`;
|
|
35
|
+
if (orders.length === 1) {
|
|
36
|
+
return `Boundary dedup: edit #${orders[0]!} produced no change (${noun} not added again).`;
|
|
37
|
+
}
|
|
38
|
+
return `Boundary dedup: edits ${orders.map((order) => `#${order}`).join(", ")} produced no change (${noun} not added again).`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function dedupRowsFromFixes(fixes: AutoFix[]): { removedTexts: string[]; above: string[]; below: string[] } {
|
|
42
|
+
const sorted = [...fixes].sort((a, b) => a.removedLineIndex - b.removedLineIndex);
|
|
43
|
+
const above = sorted.filter((fix) => fix.kind === "leading" || fix.kind === "last-new-before").map((fix) => fix.removedLine);
|
|
44
|
+
const below = sorted.filter((fix) => fix.kind === "trailing" || fix.kind === "first-new-after").map((fix) => fix.removedLine);
|
|
45
|
+
return { removedTexts: sorted.map((fix) => fix.removedLine), above, below };
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promise<TResult> {
|
|
38
49
|
const { path, absolutePath, mutationTargetPath, signal } = meta;
|
|
39
50
|
const warnings = [...(meta.prefixWarnings ?? []), ...pipe.warnings];
|
|
@@ -41,7 +52,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
41
52
|
|
|
42
53
|
if (pipe.result === pipe.originalNormalized) {
|
|
43
54
|
const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
|
|
44
|
-
if (pipe.hadBoundaryDedup) meta.onNoopDedup?.();
|
|
45
55
|
return buildNoop(
|
|
46
56
|
{
|
|
47
57
|
path,
|
|
@@ -60,7 +70,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
60
70
|
);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
warnings.push(...(meta.appliedWarnings ?? []));
|
|
64
73
|
if (pipe.hadUtf8DecodeErrors) {
|
|
65
74
|
warnings.push(
|
|
66
75
|
"Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
|
|
@@ -110,7 +119,6 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
110
119
|
await undo.restore();
|
|
111
120
|
throw error;
|
|
112
121
|
}
|
|
113
|
-
meta.onApplied?.();
|
|
114
122
|
const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
|
|
115
123
|
|
|
116
124
|
const editMeta: RMeta = {
|
|
@@ -122,18 +130,14 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
122
130
|
removedLines: pipe.totalRemovedLines,
|
|
123
131
|
};
|
|
124
132
|
|
|
125
|
-
const span = meta.editAnchors ?
|
|
126
|
-
const resultCount = splitLines(pipe.result).length;
|
|
127
|
-
const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
|
|
133
|
+
const span = meta.editAnchors ? spanForEdit(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1], pipe.result) : undefined;
|
|
128
134
|
let resultHashes: string[];
|
|
129
135
|
try {
|
|
130
|
-
resultHashes = pipe.result
|
|
131
|
-
|
|
132
|
-
:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
|
|
136
|
-
});
|
|
136
|
+
resultHashes = await lineHashes(pipe.result, mutationTargetPath, {
|
|
137
|
+
content: pipe.originalNormalized,
|
|
138
|
+
hashes: pipe.originalHashes,
|
|
139
|
+
spans: span ? [span] : undefined,
|
|
140
|
+
});
|
|
137
141
|
} catch (error) {
|
|
138
142
|
const detail = error instanceof Error ? error.message : String(error);
|
|
139
143
|
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
@@ -149,15 +153,11 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
149
153
|
editMeta,
|
|
150
154
|
boundaryDedupAbove: pipe.boundaryDedupAbove,
|
|
151
155
|
boundaryDedupBelow: pipe.boundaryDedupBelow,
|
|
152
|
-
...(span ? { spans: [
|
|
156
|
+
...(span ? { spans: [span] } : {}),
|
|
153
157
|
};
|
|
154
158
|
const changed = buildChanged(successInput, meta.verb, await getDiffContextLines());
|
|
155
159
|
if (changed.details.diff) {
|
|
156
|
-
|
|
157
|
-
mutationTargetPath,
|
|
158
|
-
buildServedMap(resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff)),
|
|
159
|
-
new Set(resultHashes),
|
|
160
|
-
);
|
|
160
|
+
serveRows(mutationTargetPath, resultHashes, splitLines(pipe.result), servedHashesFromDiff(changed.details.diff));
|
|
161
161
|
}
|
|
162
162
|
return changed;
|
|
163
163
|
}
|
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" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
5
|
+
export type ConfigToggleKey = "autoRead" | "autoReadAll" | "anchorGrepEnabled" | "requirePath" | "strictInput" | "boundaryDedupMode" | "diffContextLines";
|
|
6
6
|
|
|
7
7
|
export interface ConfigRow {
|
|
8
8
|
key: ConfigToggleKey;
|
|
@@ -18,6 +18,7 @@ export interface ConfigRow {
|
|
|
18
18
|
export function configRows(config: Config): ConfigRow[] {
|
|
19
19
|
return [
|
|
20
20
|
{ key: "autoRead", label: "Auto-read", hint: "Anchors after write + post-edit diffs", enabled: config.autoRead !== false },
|
|
21
|
+
{ key: "autoReadAll", label: "Auto-read all", hint: "Attach every non-ignored file with anchors on the first turn", enabled: config.autoReadAll === true },
|
|
21
22
|
{ key: "diffContextLines", label: "Diff context", hint: "Surrounding lines in post-edit diffs (needs Auto-read)", enabled: config.autoRead !== false, value: config.diffContextLines ?? 1, disabled: config.autoRead === false },
|
|
22
23
|
{ key: "anchorGrepEnabled", label: "Anchor grep", hint: "anchor_grep tool (builtin grep off while on)", enabled: config.anchorGrepEnabled === true },
|
|
23
24
|
{ key: "requirePath", label: "Require path", hint: "replace + insert need path (RPC visibility)", enabled: config.requirePath === true },
|