@kenkaiiii/ggcoder 5.72.0 → 5.74.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/assets/skills/refactoring/SKILL.md +3 -1
- package/dist/app-error.d.ts +21 -0
- package/dist/app-error.js +39 -0
- package/dist/app-error.js.map +1 -0
- package/dist/app-sidecar.js +39 -35
- package/dist/app-sidecar.js.map +1 -1
- package/dist/core/agent-session.d.ts +2 -2
- package/dist/core/agent-session.js +17 -7
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/checklist-detection.d.ts +11 -0
- package/dist/core/checklist-detection.js +238 -0
- package/dist/core/checklist-detection.js.map +1 -0
- package/dist/core/checklist-items.d.ts +180 -0
- package/dist/core/checklist-items.js +204 -0
- package/dist/core/checklist-items.js.map +1 -0
- package/dist/core/checklist-prompt.d.ts +3 -0
- package/dist/core/checklist-prompt.js +34 -0
- package/dist/core/checklist-prompt.js.map +1 -0
- package/dist/core/checklist-snapshot.d.ts +13 -0
- package/dist/core/checklist-snapshot.js +29 -0
- package/dist/core/checklist-snapshot.js.map +1 -0
- package/dist/core/checklist-store.d.ts +59 -0
- package/dist/core/checklist-store.js +242 -0
- package/dist/core/checklist-store.js.map +1 -0
- package/dist/core/compaction/compactor.js +6 -0
- package/dist/core/compaction/compactor.js.map +1 -1
- package/dist/core/ken-context.js +7 -3
- package/dist/core/ken-context.js.map +1 -1
- package/dist/core/lsp/client.d.ts +4 -0
- package/dist/core/lsp/client.js +71 -3
- package/dist/core/lsp/client.js.map +1 -1
- package/dist/core/lsp/manager.d.ts +1 -0
- package/dist/core/lsp/manager.js +9 -3
- package/dist/core/lsp/manager.js.map +1 -1
- package/dist/core/lsp/project-root.d.ts +9 -0
- package/dist/core/lsp/project-root.js +75 -0
- package/dist/core/lsp/project-root.js.map +1 -0
- package/dist/core/lsp/reference-project.d.ts +6 -0
- package/dist/core/lsp/reference-project.js +110 -0
- package/dist/core/lsp/reference-project.js.map +1 -0
- package/dist/core/lsp/servers.js +1 -0
- package/dist/core/lsp/servers.js.map +1 -1
- package/dist/core/subagent-receipt.js +3 -1
- package/dist/core/subagent-receipt.js.map +1 -1
- package/dist/modes/acp-mode.d.ts +2 -1
- package/dist/modes/acp-mode.js +4 -1
- package/dist/modes/acp-mode.js.map +1 -1
- package/dist/motion-agent/motion-review-session.js +7 -3
- package/dist/motion-agent/motion-review-session.js.map +1 -1
- package/dist/system-prompt.js +14 -13
- package/dist/system-prompt.js.map +1 -1
- package/dist/tools/bash.d.ts +4 -1
- package/dist/tools/bash.js +32 -7
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/checklist.d.ts +56 -0
- package/dist/tools/checklist.js +178 -0
- package/dist/tools/checklist.js.map +1 -0
- package/dist/tools/code-nav.d.ts +1 -1
- package/dist/tools/code-nav.js +3 -2
- package/dist/tools/code-nav.js.map +1 -1
- package/dist/tools/edit-targets.d.ts +10 -0
- package/dist/tools/edit-targets.js +31 -0
- package/dist/tools/edit-targets.js.map +1 -0
- package/dist/tools/edit.d.ts +11 -3
- package/dist/tools/edit.js +386 -312
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/exit-plan.d.ts +2 -2
- package/dist/tools/exit-plan.js +7 -1
- package/dist/tools/exit-plan.js.map +1 -1
- package/dist/tools/index.d.ts +3 -2
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.js +3 -1
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/skill.js +6 -3
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent.js +2 -1
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/tool-tiers.js +1 -0
- package/dist/tools/tool-tiers.js.map +1 -1
- package/dist/tools/verification-review.d.ts +2 -0
- package/dist/tools/verification-review.js +41 -0
- package/dist/tools/verification-review.js.map +1 -0
- package/dist/ui/components/ToolExecution.js +3 -2
- package/dist/ui/components/ToolExecution.js.map +1 -1
- package/dist/ui/terminal-history.js +3 -1
- package/dist/ui/terminal-history.js.map +1 -1
- package/dist/ui/tool-line-summary.js +14 -2
- package/dist/ui/tool-line-summary.js.map +1 -1
- package/dist/ui/transcript/tool-presentation.js +1 -1
- package/dist/ui/transcript/tool-presentation.js.map +1 -1
- package/dist/utils/format.js +5 -1
- package/dist/utils/format.js.map +1 -1
- package/package.json +4 -4
package/dist/tools/edit.js
CHANGED
|
@@ -80,27 +80,48 @@ const STRINGIFIED_EDITS_ERROR = "`edits` arrived as a JSON-encoded string and co
|
|
|
80
80
|
"Send `edits` as a real JSON array of objects, never as a string. " +
|
|
81
81
|
"Re-sending the same large payload usually breaks the same way: split the work into " +
|
|
82
82
|
"several `edit` calls carrying one or two smaller edits each.";
|
|
83
|
-
const
|
|
83
|
+
const EditList = z
|
|
84
|
+
.preprocess(coerceStringifiedEdits, z
|
|
85
|
+
.array(EditItem, {
|
|
86
|
+
// Narrow to "an array was expected, a string arrived" so a nested
|
|
87
|
+
// string-typed mistake (`anchor: "x"`, `replace_all: "true"`) keeps
|
|
88
|
+
// its own accurate message. Any non-matching issue returns undefined
|
|
89
|
+
// and falls through to Zod's default.
|
|
90
|
+
error: (issue) => issue.code === "invalid_type" &&
|
|
91
|
+
issue.expected === "array" &&
|
|
92
|
+
typeof issue.input === "string"
|
|
93
|
+
? STRINGIFIED_EDITS_ERROR
|
|
94
|
+
: undefined,
|
|
95
|
+
})
|
|
96
|
+
.min(1))
|
|
97
|
+
.describe("One or more edits applied in order. Each edit operates on the result of the previous one.");
|
|
98
|
+
// Multi-file entries take the text form only: a replay test batched all 7
|
|
99
|
+
// files 8/8 times either way, and dropping the anchor/span forms here keeps
|
|
100
|
+
// ~2k chars of duplicated schema out of every request.
|
|
101
|
+
const TextEditItem = EditItem.pick({ old_text: true, new_text: true, replace_all: true });
|
|
102
|
+
const FileEdits = z.object({
|
|
84
103
|
file_path: z.string().describe("The file path to edit"),
|
|
85
104
|
edits: z
|
|
86
|
-
.preprocess(coerceStringifiedEdits, z
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.min(1)
|
|
99
|
-
.
|
|
105
|
+
.preprocess(coerceStringifiedEdits, z.array(TextEditItem).min(1))
|
|
106
|
+
.describe("Text-form edits for this file, applied in order."),
|
|
107
|
+
});
|
|
108
|
+
// `files` lets one call cover a multi-file change. Measured on gpt-6-astra
|
|
109
|
+
// (replay of a 7-file refactor at the point where every file had been read):
|
|
110
|
+
// with it the model sent all 7 files in one call 8/8 times; without it 5/8
|
|
111
|
+
// responses carried every edit and the rest fell back to one file per turn.
|
|
112
|
+
const EditParams = z.object({
|
|
113
|
+
file_path: z.string().optional().describe("The file path to edit (single-file form)"),
|
|
114
|
+
edits: EditList.optional(),
|
|
115
|
+
files: z
|
|
116
|
+
.array(FileEdits)
|
|
117
|
+
.min(1)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Several files in ONE call: one entry per file, each with text-form edits (old_text/new_text). " +
|
|
120
|
+
"Use instead of file_path/edits when a change spans files."),
|
|
100
121
|
atomic: z
|
|
101
122
|
.boolean()
|
|
102
123
|
.optional()
|
|
103
|
-
.describe("If true, fail
|
|
124
|
+
.describe("If true, fail a file's whole batch when any of its edits fails — that file is left unchanged. " +
|
|
104
125
|
"Default false: partial-apply, keep every successful edit and report failures " +
|
|
105
126
|
"for retry. Use atomic only when later edits depend on earlier ones in a way " +
|
|
106
127
|
"where a half-applied state would be worse than nothing."),
|
|
@@ -167,340 +188,393 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
|
|
|
167
188
|
"text edits then run on the result.\n" +
|
|
168
189
|
"Partial-apply by default: failed edits are listed for retry, successful ones are still written — " +
|
|
169
190
|
"re-issue ONLY the listed failures, not the whole batch. " +
|
|
170
|
-
"Returns a unified diff
|
|
191
|
+
"Returns a unified diff.\n" +
|
|
192
|
+
"Multi-file: pass `files` (one {file_path, edits} entry per file) to change several files in a single call.",
|
|
171
193
|
parameters: EditParams,
|
|
172
194
|
executionMode: "sequential",
|
|
173
|
-
async execute({ file_path, edits, atomic = false }) {
|
|
195
|
+
async execute({ file_path, edits, files, atomic = false }) {
|
|
174
196
|
if (isPlanModeActive(planModeRef)) {
|
|
175
197
|
return planModeRestriction("edit");
|
|
176
198
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
// No prior read is required: every old_text, span and anchor is matched
|
|
185
|
-
// against the live content below, which proves the model knows the text
|
|
186
|
-
// it replaces (it may have seen it via `cat`, or changed the file itself
|
|
187
|
-
// with a script). A read that no longer matches disk is forgotten, so the
|
|
188
|
-
// file counts as unread again: the edit still validates against live
|
|
189
|
-
// content, but a later full-file `write` needs a fresh read.
|
|
190
|
-
if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
|
|
191
|
-
readFiles.delete(resolved);
|
|
199
|
+
const targets = resolveEditTargets(file_path, edits, files);
|
|
200
|
+
if (!targets.ok)
|
|
201
|
+
throw new Error(targets.error);
|
|
202
|
+
if (targets.value.length === 1) {
|
|
203
|
+
const only = targets.value[0];
|
|
204
|
+
return editOneFile(only.file_path, only.edits, atomic);
|
|
192
205
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
outcomes[i] = {
|
|
212
|
-
ok: false,
|
|
213
|
-
failure: {
|
|
214
|
-
reason: "invalid",
|
|
215
|
-
detail: "provide either old_text+new_text, or span+lines — this edit has neither",
|
|
216
|
-
},
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
continue;
|
|
206
|
+
// Files apply in order, each independently: one file's failure never
|
|
207
|
+
// rolls back another, and the result says exactly which to re-issue.
|
|
208
|
+
const parts = [];
|
|
209
|
+
const diffs = [];
|
|
210
|
+
let failed = 0;
|
|
211
|
+
for (const target of targets.value) {
|
|
212
|
+
try {
|
|
213
|
+
const result = await editOneFile(target.file_path, target.edits, atomic);
|
|
214
|
+
const content = typeof result === "string" ? result : result.content;
|
|
215
|
+
if (typeof result !== "string" && result.details.diff)
|
|
216
|
+
diffs.push(result.details.diff);
|
|
217
|
+
if (typeof result === "string" && result.startsWith("Error:"))
|
|
218
|
+
failed++;
|
|
219
|
+
parts.push(`### ${target.file_path}\n${content}`);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
failed++;
|
|
223
|
+
parts.push(`### ${target.file_path}\nFAILED — ${error instanceof Error ? error.message : String(error)}`);
|
|
220
224
|
}
|
|
221
|
-
|
|
225
|
+
}
|
|
226
|
+
const total = targets.value.length;
|
|
227
|
+
if (failed === total)
|
|
228
|
+
throw new Error(`All ${total} files failed; no changes written.\n\n${parts.join("\n\n")}`);
|
|
229
|
+
const header = failed === 0
|
|
230
|
+
? `Edited ${total} files.`
|
|
231
|
+
: `Edited ${total - failed} of ${total} files. ${failed} failed — re-issue ONLY the failed files (the rest are already done).`;
|
|
232
|
+
return { content: `${header}\n\n${parts.join("\n\n")}`, details: { diff: diffs.join("\n") } };
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
async function editOneFile(file_path, edits, atomic) {
|
|
236
|
+
const resolved = resolvePath(cwd, file_path);
|
|
237
|
+
await rejectSymlink(resolved);
|
|
238
|
+
// Workspace write guard: outside cwd/tmp/~/.gg requires user approval.
|
|
239
|
+
const guard = resolveWriteGuard(cwd, resolved, getWriteGuardSettings?.());
|
|
240
|
+
if (!guard.allowed) {
|
|
241
|
+
return `Error: ${guard.reason}`;
|
|
242
|
+
}
|
|
243
|
+
// No prior read is required: every old_text, span and anchor is matched
|
|
244
|
+
// against the live content below, which proves the model knows the text
|
|
245
|
+
// it replaces (it may have seen it via `cat`, or changed the file itself
|
|
246
|
+
// with a script). A read that no longer matches disk is forgotten, so the
|
|
247
|
+
// file counts as unread again: the edit still validates against live
|
|
248
|
+
// content, but a later full-file `write` needs a fresh read.
|
|
249
|
+
if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
|
|
250
|
+
readFiles.delete(resolved);
|
|
251
|
+
}
|
|
252
|
+
const original = await ops.readFile(resolved);
|
|
253
|
+
const hasCRLF = original.includes("\r\n");
|
|
254
|
+
const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
|
|
255
|
+
// Anchors pin lines in the file AS READ, so they always verify against the
|
|
256
|
+
// original (pre-edit) line array — earlier edits in the batch don't shift
|
|
257
|
+
// what an anchor refers to.
|
|
258
|
+
const originalLines = originalNormalized.split("\n");
|
|
259
|
+
const fileName = path.basename(resolved);
|
|
260
|
+
const outcomes = new Array(edits.length);
|
|
261
|
+
// ── Phase 1: span-form edits (hash-anchored replacement). Spans resolve
|
|
262
|
+
// against the file AS READ and apply bottom-up so indices stay valid.
|
|
263
|
+
// Text-form edits then run on the result in phase 2.
|
|
264
|
+
const isSpanForm = (e) => e.span !== undefined || e.lines !== undefined;
|
|
265
|
+
const spanResolved = [];
|
|
266
|
+
for (let i = 0; i < edits.length; i++) {
|
|
267
|
+
const e = edits[i];
|
|
268
|
+
if (!isSpanForm(e)) {
|
|
269
|
+
if (e.old_text === undefined || e.new_text === undefined) {
|
|
222
270
|
outcomes[i] = {
|
|
223
271
|
ok: false,
|
|
224
272
|
failure: {
|
|
225
273
|
reason: "invalid",
|
|
226
|
-
detail: "
|
|
274
|
+
detail: "provide either old_text+new_text, or span+lines — this edit has neither",
|
|
227
275
|
},
|
|
228
276
|
};
|
|
229
|
-
continue;
|
|
230
277
|
}
|
|
231
|
-
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!e.span || !e.lines || e.old_text !== undefined || e.new_text !== undefined) {
|
|
281
|
+
outcomes[i] = {
|
|
282
|
+
ok: false,
|
|
283
|
+
failure: {
|
|
284
|
+
reason: "invalid",
|
|
285
|
+
detail: "span form requires BOTH span and lines, and must not mix with old_text/new_text",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const res = resolveAnchoredEdit(originalLines, e.span);
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
|
|
296
|
+
}
|
|
297
|
+
// Reject overlapping spans (keep the first, fail the rest) — overlap means
|
|
298
|
+
// the model double-addressed the same region and the result is undefined.
|
|
299
|
+
spanResolved.sort((a, b) => a.start - b.start || a.index - b.index);
|
|
300
|
+
const spanApplied = [];
|
|
301
|
+
let lastEnd = -1;
|
|
302
|
+
for (const s of spanResolved) {
|
|
303
|
+
if (s.start <= lastEnd) {
|
|
304
|
+
outcomes[s.index] = { ok: false, failure: { reason: "overlap" } };
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
spanApplied.push(s);
|
|
308
|
+
lastEnd = s.end;
|
|
309
|
+
}
|
|
310
|
+
const workingLines = [...originalLines];
|
|
311
|
+
for (let i = spanApplied.length - 1; i >= 0; i--) {
|
|
312
|
+
const s = spanApplied[i];
|
|
313
|
+
workingLines.splice(s.start, s.end - s.start + 1, ...s.lines);
|
|
314
|
+
outcomes[s.index] = { ok: true, source: "span" };
|
|
315
|
+
}
|
|
316
|
+
let working = workingLines.join("\n");
|
|
317
|
+
// ── Phase 2: text-form edits, sequential on the working buffer.
|
|
318
|
+
for (let i = 0; i < edits.length; i++) {
|
|
319
|
+
if (outcomes[i] !== undefined)
|
|
320
|
+
continue; // span-form or invalid, already settled
|
|
321
|
+
const { old_text, new_text, replace_all, anchor } = edits[i];
|
|
322
|
+
if (old_text === undefined || new_text === undefined)
|
|
323
|
+
continue; // settled above
|
|
324
|
+
// Optional staleness guard (opt-in). Runs BEFORE the fuzzy match ladder:
|
|
325
|
+
// if the model supplied an anchor and the file drifted since it read it,
|
|
326
|
+
// reject this edit instead of risking a misplaced fuzzy match. The fuzzy
|
|
327
|
+
// path below is byte-identical to today when `anchor` is absent.
|
|
328
|
+
if (anchor) {
|
|
329
|
+
const res = resolveAnchoredEdit(originalLines, anchor);
|
|
232
330
|
if (!res.ok) {
|
|
233
331
|
outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
|
|
234
332
|
continue;
|
|
235
333
|
}
|
|
236
|
-
spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
|
|
237
334
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
spanApplied.push(s);
|
|
249
|
-
lastEnd = s.end;
|
|
335
|
+
const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
|
|
336
|
+
const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
|
|
337
|
+
const replaceAll = replace_all ?? false;
|
|
338
|
+
// Identical replacements are explicit no-op successes. They should not
|
|
339
|
+
// block atomic batches that contain real edits, and all-no-op batches
|
|
340
|
+
// should report success without writing.
|
|
341
|
+
if (normalizedOld === normalizedNew) {
|
|
342
|
+
outcomes[i] = { ok: true };
|
|
343
|
+
continue;
|
|
250
344
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
345
|
+
if (normalizedOld.length === 0 ||
|
|
346
|
+
(!working.includes(normalizedOld) && normalizeForFuzzyMatch(normalizedOld).length === 0)) {
|
|
347
|
+
outcomes[i] = {
|
|
348
|
+
ok: false,
|
|
349
|
+
failure: {
|
|
350
|
+
reason: "invalid",
|
|
351
|
+
detail: "old_text is empty after normalization; provide visible surrounding context.",
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
continue;
|
|
256
355
|
}
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
356
|
+
// Aider's full fallback ladder, run only when the primary match
|
|
357
|
+
// returns "not_found". Ambiguous matches deliberately don't fall
|
|
358
|
+
// through — the model needs to add context, not paraphrase further.
|
|
359
|
+
// Order mirrors aider/coders/editblock_coder.py:
|
|
360
|
+
// 1. exact + smart-quote/dash fuzzy (in tryMatch)
|
|
361
|
+
// 2. indent-flex (model omitted/shortened leading whitespace)
|
|
362
|
+
// 3. drop spurious leading/trailing blank lines, retry 1+2
|
|
363
|
+
// 4. dotdotdots (`...` elision with preserved middle)
|
|
364
|
+
let source = "text";
|
|
365
|
+
let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
|
|
366
|
+
const tryFallbacks = (oldText) => {
|
|
367
|
+
const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
|
|
368
|
+
if (flexed !== null)
|
|
369
|
+
return flexed;
|
|
370
|
+
// Re-run primary matcher on the stripped variant as a cheap retry.
|
|
371
|
+
const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
|
|
372
|
+
if (exact.ok)
|
|
373
|
+
return exact.newWorking;
|
|
374
|
+
return null;
|
|
375
|
+
};
|
|
376
|
+
if (!result.ok && result.reason === "not_found") {
|
|
377
|
+
const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
|
|
378
|
+
if (indentFlexed !== null) {
|
|
379
|
+
result = { ok: true, newWorking: indentFlexed };
|
|
380
|
+
source = "indent_flex";
|
|
275
381
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
382
|
+
}
|
|
383
|
+
if (!result.ok && result.reason === "not_found") {
|
|
384
|
+
const stripped = stripBlankEdges(normalizedOld);
|
|
385
|
+
if (stripped !== null) {
|
|
386
|
+
const candidate = tryFallbacks(stripped);
|
|
387
|
+
if (candidate !== null) {
|
|
388
|
+
result = { ok: true, newWorking: candidate };
|
|
389
|
+
source = "blank_edges";
|
|
390
|
+
}
|
|
285
391
|
}
|
|
286
|
-
|
|
287
|
-
|
|
392
|
+
}
|
|
393
|
+
if (!result.ok && result.reason === "not_found") {
|
|
394
|
+
if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
|
|
288
395
|
outcomes[i] = {
|
|
289
396
|
ok: false,
|
|
290
397
|
failure: {
|
|
291
398
|
reason: "invalid",
|
|
292
|
-
detail: "
|
|
399
|
+
detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
|
|
293
400
|
},
|
|
294
401
|
};
|
|
295
402
|
continue;
|
|
296
403
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// 1. exact + smart-quote/dash fuzzy (in tryMatch)
|
|
302
|
-
// 2. indent-flex (model omitted/shortened leading whitespace)
|
|
303
|
-
// 3. drop spurious leading/trailing blank lines, retry 1+2
|
|
304
|
-
// 4. dotdotdots (`...` elision with preserved middle)
|
|
305
|
-
let source = "text";
|
|
306
|
-
let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
|
|
307
|
-
const tryFallbacks = (oldText) => {
|
|
308
|
-
const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
|
|
309
|
-
if (flexed !== null)
|
|
310
|
-
return flexed;
|
|
311
|
-
// Re-run primary matcher on the stripped variant as a cheap retry.
|
|
312
|
-
const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
|
|
313
|
-
if (exact.ok)
|
|
314
|
-
return exact.newWorking;
|
|
315
|
-
return null;
|
|
316
|
-
};
|
|
317
|
-
if (!result.ok && result.reason === "not_found") {
|
|
318
|
-
const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
|
|
319
|
-
if (indentFlexed !== null) {
|
|
320
|
-
result = { ok: true, newWorking: indentFlexed };
|
|
321
|
-
source = "indent_flex";
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (!result.ok && result.reason === "not_found") {
|
|
325
|
-
const stripped = stripBlankEdges(normalizedOld);
|
|
326
|
-
if (stripped !== null) {
|
|
327
|
-
const candidate = tryFallbacks(stripped);
|
|
328
|
-
if (candidate !== null) {
|
|
329
|
-
result = { ok: true, newWorking: candidate };
|
|
330
|
-
source = "blank_edges";
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (!result.ok && result.reason === "not_found") {
|
|
335
|
-
if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
|
|
336
|
-
outcomes[i] = {
|
|
337
|
-
ok: false,
|
|
338
|
-
failure: {
|
|
339
|
-
reason: "invalid",
|
|
340
|
-
detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
|
|
341
|
-
},
|
|
342
|
-
};
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
|
|
346
|
-
if (elided !== null) {
|
|
347
|
-
result = { ok: true, newWorking: elided };
|
|
348
|
-
source = "dotdotdot";
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
if (result.ok) {
|
|
352
|
-
working = result.newWorking;
|
|
353
|
-
outcomes[i] = { ok: true, source };
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (result.reason === "not_found") {
|
|
357
|
-
// Capture the closest-match snippet eagerly against the current
|
|
358
|
-
// working buffer; we'll decide whether to render it post-loop based
|
|
359
|
-
// on whether other edits in this batch succeeded.
|
|
360
|
-
const closest = findClosestSnippet(working, normalizedOld);
|
|
361
|
-
outcomes[i] = {
|
|
362
|
-
ok: false,
|
|
363
|
-
failure: {
|
|
364
|
-
reason: "not_found",
|
|
365
|
-
closestSnippet: closest?.snippet ?? null,
|
|
366
|
-
closestLine: closest?.topLine ?? null,
|
|
367
|
-
redactedHint: redactedOldTextHint(normalizedOld),
|
|
368
|
-
},
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
const occurrences = result.occurrences ?? 0;
|
|
373
|
-
const matches = findOccurrenceLines(working, normalizedOld);
|
|
374
|
-
const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
|
|
375
|
-
const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
|
|
376
|
-
outcomes[i] = {
|
|
377
|
-
ok: false,
|
|
378
|
-
failure: { reason: "ambiguous", occurrences, matchLines, more },
|
|
379
|
-
};
|
|
404
|
+
const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
|
|
405
|
+
if (elided !== null) {
|
|
406
|
+
result = { ok: true, newWorking: elided };
|
|
407
|
+
source = "dotdotdot";
|
|
380
408
|
}
|
|
381
409
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
410
|
+
if (result.ok) {
|
|
411
|
+
working = result.newWorking;
|
|
412
|
+
outcomes[i] = { ok: true, source };
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (result.reason === "not_found") {
|
|
416
|
+
// Capture the closest-match snippet eagerly against the current
|
|
417
|
+
// working buffer; we'll decide whether to render it post-loop based
|
|
418
|
+
// on whether other edits in this batch succeeded.
|
|
419
|
+
const closest = findClosestSnippet(working, normalizedOld);
|
|
420
|
+
outcomes[i] = {
|
|
421
|
+
ok: false,
|
|
422
|
+
failure: {
|
|
423
|
+
reason: "not_found",
|
|
424
|
+
closestSnippet: closest?.snippet ?? null,
|
|
425
|
+
closestLine: closest?.topLine ?? null,
|
|
426
|
+
redactedHint: redactedOldTextHint(normalizedOld),
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
const occurrences = result.occurrences ?? 0;
|
|
432
|
+
const matches = findOccurrenceLines(working, normalizedOld);
|
|
433
|
+
const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
|
|
434
|
+
const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
|
|
435
|
+
outcomes[i] = {
|
|
436
|
+
ok: false,
|
|
437
|
+
failure: { reason: "ambiguous", occurrences, matchLines, more },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const failures = outcomes
|
|
442
|
+
.map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
|
|
443
|
+
.filter((x) => x !== null);
|
|
444
|
+
const successCount = outcomes.length - failures.length;
|
|
445
|
+
// Closest-match snippets only get suppressed when successes will ACTUALLY
|
|
446
|
+
// be persisted (partial-apply with at least one win). In atomic mode we
|
|
447
|
+
// throw before writing, so the model retries against an unchanged file
|
|
448
|
+
// and the snippet is its only guidance — keep it.
|
|
449
|
+
const willPersistSuccesses = successCount > 0 && !atomic;
|
|
450
|
+
const formatFailureMessage = (f) => {
|
|
451
|
+
if (f.reason === "stale_anchor") {
|
|
452
|
+
return `the file changed since you read it (anchor mismatch) — re-read \`${file_path}\` and retry`;
|
|
453
|
+
}
|
|
454
|
+
if (f.reason === "noop") {
|
|
455
|
+
return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
|
|
456
|
+
}
|
|
457
|
+
if (f.reason === "invalid") {
|
|
458
|
+
return `invalid edit: ${f.detail}.`;
|
|
459
|
+
}
|
|
460
|
+
if (f.reason === "overlap") {
|
|
461
|
+
return (`span overlaps another span edit in this batch — the overlapping region was addressed twice. ` +
|
|
462
|
+
`Merge the overlapping spans into one edit and retry.`);
|
|
463
|
+
}
|
|
464
|
+
if (f.reason === "ambiguous") {
|
|
465
|
+
return (`old_text found ${f.occurrences} times in ${fileName}. ` +
|
|
466
|
+
"Include more surrounding context to make the match unique, " +
|
|
467
|
+
"or set replace_all: true to swap every occurrence.\n" +
|
|
468
|
+
"Matches at:\n" +
|
|
469
|
+
f.matchLines +
|
|
470
|
+
f.more);
|
|
471
|
+
}
|
|
472
|
+
const base = `old_text not found in ${fileName}. ` +
|
|
473
|
+
"Text must match verbatim — do not paraphrase. " +
|
|
474
|
+
"Fix this edit's old_text to match the file exactly (re-read the region below if unsure); " +
|
|
475
|
+
"the file is unchanged, so successful edits and prior reads are still valid." +
|
|
476
|
+
f.redactedHint;
|
|
477
|
+
// Build a bounded read suggestion around the closest-match line so the
|
|
478
|
+
// model can re-read just that region (e.g. ±25 lines) instead of the
|
|
479
|
+
// whole file. Skipped when willPersistSuccesses — see comment above.
|
|
480
|
+
const readHint = f.closestLine !== null && !willPersistSuccesses
|
|
481
|
+
? `\nSuggested re-read: \`read file_path="${file_path}" offset=${Math.max(1, f.closestLine - 25)} limit=50\``
|
|
482
|
+
: "";
|
|
483
|
+
if (willPersistSuccesses || !f.closestSnippet)
|
|
484
|
+
return base + readHint;
|
|
485
|
+
return `${base}${readHint}\nClosest match in file:\n${f.closestSnippet}`;
|
|
486
|
+
};
|
|
487
|
+
const formatFailures = () => {
|
|
488
|
+
if (failures.length === 1 && edits.length === 1) {
|
|
489
|
+
return formatFailureMessage(failures[0].failure);
|
|
490
|
+
}
|
|
491
|
+
return failures
|
|
492
|
+
.map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
|
|
493
|
+
.join("\n\n");
|
|
494
|
+
};
|
|
495
|
+
// Atomic-mode failure, OR partial-mode failure where literally nothing
|
|
496
|
+
// succeeded. Either way nothing should be written and we throw to make
|
|
497
|
+
// the model retry the whole batch.
|
|
498
|
+
if (failures.length > 0 && (atomic || successCount === 0)) {
|
|
499
|
+
// Nothing was written — the file is byte-identical to the last read, so
|
|
500
|
+
// the read tracker stays valid. We deliberately do NOT invalidate it
|
|
501
|
+
// here: doing so turned a precise "old_text not found (closest match
|
|
502
|
+
// below)" error into a misleading "File must be read first" on every
|
|
503
|
+
// following edit, hiding the real fix from the model. A genuine on-disk
|
|
504
|
+
// change (formatter/external edit) is caught by the freshness check.
|
|
505
|
+
const header = atomic && failures.length > 0
|
|
506
|
+
? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
|
|
507
|
+
: edits.length > 1
|
|
508
|
+
? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
|
|
423
509
|
: "";
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
510
|
+
throw new Error(header + formatFailures());
|
|
511
|
+
}
|
|
512
|
+
// Never replace real secrets with the placeholder the model was shown.
|
|
513
|
+
const lossError = redactionLossError(originalNormalized, working, fileName);
|
|
514
|
+
if (lossError)
|
|
515
|
+
throw new Error(lossError);
|
|
516
|
+
const relPath = path.relative(cwd, resolved);
|
|
517
|
+
const diff = generateDiff(originalNormalized, working, relPath);
|
|
518
|
+
const changed = working !== originalNormalized;
|
|
519
|
+
// LSP diagnostics for the just-written content. Best-effort enhancement:
|
|
520
|
+
// any failure (or an opted-out provider) leaves output identical to today.
|
|
521
|
+
let diagnosticsNote = "";
|
|
522
|
+
if (changed) {
|
|
523
|
+
const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
|
|
524
|
+
// Snapshot the pre-mutation on-disk state for /rewind before writing.
|
|
525
|
+
await onPreFileMutation?.(resolved);
|
|
526
|
+
await ops.writeFile(resolved, finalContent);
|
|
527
|
+
await recordWrite(readFiles, resolved, finalContent, ops, "edit");
|
|
528
|
+
await mutationCallback?.(resolved);
|
|
529
|
+
// recordWrite refreshed the tracker to the just-written content, so the
|
|
530
|
+
// next edit (including retries of the skipped ones) validates against an
|
|
531
|
+
// accurate snapshot. No invalidation — the failure message already
|
|
532
|
+
// carries the closest match and a bounded re-read hint.
|
|
533
|
+
if (getDiagnostics) {
|
|
534
|
+
try {
|
|
535
|
+
diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
|
|
431
536
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
.join("\n\n");
|
|
435
|
-
};
|
|
436
|
-
// Atomic-mode failure, OR partial-mode failure where literally nothing
|
|
437
|
-
// succeeded. Either way nothing should be written and we throw to make
|
|
438
|
-
// the model retry the whole batch.
|
|
439
|
-
if (failures.length > 0 && (atomic || successCount === 0)) {
|
|
440
|
-
// Nothing was written — the file is byte-identical to the last read, so
|
|
441
|
-
// the read tracker stays valid. We deliberately do NOT invalidate it
|
|
442
|
-
// here: doing so turned a precise "old_text not found (closest match
|
|
443
|
-
// below)" error into a misleading "File must be read first" on every
|
|
444
|
-
// following edit, hiding the real fix from the model. A genuine on-disk
|
|
445
|
-
// change (formatter/external edit) is caught by the freshness check.
|
|
446
|
-
const header = atomic && failures.length > 0
|
|
447
|
-
? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
|
|
448
|
-
: edits.length > 1
|
|
449
|
-
? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
|
|
450
|
-
: "";
|
|
451
|
-
throw new Error(header + formatFailures());
|
|
452
|
-
}
|
|
453
|
-
// Never replace real secrets with the placeholder the model was shown.
|
|
454
|
-
const lossError = redactionLossError(originalNormalized, working, fileName);
|
|
455
|
-
if (lossError)
|
|
456
|
-
throw new Error(lossError);
|
|
457
|
-
const relPath = path.relative(cwd, resolved);
|
|
458
|
-
const diff = generateDiff(originalNormalized, working, relPath);
|
|
459
|
-
const changed = working !== originalNormalized;
|
|
460
|
-
// LSP diagnostics for the just-written content. Best-effort enhancement:
|
|
461
|
-
// any failure (or an opted-out provider) leaves output identical to today.
|
|
462
|
-
let diagnosticsNote = "";
|
|
463
|
-
if (changed) {
|
|
464
|
-
const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
|
|
465
|
-
// Snapshot the pre-mutation on-disk state for /rewind before writing.
|
|
466
|
-
await onPreFileMutation?.(resolved);
|
|
467
|
-
await ops.writeFile(resolved, finalContent);
|
|
468
|
-
await recordWrite(readFiles, resolved, finalContent, ops, "edit");
|
|
469
|
-
await mutationCallback?.(resolved);
|
|
470
|
-
// recordWrite refreshed the tracker to the just-written content, so the
|
|
471
|
-
// next edit (including retries of the skipped ones) validates against an
|
|
472
|
-
// accurate snapshot. No invalidation — the failure message already
|
|
473
|
-
// carries the closest match and a bounded re-read hint.
|
|
474
|
-
if (getDiagnostics) {
|
|
475
|
-
try {
|
|
476
|
-
diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
|
|
477
|
-
}
|
|
478
|
-
catch {
|
|
479
|
-
// Diagnostics must never break a successful edit.
|
|
480
|
-
}
|
|
537
|
+
catch {
|
|
538
|
+
// Diagnostics must never break a successful edit.
|
|
481
539
|
}
|
|
482
540
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
|
|
487
|
-
: `No changes needed in ${relPath}; edit was a no-op.`;
|
|
488
|
-
return { content: summary, details: { diff } };
|
|
489
|
-
}
|
|
541
|
+
}
|
|
542
|
+
if (failures.length === 0) {
|
|
543
|
+
if (!changed) {
|
|
490
544
|
const summary = edits.length > 1
|
|
491
|
-
? `
|
|
492
|
-
: `
|
|
493
|
-
return { content: summary
|
|
545
|
+
? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
|
|
546
|
+
: `No changes needed in ${relPath}; edit was a no-op.`;
|
|
547
|
+
return { content: summary, details: { diff } };
|
|
494
548
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
|
|
549
|
+
const summary = edits.length > 1
|
|
550
|
+
? `Successfully applied ${edits.length} edits to ${relPath}.`
|
|
551
|
+
: `Successfully replaced text in ${relPath}.`;
|
|
552
|
+
return { content: summary + diagnosticsNote, details: { diff } };
|
|
553
|
+
}
|
|
554
|
+
// Partial success — the loud header is deliberate: the model has to know
|
|
555
|
+
// that work was saved AND that only the listed edits need to be retried.
|
|
556
|
+
const noun = failures.length === 1 ? "edit" : "edits";
|
|
557
|
+
const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
|
|
558
|
+
`${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
|
|
559
|
+
formatFailures() +
|
|
560
|
+
diagnosticsNote;
|
|
561
|
+
return { content, details: { diff } };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** Exactly one form: `file_path`+`edits`, or `files`. */
|
|
565
|
+
function resolveEditTargets(file_path, edits, files) {
|
|
566
|
+
const single = file_path !== undefined || edits !== undefined;
|
|
567
|
+
if (files !== undefined && single) {
|
|
568
|
+
return { ok: false, error: "Use either file_path+edits or files, not both." };
|
|
569
|
+
}
|
|
570
|
+
if (files !== undefined)
|
|
571
|
+
return { ok: true, value: files };
|
|
572
|
+
if (file_path === undefined || edits === undefined) {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
error: "Provide file_path and edits (one file), or files (several files).",
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
return { ok: true, value: [{ file_path, edits }] };
|
|
505
579
|
}
|
|
506
580
|
//# sourceMappingURL=edit.js.map
|