pi-hashline-edit-pro 4.0.1 → 4.2.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 +17 -12
- package/index.ts +70 -23
- package/package.json +1 -1
- package/prompts/insert-guidelines.md +2 -1
- package/prompts/insert.md +1 -1
- package/prompts/read-guidelines.md +1 -0
- package/prompts/read.md +1 -1
- package/prompts/replace-guidelines.md +5 -5
- package/prompts/replace.md +1 -1
- package/src/anchor-registry.ts +11 -8
- package/src/batch.ts +614 -0
- package/src/boundary-bypass.ts +6 -0
- package/src/commit.ts +1 -1
- package/src/config-ui.ts +108 -0
- package/src/config.ts +48 -1
- package/src/edit-common.ts +115 -1
- package/src/fs-write.ts +6 -0
- package/src/grep.ts +84 -1
- package/src/hashline/alphabet.ts +1 -1
- package/src/hashline/apply.ts +128 -84
- package/src/hashline/index.ts +3 -0
- package/src/hashline/resolve.ts +5 -4
- package/src/insert.ts +127 -74
- package/src/payload-contract.ts +50 -2
- package/src/read.ts +10 -11
- package/src/replace-render.ts +2 -2
- package/src/replace-response.ts +3 -1
- package/src/replace.ts +94 -37
package/src/batch.ts
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { constants } from "fs";
|
|
3
|
+
import { relative } from "path";
|
|
4
|
+
import { readConfig } from "./config";
|
|
5
|
+
import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
|
|
6
|
+
import { readNormFile, safeSnapId } from "./file-reader";
|
|
7
|
+
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
8
|
+
import {
|
|
9
|
+
AnchorMismatchError,
|
|
10
|
+
RangeStaleError,
|
|
11
|
+
assertNotEmpty,
|
|
12
|
+
changedRange,
|
|
13
|
+
lineHashes,
|
|
14
|
+
planEdit,
|
|
15
|
+
MAX_HASH_LINES,
|
|
16
|
+
type HEdit,
|
|
17
|
+
type PlannedEdit,
|
|
18
|
+
} from "./hashline";
|
|
19
|
+
import { clearBoundaryBypass, markBoundaryNoop } from "./boundary-bypass";
|
|
20
|
+
import { boundaryDedupWarning } from "./commit";
|
|
21
|
+
import { adoptAnchors, markServed, servedForPath } from "./anchor-registry";
|
|
22
|
+
import { restoreEndings, stripBOM, toLF, type LineEnding } from "./normalize";
|
|
23
|
+
import { assertInsertReq, assertReq, normReq } from "./payload-contract";
|
|
24
|
+
import { saveUndo } from "./replace-undo";
|
|
25
|
+
import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
|
|
26
|
+
import { buildServedMap, servedHashesFromDiff } from "./served";
|
|
27
|
+
import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
|
|
28
|
+
import { MAX_BYTES } from "./constants";
|
|
29
|
+
|
|
30
|
+
export interface PlannedMember {
|
|
31
|
+
batchKey: number;
|
|
32
|
+
display: number;
|
|
33
|
+
total: number;
|
|
34
|
+
target: string;
|
|
35
|
+
kind: BatchKind;
|
|
36
|
+
args: unknown;
|
|
37
|
+
order: number;
|
|
38
|
+
size: number;
|
|
39
|
+
last: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type BatchKind = "replace" | "insert";
|
|
43
|
+
|
|
44
|
+
interface BatchBase {
|
|
45
|
+
content: string;
|
|
46
|
+
hashes: string[];
|
|
47
|
+
bom: string;
|
|
48
|
+
ending: LineEnding;
|
|
49
|
+
identity: FileIdentity;
|
|
50
|
+
hadUtf8DecodeErrors: boolean;
|
|
51
|
+
absolutePath: string;
|
|
52
|
+
snapshotId?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BatchPiece {
|
|
56
|
+
order: number;
|
|
57
|
+
kind: BatchKind;
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
fromHash: string;
|
|
61
|
+
toHash: string;
|
|
62
|
+
newLines: string[];
|
|
63
|
+
warnings: string[];
|
|
64
|
+
autoFixes: number;
|
|
65
|
+
noop: boolean;
|
|
66
|
+
noopPayload?: string;
|
|
67
|
+
foldedLines: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BatchMemberInput {
|
|
71
|
+
kind: BatchKind;
|
|
72
|
+
member: PlannedMember;
|
|
73
|
+
targetPath: string;
|
|
74
|
+
mutationTargetPath: string;
|
|
75
|
+
cwd: string;
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
hedit: HEdit;
|
|
78
|
+
extraWarnings: string[];
|
|
79
|
+
skipBoundaryDedup: boolean;
|
|
80
|
+
strictBoundaryDedup: boolean;
|
|
81
|
+
noopPayload?: string;
|
|
82
|
+
foldedLines?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface BatchState {
|
|
86
|
+
display: number;
|
|
87
|
+
target: string;
|
|
88
|
+
memberIds: string[];
|
|
89
|
+
replaceCount: number;
|
|
90
|
+
insertCount: number;
|
|
91
|
+
base?: BatchBase;
|
|
92
|
+
paths?: { absolutePath: string; mutationTargetPath: string; displayPath: string };
|
|
93
|
+
served?: ReadonlyMap<string, string>;
|
|
94
|
+
pieces: BatchPiece[];
|
|
95
|
+
applied: number;
|
|
96
|
+
noops: number;
|
|
97
|
+
failures: number;
|
|
98
|
+
failed: boolean;
|
|
99
|
+
firstError?: unknown;
|
|
100
|
+
warnings: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface EditCall {
|
|
104
|
+
id: string;
|
|
105
|
+
name: string;
|
|
106
|
+
args: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type NormalizedEditArgs =
|
|
110
|
+
| { kind: "replace"; removeFrom: string; removeTo?: string; path?: string }
|
|
111
|
+
| { kind: "insert"; anchor: string; path?: string };
|
|
112
|
+
|
|
113
|
+
const MAX_TRACKED_BATCHES = 256;
|
|
114
|
+
|
|
115
|
+
const plan = new Map<string, PlannedMember>();
|
|
116
|
+
const batches = new Map<number, BatchState>();
|
|
117
|
+
let nextBatchKey = 1;
|
|
118
|
+
|
|
119
|
+
export function batchMemberFor(toolCallId: string): PlannedMember | undefined {
|
|
120
|
+
return plan.get(toolCallId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resetBatchStateForTests(): void {
|
|
124
|
+
plan.clear();
|
|
125
|
+
batches.clear();
|
|
126
|
+
nextBatchKey = 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
|
|
130
|
+
if (!isRec(args)) return undefined;
|
|
131
|
+
const normalized = normReq(args);
|
|
132
|
+
if (!isRec(normalized)) return undefined;
|
|
133
|
+
const path = typeof normalized.path === "string" ? normalized.path : undefined;
|
|
134
|
+
if (typeof normalized.remove_from === "string") {
|
|
135
|
+
return {
|
|
136
|
+
kind: "replace",
|
|
137
|
+
removeFrom: normalized.remove_from,
|
|
138
|
+
...(typeof normalized.remove_to === "string" ? { removeTo: normalized.remove_to } : {}),
|
|
139
|
+
...(path ? { path } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (typeof normalized.anchor === "string") {
|
|
143
|
+
return { kind: "insert", anchor: normalized.anchor, ...(path ? { path } : {}) };
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function anchorTargetFor(args: unknown): string | undefined {
|
|
149
|
+
const normalized = normalizeEditArgs(args);
|
|
150
|
+
if (!normalized) return undefined;
|
|
151
|
+
if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
|
|
152
|
+
return tryResolveEditTarget(normalized.anchor);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function verifyPaths(
|
|
156
|
+
group: Array<{ id: string; target: string; kind: BatchKind; args: unknown }>,
|
|
157
|
+
cwd: string,
|
|
158
|
+
): Promise<Array<{ id: string; target: string; kind: BatchKind; args: unknown }>> {
|
|
159
|
+
const verified: Array<{ id: string; target: string; kind: BatchKind; args: unknown }> = [];
|
|
160
|
+
for (const item of group) {
|
|
161
|
+
const normalized = normalizeEditArgs(item.args);
|
|
162
|
+
if (!normalized || !normalized.path) continue;
|
|
163
|
+
let resolved: string | undefined;
|
|
164
|
+
try {
|
|
165
|
+
resolved = (await resolveInCwd(normalized.path, cwd)).resolved;
|
|
166
|
+
} catch {
|
|
167
|
+
resolved = undefined;
|
|
168
|
+
}
|
|
169
|
+
if (resolved === item.target) verified.push(item);
|
|
170
|
+
}
|
|
171
|
+
return verified;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function enforceCap(): void {
|
|
175
|
+
while (batches.size > MAX_TRACKED_BATCHES) {
|
|
176
|
+
const oldest = batches.keys().next().value;
|
|
177
|
+
if (oldest === undefined) return;
|
|
178
|
+
const state = batches.get(oldest);
|
|
179
|
+
batches.delete(oldest);
|
|
180
|
+
if (state) for (const id of state.memberIds) plan.delete(id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function planAssistantMessage(message: unknown, cwd: string): Promise<void> {
|
|
185
|
+
if (!isRec(message) || message.role !== "assistant" || !Array.isArray(message.content)) return;
|
|
186
|
+
const calls: EditCall[] = [];
|
|
187
|
+
for (const block of message.content) {
|
|
188
|
+
if (!isRec(block) || block.type !== "toolCall") continue;
|
|
189
|
+
if (block.name !== "replace" && block.name !== "insert") continue;
|
|
190
|
+
if (typeof block.id !== "string") continue;
|
|
191
|
+
calls.push({ id: block.id, name: block.name, args: block.arguments });
|
|
192
|
+
}
|
|
193
|
+
if (calls.length < 2) return;
|
|
194
|
+
interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown }
|
|
195
|
+
const resolved: ResolvedCall[] = [];
|
|
196
|
+
for (const call of calls) {
|
|
197
|
+
const target = anchorTargetFor(call.args);
|
|
198
|
+
if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args });
|
|
199
|
+
}
|
|
200
|
+
const groups = new Map<string, ResolvedCall[]>();
|
|
201
|
+
for (const item of resolved) {
|
|
202
|
+
const group = groups.get(item.target) ?? [];
|
|
203
|
+
group.push(item);
|
|
204
|
+
groups.set(item.target, group);
|
|
205
|
+
}
|
|
206
|
+
const multi = [...groups.values()].filter((group) => group.length >= 2);
|
|
207
|
+
if (multi.length === 0) return;
|
|
208
|
+
const config = await readConfig();
|
|
209
|
+
const finalGroups: ResolvedCall[][] = [];
|
|
210
|
+
if (config.requirePath === true) {
|
|
211
|
+
for (const group of multi) {
|
|
212
|
+
const verified = await verifyPaths(group, cwd);
|
|
213
|
+
if (verified.length >= 2) finalGroups.push(verified);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
finalGroups.push(...multi);
|
|
217
|
+
}
|
|
218
|
+
if (finalGroups.length === 0) return;
|
|
219
|
+
let display = 0;
|
|
220
|
+
for (const group of finalGroups) {
|
|
221
|
+
display += 1;
|
|
222
|
+
const key = nextBatchKey++;
|
|
223
|
+
batches.set(key, {
|
|
224
|
+
display,
|
|
225
|
+
target: group[0]!.target,
|
|
226
|
+
memberIds: group.map((item) => item.id),
|
|
227
|
+
replaceCount: 0,
|
|
228
|
+
insertCount: 0,
|
|
229
|
+
pieces: [],
|
|
230
|
+
applied: 0,
|
|
231
|
+
noops: 0,
|
|
232
|
+
failures: 0,
|
|
233
|
+
failed: false,
|
|
234
|
+
warnings: [],
|
|
235
|
+
});
|
|
236
|
+
group.forEach((item, index) => {
|
|
237
|
+
plan.set(item.id, {
|
|
238
|
+
batchKey: key,
|
|
239
|
+
display,
|
|
240
|
+
total: finalGroups.length,
|
|
241
|
+
target: item.target,
|
|
242
|
+
kind: item.kind,
|
|
243
|
+
args: item.args,
|
|
244
|
+
order: index + 1,
|
|
245
|
+
size: group.length,
|
|
246
|
+
last: index === group.length - 1,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
enforceCap();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function dedupeWarnings(warnings: string[]): string[] {
|
|
254
|
+
const seen = new Set<string>();
|
|
255
|
+
const out: string[] = [];
|
|
256
|
+
for (const warning of warnings) {
|
|
257
|
+
if (seen.has(warning)) continue;
|
|
258
|
+
seen.add(warning);
|
|
259
|
+
out.push(warning);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function batchVerb(runtime: BatchState): string {
|
|
265
|
+
if (runtime.replaceCount > 0 && runtime.insertCount > 0) return "edited";
|
|
266
|
+
if (runtime.insertCount > 0) return "inserted";
|
|
267
|
+
return "replaced";
|
|
268
|
+
}
|
|
269
|
+
function batchHeader(member: PlannedMember): string {
|
|
270
|
+
return member.total > 1 ? `batch ${member.display}:` : "batch:";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function formatBatchLines(start: number, end: number): string {
|
|
274
|
+
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatBatchPiece(piece: BatchPiece): string {
|
|
278
|
+
const lines = formatBatchLines(piece.start, piece.end);
|
|
279
|
+
if (piece.kind === "insert") return `edit #${piece.order} (insert at ${piece.fromHash}, ${lines})`;
|
|
280
|
+
if (piece.fromHash === piece.toHash) return `edit #${piece.order} (replace ${piece.fromHash}, ${lines})`;
|
|
281
|
+
return `edit #${piece.order} (replace ${piece.fromHash}→${piece.toHash}, ${lines})`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId: string | undefined): TResult {
|
|
285
|
+
const grossAdded = Math.max(0, piece.newLines.length - piece.autoFixes);
|
|
286
|
+
const added = piece.kind === "insert" ? Math.max(0, grossAdded - piece.foldedLines) : grossAdded;
|
|
287
|
+
const metrics: RMetrics = {
|
|
288
|
+
edits_attempted: 1,
|
|
289
|
+
edits_noop: piece.noop ? 1 : 0,
|
|
290
|
+
warnings: 0,
|
|
291
|
+
classification: piece.noop ? "noop" : "applied",
|
|
292
|
+
...(piece.noop ? {} : { added_lines: added, removed_lines: piece.end - piece.start + 1 }),
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
content: [
|
|
296
|
+
{
|
|
297
|
+
type: "text",
|
|
298
|
+
text: member.total > 1 ? `In batch ${member.display}` : "In batch",
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
details: {
|
|
302
|
+
diff: "",
|
|
303
|
+
patch: "",
|
|
304
|
+
...(piece.noop ? {} : { firstChangedLine: piece.start }),
|
|
305
|
+
...(snapshotId !== undefined ? { snapshotId } : {}),
|
|
306
|
+
...(piece.noop ? { classification: "noop" as const } : {}),
|
|
307
|
+
metrics,
|
|
308
|
+
batch: { id: member.display, size: member.size, last: false, total: member.total },
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function noteBatchFailure(member: PlannedMember, error: unknown): void {
|
|
314
|
+
const runtime = batches.get(member.batchKey);
|
|
315
|
+
if (!runtime) return;
|
|
316
|
+
runtime.failures += 1;
|
|
317
|
+
if (!runtime.failed) {
|
|
318
|
+
runtime.failed = true;
|
|
319
|
+
runtime.firstError = error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function batchAbortedError(runtime: BatchState): Error {
|
|
324
|
+
const first = runtime.firstError instanceof Error ? runtime.firstError.message : String(runtime.firstError);
|
|
325
|
+
return new Error(`[E_BATCH_ABORTED] Batch ${runtime.display} aborted; nothing was written. First failure: ${first}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function ensureBatchBase(input: {
|
|
329
|
+
member: PlannedMember;
|
|
330
|
+
targetPath: string;
|
|
331
|
+
mutationTargetPath: string;
|
|
332
|
+
cwd: string;
|
|
333
|
+
signal?: AbortSignal;
|
|
334
|
+
}): Promise<BatchBase> {
|
|
335
|
+
const runtime = batches.get(input.member.batchKey);
|
|
336
|
+
if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
337
|
+
if (runtime.base) return runtime.base;
|
|
338
|
+
abortIf(input.signal);
|
|
339
|
+
const file = await readNormFile(input.targetPath, input.cwd, {
|
|
340
|
+
signal: input.signal,
|
|
341
|
+
accessMode: constants.R_OK | constants.W_OK,
|
|
342
|
+
maxLines: MAX_HASH_LINES,
|
|
343
|
+
});
|
|
344
|
+
const snapshotId = await safeSnapId(file.absolutePath, "batch edit");
|
|
345
|
+
const base: BatchBase = {
|
|
346
|
+
content: file.normalized,
|
|
347
|
+
hashes: file.fileHashes.slice(),
|
|
348
|
+
bom: file.bom,
|
|
349
|
+
ending: file.originalEnding,
|
|
350
|
+
identity: file.identity,
|
|
351
|
+
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors,
|
|
352
|
+
absolutePath: file.absolutePath,
|
|
353
|
+
...(snapshotId !== undefined ? { snapshotId } : {}),
|
|
354
|
+
};
|
|
355
|
+
runtime.base = base;
|
|
356
|
+
runtime.served = servedForPath(file.absolutePath);
|
|
357
|
+
runtime.paths = {
|
|
358
|
+
absolutePath: file.absolutePath,
|
|
359
|
+
mutationTargetPath: input.mutationTargetPath,
|
|
360
|
+
displayPath: relative(input.cwd, file.absolutePath).replace(/\\/g, "/") || input.targetPath,
|
|
361
|
+
};
|
|
362
|
+
return base;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function executeBatchMember(input: BatchMemberInput): Promise<TResult> {
|
|
366
|
+
const runtime = batches.get(input.member.batchKey);
|
|
367
|
+
if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
368
|
+
if (runtime.failed) throw batchAbortedError(runtime);
|
|
369
|
+
let base: BatchBase;
|
|
370
|
+
try {
|
|
371
|
+
base = await ensureBatchBase({
|
|
372
|
+
member: input.member,
|
|
373
|
+
targetPath: input.targetPath,
|
|
374
|
+
mutationTargetPath: input.mutationTargetPath,
|
|
375
|
+
cwd: input.cwd,
|
|
376
|
+
signal: input.signal,
|
|
377
|
+
});
|
|
378
|
+
} catch (error) {
|
|
379
|
+
noteBatchFailure(input.member, error);
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
if (input.mutationTargetPath !== input.member.target) {
|
|
383
|
+
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.`);
|
|
384
|
+
noteBatchFailure(input.member, error);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
const displayPath = runtime.paths?.displayPath ?? input.targetPath;
|
|
388
|
+
let planned: PlannedEdit;
|
|
389
|
+
try {
|
|
390
|
+
planned = planEdit(base.content, input.hedit, base.hashes, {
|
|
391
|
+
filePath: displayPath,
|
|
392
|
+
servedHashes: runtime.served,
|
|
393
|
+
skipBoundaryDedup: input.skipBoundaryDedup,
|
|
394
|
+
strictBoundaryDedup: input.strictBoundaryDedup,
|
|
395
|
+
signal: input.signal,
|
|
396
|
+
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
|
|
399
|
+
else if (error instanceof AnchorMismatchError) adoptAnchors(base.absolutePath, error.feedbackMap);
|
|
400
|
+
else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
|
|
401
|
+
const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
|
|
402
|
+
noteBatchFailure(input.member, indexed);
|
|
403
|
+
throw indexed;
|
|
404
|
+
}
|
|
405
|
+
noteBatchFailure(input.member, error);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
const start = planned.resolved.hash_bounds[0].line;
|
|
409
|
+
const end = planned.resolved.hash_bounds[1].line;
|
|
410
|
+
const newLines = planned.resolved.content_lines;
|
|
411
|
+
const baseLines = splitLines(base.content);
|
|
412
|
+
const originalSlice = baseLines.slice(start - 1, end);
|
|
413
|
+
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
414
|
+
const autoFixes = planned.autoFixes?.length ?? 0;
|
|
415
|
+
const piece: BatchPiece = {
|
|
416
|
+
order: input.member.order,
|
|
417
|
+
kind: input.kind,
|
|
418
|
+
start,
|
|
419
|
+
end,
|
|
420
|
+
fromHash: input.hedit.hash_bounds[0].hash,
|
|
421
|
+
toHash: input.hedit.hash_bounds[1].hash,
|
|
422
|
+
newLines: [...newLines],
|
|
423
|
+
warnings: [...input.extraWarnings, ...planned.warnings],
|
|
424
|
+
autoFixes,
|
|
425
|
+
noop,
|
|
426
|
+
...(input.noopPayload !== undefined ? { noopPayload: input.noopPayload } : {}),
|
|
427
|
+
foldedLines: input.foldedLines ?? 0,
|
|
428
|
+
};
|
|
429
|
+
runtime.pieces.push(piece);
|
|
430
|
+
if (input.kind === "replace") runtime.replaceCount += 1;
|
|
431
|
+
else runtime.insertCount += 1;
|
|
432
|
+
if (noop) runtime.noops += 1;
|
|
433
|
+
else runtime.applied += 1;
|
|
434
|
+
runtime.warnings.push(...piece.warnings);
|
|
435
|
+
if (!input.member.last) return batchPlaceholder(input.member, piece, base.snapshotId);
|
|
436
|
+
return finishBatch(input.member, input.signal);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function composeBatchLines(baseContent: string, pieces: BatchPiece[]): string {
|
|
440
|
+
const lines = splitLines(baseContent);
|
|
441
|
+
const descending = [...pieces].sort((a, b) => b.start - a.start);
|
|
442
|
+
for (const piece of descending) lines.splice(piece.start - 1, piece.end - piece.start + 1, ...piece.newLines);
|
|
443
|
+
let composed = lines.join("\n");
|
|
444
|
+
if (lines.length > 0 && baseContent.endsWith("\n")) composed += "\n";
|
|
445
|
+
return composed;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise<TResult> {
|
|
449
|
+
const runtime = batches.get(member.batchKey);
|
|
450
|
+
if (!runtime || !runtime.base || !runtime.paths) throw new Error(`[E_STALE_ANCHOR] Batch ${member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
451
|
+
const base = runtime.base;
|
|
452
|
+
const paths = runtime.paths;
|
|
453
|
+
if (runtime.failed) throw batchAbortedError(runtime);
|
|
454
|
+
for (const id of runtime.memberIds) {
|
|
455
|
+
const planned = plan.get(id);
|
|
456
|
+
if (!planned) continue;
|
|
457
|
+
try {
|
|
458
|
+
const normalized = normReq(planned.args);
|
|
459
|
+
if (planned.kind === "replace") assertReq(normalized);
|
|
460
|
+
else assertInsertReq(normalized);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
noteBatchFailure(member, error);
|
|
463
|
+
throw batchAbortedError(runtime);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const appliedPieces = runtime.pieces.filter((piece) => !piece.noop);
|
|
467
|
+
if (appliedPieces.length === 0) {
|
|
468
|
+
for (const piece of runtime.pieces) {
|
|
469
|
+
if (piece.autoFixes > 0 && piece.noopPayload !== undefined) markBoundaryNoop(runtime.target, piece.noopPayload);
|
|
470
|
+
}
|
|
471
|
+
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
472
|
+
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
473
|
+
}
|
|
474
|
+
const ordered = [...appliedPieces].sort((a, b) => a.start - b.start);
|
|
475
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
476
|
+
const prev = ordered[i - 1]!;
|
|
477
|
+
const current = ordered[i]!;
|
|
478
|
+
if (current.start <= prev.end) {
|
|
479
|
+
throw new Error(`[E_BATCH_OVERLAP] Batch ${runtime.display} has overlapping ranges: ${formatBatchPiece(prev)} overlaps ${formatBatchPiece(current)}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const warnings = [...runtime.warnings];
|
|
483
|
+
if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
484
|
+
const dedupTotal = runtime.pieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
|
|
485
|
+
if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
|
|
486
|
+
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
487
|
+
const composed = composeBatchLines(base.content, appliedPieces);
|
|
488
|
+
assertNotEmpty(base.content, composed);
|
|
489
|
+
assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
|
|
490
|
+
const finalBytes = base.bom + restoreEndings(composed, base.ending);
|
|
491
|
+
if (Buffer.byteLength(finalBytes, "utf-8") > MAX_BYTES) {
|
|
492
|
+
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.`);
|
|
493
|
+
}
|
|
494
|
+
if (composed === base.content) {
|
|
495
|
+
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
496
|
+
return combinedNoop(paths.displayPath, member, runtime, snapshotId);
|
|
497
|
+
}
|
|
498
|
+
abortIf(signal);
|
|
499
|
+
let currentRaw: string;
|
|
500
|
+
try {
|
|
501
|
+
currentRaw = await readFile(runtime.target, "utf-8");
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
504
|
+
throw new Error(`[E_BATCH_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started; nothing was written.`);
|
|
505
|
+
}
|
|
506
|
+
if (toLF(stripBOM(currentRaw).text) !== base.content) {
|
|
507
|
+
throw new Error(`[E_BATCH_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started; nothing was written. Call read for fresh anchors and retry.`);
|
|
508
|
+
}
|
|
509
|
+
const undo = await saveUndo(runtime.target, {
|
|
510
|
+
content: base.content,
|
|
511
|
+
bom: base.bom,
|
|
512
|
+
originalEnding: base.ending,
|
|
513
|
+
hashes: base.hashes,
|
|
514
|
+
resultContent: composed,
|
|
515
|
+
});
|
|
516
|
+
if (!undo.persisted) {
|
|
517
|
+
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history; the edit was not applied and ${paths.displayPath} is unchanged.`);
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
abortIf(signal);
|
|
521
|
+
await writeAtomic(paths.absolutePath, base.bom + restoreEndings(composed, base.ending), base.identity);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
await undo.restore();
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
clearBoundaryBypass(runtime.target);
|
|
527
|
+
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
528
|
+
const spans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
|
|
529
|
+
const resultHashes = await lineHashes(composed, runtime.target, {
|
|
530
|
+
content: base.content,
|
|
531
|
+
hashes: base.hashes,
|
|
532
|
+
spans,
|
|
533
|
+
});
|
|
534
|
+
const range = changedRange(base.content, composed);
|
|
535
|
+
let added = 0;
|
|
536
|
+
let removed = 0;
|
|
537
|
+
for (const piece of appliedPieces) {
|
|
538
|
+
removed += piece.end - piece.start + 1;
|
|
539
|
+
const gross = Math.max(0, piece.newLines.length - piece.autoFixes);
|
|
540
|
+
added += piece.kind === "insert" ? Math.max(0, gross - piece.foldedLines) : gross;
|
|
541
|
+
}
|
|
542
|
+
const header = batchHeader(member);
|
|
543
|
+
const changed = buildChanged(
|
|
544
|
+
{
|
|
545
|
+
path: paths.displayPath,
|
|
546
|
+
originalNormalized: base.content,
|
|
547
|
+
originalHashes: base.hashes,
|
|
548
|
+
result: composed,
|
|
549
|
+
resultHashes,
|
|
550
|
+
warnings: dedupeWarnings(warnings),
|
|
551
|
+
snapshotId: updatedSnapshotId,
|
|
552
|
+
editMeta: {
|
|
553
|
+
editsAttempted: runtime.applied + runtime.noops,
|
|
554
|
+
noopEditsCount: runtime.noops,
|
|
555
|
+
firstChangedLine: range?.firstChangedLine,
|
|
556
|
+
lastChangedLine: range?.lastChangedLine,
|
|
557
|
+
addedLines: added,
|
|
558
|
+
removedLines: removed,
|
|
559
|
+
},
|
|
560
|
+
boundaryDedupAbove: [],
|
|
561
|
+
boundaryDedupBelow: [],
|
|
562
|
+
},
|
|
563
|
+
batchVerb(runtime),
|
|
564
|
+
);
|
|
565
|
+
changed.details.diff = `${header}\n${changed.details.diff}`;
|
|
566
|
+
changed.details.diffLineNumbers?.unshift(undefined);
|
|
567
|
+
try {
|
|
568
|
+
markServed(runtime.target, buildServedMap(resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff)), new Set(resultHashes));
|
|
569
|
+
} catch (error) {
|
|
570
|
+
console.error("Failed to mark batch diff served:", error);
|
|
571
|
+
}
|
|
572
|
+
const executed = runtime.applied + runtime.noops;
|
|
573
|
+
changed.content[0]!.text = `${header}\n${changed.content[0]!.text}\nBatch ${member.display}: ${executed} edit${executed === 1 ? "" : "s"} applied as one commit; one undo reverts them.`;
|
|
574
|
+
changed.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
575
|
+
return changed;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function combinedNoop(path: string, member: PlannedMember, runtime: BatchState, snapshotId: string | undefined): Promise<TResult> {
|
|
579
|
+
const executed = runtime.applied + runtime.noops;
|
|
580
|
+
const noop = buildNoop(
|
|
581
|
+
{
|
|
582
|
+
path,
|
|
583
|
+
noopEdit: undefined,
|
|
584
|
+
snapshotId,
|
|
585
|
+
editMeta: {
|
|
586
|
+
editsAttempted: executed,
|
|
587
|
+
noopEditsCount: runtime.noops,
|
|
588
|
+
addedLines: 0,
|
|
589
|
+
removedLines: 0,
|
|
590
|
+
},
|
|
591
|
+
warnings: dedupeWarnings(runtime.warnings),
|
|
592
|
+
boundaryRemovedLines: 0,
|
|
593
|
+
},
|
|
594
|
+
"Batch",
|
|
595
|
+
);
|
|
596
|
+
noop.content[0]!.text += `\nBatch ${member.display}: ${executed} edits produced no net change; undo history preserved.`;
|
|
597
|
+
noop.details.batch = { id: member.display, size: member.size, last: true, total: member.total };
|
|
598
|
+
return noop;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
|
|
603
|
+
const keys = new Set<number>();
|
|
604
|
+
for (const id of toolCallIds) {
|
|
605
|
+
const member = plan.get(id);
|
|
606
|
+
if (member) keys.add(member.batchKey);
|
|
607
|
+
}
|
|
608
|
+
for (const key of keys) {
|
|
609
|
+
const runtime = batches.get(key);
|
|
610
|
+
if (!runtime) continue;
|
|
611
|
+
for (const id of runtime.memberIds) plan.delete(id);
|
|
612
|
+
batches.delete(key);
|
|
613
|
+
}
|
|
614
|
+
}
|
package/src/boundary-bypass.ts
CHANGED
|
@@ -13,6 +13,7 @@ function canonLines(lines: string[]): string[] {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const boundaryBypassTracker = new Map<string, string>();
|
|
16
|
+
const BOUNDARY_BYPASS_LIMIT = 256;
|
|
16
17
|
|
|
17
18
|
export function noopPayloadKey(
|
|
18
19
|
absolutePath: string,
|
|
@@ -29,7 +30,12 @@ export function noopPayloadKey(
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function markBoundaryNoop(absolutePath: string, payload: string): void {
|
|
33
|
+
boundaryBypassTracker.delete(absolutePath);
|
|
32
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
|
+
}
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function consumeBoundaryBypass(absolutePath: string, payload: string): boolean {
|
package/src/commit.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface CommitMeta {
|
|
|
27
27
|
onNoopDedup?: () => void;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function boundaryDedupWarning(count: number): string {
|
|
30
|
+
export function boundaryDedupWarning(count: number): string {
|
|
31
31
|
const noun = count === 1 ? "1 line" : `${count} lines`;
|
|
32
32
|
const row = count === 1 ? "row" : "rows";
|
|
33
33
|
return `Boundary dedup: ${noun} not added again (see ${DEDUP_ANCHOR}${HASH_SEP} ${row}).`;
|