@oh-my-pi/pi-coding-agent 14.5.8 → 14.5.10
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/CHANGELOG.md +56 -0
- package/package.json +7 -15
- package/scripts/build-binary.ts +1 -1
- package/src/cli/update-cli.ts +25 -1
- package/src/config/model-registry.ts +21 -19
- package/src/config/settings-schema.ts +14 -19
- package/src/discovery/claude-plugins.ts +28 -3
- package/src/edit/modes/atom.lark +7 -5
- package/src/edit/modes/atom.ts +510 -73
- package/src/edit/modes/hashline.ts +172 -91
- package/src/extensibility/extensions/runner.ts +34 -1
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/lsp/client.ts +27 -35
- package/src/lsp/index.ts +2 -4
- package/src/lsp/render.ts +0 -3
- package/src/lsp/types.ts +1 -4
- package/src/lsp/utils.ts +18 -14
- package/src/memories/index.ts +5 -0
- package/src/modes/components/settings-defs.ts +1 -1
- package/src/modes/controllers/command-controller.ts +17 -0
- package/src/modes/controllers/input-controller.ts +7 -1
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/interactive-mode.ts +57 -26
- package/src/modes/theme/theme.ts +10 -1
- package/src/modes/types.ts +5 -3
- package/src/modes/utils/context-usage.ts +294 -0
- package/src/modes/utils/ui-helpers.ts +19 -6
- package/src/prompts/system/auto-continue.md +1 -0
- package/src/prompts/tools/atom.md +99 -44
- package/src/prompts/tools/exit-plan-mode.md +5 -39
- package/src/prompts/tools/github.md +3 -3
- package/src/prompts/tools/lsp.md +2 -3
- package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
- package/src/prompts/tools/task.md +34 -147
- package/src/prompts/tools/todo-write.md +22 -64
- package/src/sdk.ts +13 -2
- package/src/session/agent-session.ts +175 -79
- package/src/session/compaction/compaction.ts +35 -22
- package/src/session/session-dump-format.ts +1 -0
- package/src/session/session-manager.ts +19 -2
- package/src/slash-commands/builtin-registry.ts +12 -5
- package/src/tools/bash.ts +9 -4
- package/src/tools/debug.ts +57 -70
- package/src/tools/gh.ts +267 -119
- package/src/tools/index.ts +7 -7
- package/src/tools/{run-command → recipe}/index.ts +19 -19
- package/src/tools/recipe/render.ts +19 -0
- package/src/tools/{run-command → recipe}/runner.ts +28 -7
- package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
- package/src/tools/renderers.ts +2 -2
- package/src/utils/git.ts +61 -2
- package/src/web/search/providers/searxng.ts +71 -13
- package/src/tools/run-command/render.ts +0 -18
- /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/task.ts +0 -0
package/src/edit/modes/atom.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* @Lid move cursor to just after the anchored line
|
|
8
8
|
* Lid=TEXT set the anchored line to TEXT and move cursor after it
|
|
9
9
|
* -Lid delete the anchored line and move cursor to its slot
|
|
10
|
+
* LidA..LidB=TEXT replace a range; following \TEXT lines continue it
|
|
11
|
+
* \TEXT append TEXT to the active replacement (set or range)
|
|
10
12
|
* +TEXT insert TEXT at the cursor
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* ^ move cursor to beginning of file
|
|
14
|
+
* $ move cursor to end of file
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import * as fs from "node:fs/promises";
|
|
@@ -57,6 +59,11 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
|
57
59
|
const LID_RE = /^([1-9]\d*)([a-z]{2})/;
|
|
58
60
|
const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
|
|
59
61
|
|
|
62
|
+
// Sentinel hash used for interior line anchors synthesized from `-LidA..LidB`
|
|
63
|
+
// range deletes. validateAtomAnchors recognizes this and skips hash checking
|
|
64
|
+
// (only the start and end Lids' hashes are validated by the user).
|
|
65
|
+
const RANGE_INTERIOR_HASH = "**";
|
|
66
|
+
|
|
60
67
|
interface ParsedAnchor {
|
|
61
68
|
line: number;
|
|
62
69
|
hash: string;
|
|
@@ -67,6 +74,7 @@ type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op:
|
|
|
67
74
|
type AnchorStmt =
|
|
68
75
|
| { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
|
|
69
76
|
| { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
|
|
77
|
+
| { kind: "before_anchor"; anchor: ParsedAnchor; lineNum: number }
|
|
70
78
|
| { kind: "bof"; lineNum: number }
|
|
71
79
|
| { kind: "eof"; lineNum: number };
|
|
72
80
|
|
|
@@ -79,6 +87,7 @@ type InsertStmt = {
|
|
|
79
87
|
type DiffishAddStmt = {
|
|
80
88
|
kind: "diffish_add";
|
|
81
89
|
anchor: ParsedAnchor;
|
|
90
|
+
separator: "=" | "|";
|
|
82
91
|
text: string;
|
|
83
92
|
lineNum: number;
|
|
84
93
|
};
|
|
@@ -92,7 +101,11 @@ type DeleteWithOldStmt = {
|
|
|
92
101
|
|
|
93
102
|
type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
|
|
94
103
|
|
|
95
|
-
type AtomCursor =
|
|
104
|
+
type AtomCursor =
|
|
105
|
+
| { kind: "bof" }
|
|
106
|
+
| { kind: "eof" }
|
|
107
|
+
| { kind: "anchor"; anchor: Anchor }
|
|
108
|
+
| { kind: "before_anchor"; anchor: Anchor };
|
|
96
109
|
|
|
97
110
|
export type AtomEdit =
|
|
98
111
|
| { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
|
|
@@ -119,11 +132,12 @@ interface IndexedAnchorEdit {
|
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
function cloneCursor(cursor: AtomCursor): AtomCursor {
|
|
122
|
-
if (cursor.kind
|
|
123
|
-
return { kind: "
|
|
135
|
+
if (cursor.kind === "anchor") return { kind: "anchor", anchor: { ...cursor.anchor } };
|
|
136
|
+
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
|
|
137
|
+
return cursor;
|
|
124
138
|
}
|
|
125
139
|
|
|
126
|
-
function parseLidStmt(body: string, lineNum: number):
|
|
140
|
+
function parseLidStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
127
141
|
const m = LID_RE.exec(body);
|
|
128
142
|
if (!m) return null;
|
|
129
143
|
|
|
@@ -131,22 +145,133 @@ function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
|
|
|
131
145
|
const hash = m[2];
|
|
132
146
|
const rest = body.slice(m[0].length);
|
|
133
147
|
const anchor = { line: ln, hash };
|
|
148
|
+
|
|
149
|
+
// Range replace: `LidA..LidB=TEXT` deletes the inclusive range LidA..LidB
|
|
150
|
+
// and inserts TEXT in its place. Following insert statements append more
|
|
151
|
+
// replacement lines through the normal hunk reorder path. Legacy `|` is
|
|
152
|
+
// accepted as a set separator for parity with single-line `Lid|TEXT`.
|
|
153
|
+
// Bare `LidA..LidB` recovers the common missing-`-` typo for range delete.
|
|
154
|
+
if (rest.startsWith("..")) {
|
|
155
|
+
const m2 = LID_RE.exec(rest.slice(2));
|
|
156
|
+
if (m2) {
|
|
157
|
+
const endLn = Number.parseInt(m2[1], 10);
|
|
158
|
+
const endHash = m2[2];
|
|
159
|
+
const after = rest.slice(2 + m2[0].length);
|
|
160
|
+
const range = `${ln}${hash}..${endLn}${endHash}`;
|
|
161
|
+
if (endLn < ln) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Diff line ${lineNum}: range \`${range}\` ends before it starts. Use \`LidA..LidB=TEXT\` with LidA's line number ≤ LidB's.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (endLn === ln && endHash !== hash) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Diff line ${lineNum}: range \`${range}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`${ln}${hash}=TEXT\` for a single-line replacement.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const stmts: ParsedStmt[] = [];
|
|
173
|
+
for (let l = ln; l <= endLn; l++) {
|
|
174
|
+
const h = l === ln ? hash : l === endLn ? endHash : RANGE_INTERIOR_HASH;
|
|
175
|
+
stmts.push({
|
|
176
|
+
kind: "anchor_op",
|
|
177
|
+
anchor: { line: l, hash: h },
|
|
178
|
+
op: { op: "delete" },
|
|
179
|
+
lineNum,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (after.trim().length === 0) return stmts;
|
|
184
|
+
|
|
185
|
+
const replacement = /^[ \t]*([=|])(.*)$/.exec(after);
|
|
186
|
+
if (replacement) {
|
|
187
|
+
if (replacement[2].includes("\r")) {
|
|
188
|
+
throw new Error(`Diff line ${lineNum}: set value contains a carriage return; use a single-line value.`);
|
|
189
|
+
}
|
|
190
|
+
stmts.push({ kind: "insert", text: replacement[2], lineNum });
|
|
191
|
+
return stmts;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
134
196
|
if (rest.length === 0) {
|
|
135
|
-
return { kind: "bare_anchor", anchor, lineNum };
|
|
197
|
+
return [{ kind: "bare_anchor", anchor, lineNum }];
|
|
136
198
|
}
|
|
137
199
|
|
|
138
200
|
const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
201
|
+
if (replacement) {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
kind: "anchor_op",
|
|
205
|
+
anchor,
|
|
206
|
+
op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
|
|
207
|
+
lineNum,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Compound shorthand: `Lid+TEXT` collapses cursor-after-Lid + insert TEXT.
|
|
213
|
+
// Models sometimes write a run like `103rd=A` / `103rd+B` / `103rd+C` to
|
|
214
|
+
// mean "set 103 to A, then insert B and C below it". Treat each `Lid+...`
|
|
215
|
+
// as an independent cursor-move + insert; this matches semantics of the
|
|
216
|
+
// canonical `@Lid` + `+TEXT` two-line form.
|
|
217
|
+
if (rest[0] === "+") {
|
|
218
|
+
return [
|
|
219
|
+
{ kind: "bare_anchor", anchor, lineNum },
|
|
220
|
+
{ kind: "insert", text: rest.slice(1), lineNum },
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return null;
|
|
146
225
|
}
|
|
147
226
|
|
|
148
227
|
function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
149
228
|
const trimmedBody = body.trimStart();
|
|
229
|
+
|
|
230
|
+
// Range delete: `-LidA..LidB` deletes the contiguous range LidA..LidB inclusive.
|
|
231
|
+
const rangeRe = /^([1-9]\d*)([a-z]{2})\.\.([1-9]\d*)([a-z]{2})$/;
|
|
232
|
+
const rangeMatch = rangeRe.exec(trimmedBody);
|
|
233
|
+
if (rangeMatch) {
|
|
234
|
+
const startLine = Number.parseInt(rangeMatch[1], 10);
|
|
235
|
+
const startHash = rangeMatch[2];
|
|
236
|
+
const endLine = Number.parseInt(rangeMatch[3], 10);
|
|
237
|
+
const endHash = rangeMatch[4];
|
|
238
|
+
if (endLine < startLine) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` ends before it starts. Use \`-LidA..LidB\` with LidA's line number ≤ LidB's.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (endLine === startLine && endHash !== startHash) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Diff line ${lineNum}: range \`-${startLine}${startHash}..${endLine}${endHash}\` uses two different hashes for the same line. Copy the same Lid at both endpoints or use \`-${startLine}${startHash}\` for a single-line delete.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const stmts: ParsedStmt[] = [];
|
|
249
|
+
for (let ln = startLine; ln <= endLine; ln++) {
|
|
250
|
+
const hash = ln === startLine ? startHash : ln === endLine ? endHash : RANGE_INTERIOR_HASH;
|
|
251
|
+
stmts.push({
|
|
252
|
+
kind: "anchor_op",
|
|
253
|
+
anchor: { line: ln, hash },
|
|
254
|
+
op: { op: "delete" },
|
|
255
|
+
lineNum,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return stmts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `-LidA..LidB|TEXT` and `-LidA..LidB=TEXT` are not valid: ranges have no
|
|
262
|
+
// `|` (delete-with-old) form. Models reach for these when trying to
|
|
263
|
+
// "delete the range and replace with TEXT" — point at the `LidA..LidB=TEXT`
|
|
264
|
+
// shorthand instead.
|
|
265
|
+
const rangeWithSuffix = /^([1-9]\d*)([a-z]{2})\.\.([1-9]\d*)([a-z]{2})[ \t]*[=|](.*)$/.exec(trimmedBody);
|
|
266
|
+
if (rangeWithSuffix) {
|
|
267
|
+
const lidA = `${rangeWithSuffix[1]}${rangeWithSuffix[2]}`;
|
|
268
|
+
const lidB = `${rangeWithSuffix[3]}${rangeWithSuffix[4]}`;
|
|
269
|
+
const text = rangeWithSuffix[5];
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Diff line ${lineNum}: \`-${lidA}..${lidB}\` cannot have a \`|\`/\`=\` suffix. To delete the range, use \`-${lidA}..${lidB}\` alone. To replace the range with one new line, drop the leading \`-\` and use \`${lidA}..${lidB}=${text}\`.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
150
275
|
const exact = LID_EXACT_RE.exec(trimmedBody);
|
|
151
276
|
if (exact) {
|
|
152
277
|
const ln = Number.parseInt(exact[1], 10);
|
|
@@ -171,6 +296,17 @@ function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
|
171
296
|
return null;
|
|
172
297
|
}
|
|
173
298
|
|
|
299
|
+
function parseIndentedHashlineStmt(line: string, lineNum: number): ParsedStmt[] | null {
|
|
300
|
+
const trimmed = line.trimStart();
|
|
301
|
+
if (trimmed === line) return null;
|
|
302
|
+
const stmts = parseLidStmt(trimmed, lineNum);
|
|
303
|
+
if (!stmts) return null;
|
|
304
|
+
const safeHashlineEcho = stmts.every(
|
|
305
|
+
stmt => stmt.kind === "bare_anchor" || (stmt.kind === "anchor_op" && stmt.op.op === "set"),
|
|
306
|
+
);
|
|
307
|
+
return safeHashlineEcho ? stmts : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
174
310
|
function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
|
|
175
311
|
const text = line.trimStart();
|
|
176
312
|
const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
|
|
@@ -200,18 +336,29 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
200
336
|
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
201
337
|
if (line.length === 0) return [];
|
|
202
338
|
|
|
339
|
+
// `# ...` comments are silently ignored. Models often add section headers
|
|
340
|
+
// or annotations like `# Test 1: replace enum`; treating these as literal
|
|
341
|
+
// inserts corrupts files, and the canonical syntax has no comment op.
|
|
342
|
+
if (line[0] === "#") return [];
|
|
343
|
+
|
|
344
|
+
const indentedHashline = parseIndentedHashlineStmt(line, lineNum);
|
|
345
|
+
if (indentedHashline) return indentedHashline;
|
|
346
|
+
|
|
203
347
|
// `+TEXT` inserts at the cursor. Everything after `+` is content. A
|
|
204
348
|
// `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
|
|
205
349
|
// emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
|
|
206
350
|
if (line[0] === "+") {
|
|
207
351
|
const body = line.slice(1);
|
|
352
|
+
if (body.startsWith(RANGE_CONTINUATION_SENTINEL)) {
|
|
353
|
+
return [{ kind: "insert", text: body.slice(RANGE_CONTINUATION_SENTINEL.length), lineNum }];
|
|
354
|
+
}
|
|
208
355
|
const m = LID_RE.exec(body);
|
|
209
356
|
if (m) {
|
|
210
357
|
const sep = body[m[0].length];
|
|
211
358
|
if (sep === "=" || sep === "|") {
|
|
212
359
|
const ln = Number.parseInt(m[1], 10);
|
|
213
360
|
const text = body.slice(m[0].length + 1);
|
|
214
|
-
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, text, lineNum }];
|
|
361
|
+
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, separator: sep, text, lineNum }];
|
|
215
362
|
}
|
|
216
363
|
}
|
|
217
364
|
|
|
@@ -237,8 +384,58 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
237
384
|
}
|
|
238
385
|
|
|
239
386
|
// Canonical file-scope locators.
|
|
240
|
-
if (line === "
|
|
241
|
-
if (line === "
|
|
387
|
+
if (line === "^") return [{ kind: "bof", lineNum }];
|
|
388
|
+
if (line === "$") return [{ kind: "eof", lineNum }];
|
|
389
|
+
|
|
390
|
+
// Compound shorthand: `^+TEXT` and `$+TEXT` collapse a file-scope cursor
|
|
391
|
+
// move and an insert onto one line. Models occasionally do this when
|
|
392
|
+
// creating files from scratch (`^+content`) instead of the canonical
|
|
393
|
+
// `^\n+content`. No legitimate op line starts with `^+` or `$+`, so the
|
|
394
|
+
// expansion is unambiguous.
|
|
395
|
+
if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "+") {
|
|
396
|
+
const cursor: ParsedStmt = line[0] === "^" ? { kind: "bof", lineNum } : { kind: "eof", lineNum };
|
|
397
|
+
return [cursor, { kind: "insert", text: line.slice(2), lineNum }];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// `^=TEXT` and `$=TEXT` are not valid: `^` and `$` are cursor moves only.
|
|
401
|
+
// Models reach for these when trying to "replace the last line" or
|
|
402
|
+
// "replace the first line"; emit a clear diagnostic instead of falling
|
|
403
|
+
// through to "unrecognized op".
|
|
404
|
+
if (line.length >= 2 && (line[0] === "^" || line[0] === "$") && line[1] === "=") {
|
|
405
|
+
const where = line[0] === "^" ? "first" : "last";
|
|
406
|
+
const sym = line[0];
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Diff line ${lineNum}: \`${sym}=TEXT\` is not a valid op. \`${sym}\` only moves the cursor (${sym === "^" ? "BOF" : "EOF"}); it cannot replace a line. To replace the ${where} line, use its Lid (e.g. \`5xx=TEXT\`). To insert at ${sym === "^" ? "BOF" : "EOF"}, use \`${sym}\` followed by \`+TEXT\` on the next line.`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// `^Lid` cursor moves BEFORE the anchored line (insert above). Compound
|
|
413
|
+
// shorthand `^Lid+TEXT` collapses the cursor move and an insert into one
|
|
414
|
+
// line. `^Lid=TEXT` and `^Lid|TEXT` are flagged as ambiguous: pick either
|
|
415
|
+
// `^Lid` (cursor before) + `+TEXT`, or `Lid=TEXT` (replace in place).
|
|
416
|
+
if (line[0] === "^" && line.length > 1) {
|
|
417
|
+
const m = LID_RE.exec(line.slice(1));
|
|
418
|
+
if (m) {
|
|
419
|
+
const ln = Number.parseInt(m[1], 10);
|
|
420
|
+
const hash = m[2];
|
|
421
|
+
const sep = line[1 + m[0].length];
|
|
422
|
+
if (sep === undefined) {
|
|
423
|
+
return [{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum }];
|
|
424
|
+
}
|
|
425
|
+
if (sep === "+") {
|
|
426
|
+
const text = line.slice(1 + m[0].length + 1);
|
|
427
|
+
return [
|
|
428
|
+
{ kind: "before_anchor", anchor: { line: ln, hash }, lineNum },
|
|
429
|
+
{ kind: "insert", text, lineNum },
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
if (sep === "=" || sep === "|") {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`Diff line ${lineNum}: \`^${ln}${hash}${sep}...\` mixes \`^Lid\` (cursor before line) with \`Lid=TEXT\` (replace line). Pick one: \`^${ln}${hash}\` then \`+TEXT\` on the next line to insert above; or \`${ln}${hash}=TEXT\` to replace the line in place.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
242
439
|
|
|
243
440
|
// `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
|
|
244
441
|
// historical `-Lid TEXT` delete-then-insert recovery.
|
|
@@ -259,7 +456,7 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
259
456
|
if (deleteStmt) return deleteStmt;
|
|
260
457
|
|
|
261
458
|
const lidStmt = parseLidStmt(body, lineNum);
|
|
262
|
-
if (lidStmt) return
|
|
459
|
+
if (lidStmt) return lidStmt;
|
|
263
460
|
|
|
264
461
|
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
265
462
|
}
|
|
@@ -268,17 +465,17 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
268
465
|
// `@Lid|TEXT`, `@$`, and `@^`.
|
|
269
466
|
if (line[0] === "@") {
|
|
270
467
|
const body = line.slice(1);
|
|
271
|
-
if (body === "
|
|
272
|
-
if (body === "
|
|
468
|
+
if (body === "^") return [{ kind: "bof", lineNum }];
|
|
469
|
+
if (body === "$") return [{ kind: "eof", lineNum }];
|
|
273
470
|
const lidStmt = parseLidStmt(body, lineNum);
|
|
274
|
-
if (lidStmt) return
|
|
471
|
+
if (lidStmt) return lidStmt;
|
|
275
472
|
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
276
473
|
}
|
|
277
474
|
|
|
278
475
|
// `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
|
|
279
476
|
// A bare `Lid` is a cursor move.
|
|
280
477
|
const lidStmt = parseLidStmt(line, lineNum);
|
|
281
|
-
if (lidStmt) return
|
|
478
|
+
if (lidStmt) return lidStmt;
|
|
282
479
|
|
|
283
480
|
if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
|
|
284
481
|
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
@@ -288,14 +485,105 @@ function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
|
288
485
|
// emitted multi-line content after a `Lid=` or similar without `+` prefixes,
|
|
289
486
|
// or pasted raw context. Silently treating these as inserts corrupts files.
|
|
290
487
|
const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
488
|
+
const trailingDash = /^([1-9]\d*[a-z]{2})-\s*$/.exec(line);
|
|
489
|
+
if (trailingDash) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`Diff line ${lineNum}: \`${line}\` looks like a delete with the operator on the wrong side. Use \`-${trailingDash[1]}\` to delete that line.`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
291
494
|
throw new Error(
|
|
292
495
|
`Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
|
|
293
496
|
);
|
|
294
497
|
}
|
|
295
498
|
|
|
499
|
+
// Lines that look like recognized atom ops. Used to delimit range-replace
|
|
500
|
+
// recovery continuation: after `LidA..LidB=TEXT` (or legacy `|` separator),
|
|
501
|
+
// is treated as literal replacement text for backward compatibility.
|
|
502
|
+
const OP_LINE_HEAD_RE = /^([+\-@$^!]|[1-9]\d*[a-z]{2}|[ \t]*$)/;
|
|
503
|
+
const RANGE_CONTINUATION_SENTINEL = "\u0000";
|
|
504
|
+
|
|
505
|
+
function isRangeReplaceStart(line: string): boolean {
|
|
506
|
+
return /^[1-9]\d*[a-z]{2}\.\.[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(line);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// A single-line `Lid=TEXT` (or legacy `Lid|TEXT`, with optional leading `@`)
|
|
510
|
+
// also opens a replacement that `\TEXT` continuation lines may extend. The
|
|
511
|
+
// continuation lines become inserts at the cursor (which sits on the just-set
|
|
512
|
+
// line), turning `Lid=A` + `\B` + `\C` into "set the line to A, then insert B
|
|
513
|
+
// and C below it" — i.e. a multi-line rewrite of one anchor without forcing
|
|
514
|
+
// the user to switch to the `LidA..LidB=` range form.
|
|
515
|
+
function isReplaceStart(line: string): boolean {
|
|
516
|
+
if (isRangeReplaceStart(line)) return true;
|
|
517
|
+
const stripped = line.startsWith("@") ? line.slice(1) : line;
|
|
518
|
+
return /^[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(stripped);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Lookahead used by the blank-line forgiveness rule below: returns true when
|
|
522
|
+
// the first non-blank line at or after `start` is a `\TEXT` continuation.
|
|
523
|
+
function nextNonBlankIsBackslash(lines: readonly string[], start: number): boolean {
|
|
524
|
+
for (let j = start; j < lines.length; j++) {
|
|
525
|
+
const peek = lines[j].endsWith("\r") ? lines[j].slice(0, -1) : lines[j];
|
|
526
|
+
if (peek.length === 0) continue;
|
|
527
|
+
return peek.startsWith("\\");
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or
|
|
533
|
+
// `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the
|
|
534
|
+
// rest of the line is inserted literally, so `\\TEXT` inserts a line starting
|
|
535
|
+
// with `\TEXT`. As a forgiveness rule, a literal blank line inside an active
|
|
536
|
+
// replacement that is itself followed (possibly through more blanks) by another
|
|
537
|
+
// `\TEXT` continuation is treated as an implicit `\` blank insert — authors
|
|
538
|
+
// frequently drop a real blank between `\TEXT` lines instead of writing `\`.
|
|
539
|
+
// Raw unprefixed continuation remains an undocumented best-effort recovery for
|
|
540
|
+
// range replacements only, kept for old transcripts.
|
|
541
|
+
function preprocessRangeReplaceContinuation(diff: string): string {
|
|
542
|
+
const lines = diff.split("\n");
|
|
543
|
+
let inRangeReplace = false;
|
|
544
|
+
let inReplace = false;
|
|
545
|
+
for (let i = 0; i < lines.length; i++) {
|
|
546
|
+
const rawLine = lines[i];
|
|
547
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
548
|
+
|
|
549
|
+
if (line.startsWith("\\")) {
|
|
550
|
+
if (!inReplace) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Diff line ${i + 1}: \\TEXT continuation is only valid immediately after a Lid=TEXT or LidA..LidB=FIRST_LINE replacement.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine.slice(1)}`;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Forgiveness: a blank line inside an active replacement that is followed
|
|
560
|
+
// by another `\TEXT` continuation is treated as an implicit `\` blank
|
|
561
|
+
// insert. Keeps the replacement open across the blank.
|
|
562
|
+
if (inReplace && line.length === 0 && nextNonBlankIsBackslash(lines, i + 1)) {
|
|
563
|
+
lines[i] = `+${RANGE_CONTINUATION_SENTINEL}`;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (inRangeReplace) {
|
|
568
|
+
if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) {
|
|
569
|
+
inRangeReplace = isRangeReplaceStart(line);
|
|
570
|
+
inReplace = isReplaceStart(line);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
lines[i] = `+${RANGE_CONTINUATION_SENTINEL}${rawLine}`;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
inRangeReplace = isRangeReplaceStart(line);
|
|
579
|
+
inReplace = isReplaceStart(line);
|
|
580
|
+
}
|
|
581
|
+
return lines.join("\n");
|
|
582
|
+
}
|
|
583
|
+
|
|
296
584
|
function tokenizeDiff(diff: string): ParsedStmt[] {
|
|
297
585
|
const out: ParsedStmt[] = [];
|
|
298
|
-
const lines = diff.split("\n");
|
|
586
|
+
const lines = preprocessRangeReplaceContinuation(diff).split("\n");
|
|
299
587
|
for (let i = 0; i < lines.length; i++) {
|
|
300
588
|
const lineNum = i + 1;
|
|
301
589
|
const stmts = parseDiffLine(lines[i], lineNum);
|
|
@@ -327,11 +615,14 @@ function tokenizeDiff(diff: string): ParsedStmt[] {
|
|
|
327
615
|
// Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
|
|
328
616
|
// hunks and reorder so adds land at the FIRST delete's slot (block
|
|
329
617
|
// replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
|
|
330
|
-
// `set
|
|
618
|
+
// `set`; malformed standalone or mismatched `+Lid|TEXT`/`+Lid=TEXT` lines
|
|
619
|
+
// throw instead of silently dropping the Lid prefix.
|
|
331
620
|
function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
332
621
|
const isDelete = (s: ParsedStmt): boolean =>
|
|
333
622
|
(s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
|
|
334
623
|
const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
|
|
624
|
+
const formatDiffishAdd = (stmt: DiffishAddStmt): string =>
|
|
625
|
+
`+${stmt.anchor.line}${stmt.anchor.hash}${stmt.separator}${stmt.text}`;
|
|
335
626
|
const out: ParsedStmt[] = [];
|
|
336
627
|
let i = 0;
|
|
337
628
|
while (i < stmts.length) {
|
|
@@ -340,7 +631,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
|
340
631
|
if (stmt.kind === "diffish_add") {
|
|
341
632
|
const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
|
|
342
633
|
throw new Error(
|
|
343
|
-
`Diff line ${stmt.lineNum}:
|
|
634
|
+
`Diff line ${stmt.lineNum}: \`${formatDiffishAdd(stmt)}\` is unified-diff syntax, not edit syntax. To replace a line, use \`${lid}=TEXT\`; to insert literal text, use \`+TEXT\` without a Lid prefix.`,
|
|
344
635
|
);
|
|
345
636
|
}
|
|
346
637
|
out.push(stmt);
|
|
@@ -368,7 +659,7 @@ function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
|
368
659
|
const lid = `${add.anchor.line}${add.anchor.hash}`;
|
|
369
660
|
if (!deletedLids.has(lid)) {
|
|
370
661
|
throw new Error(
|
|
371
|
-
`Diff line ${add.lineNum}:
|
|
662
|
+
`Diff line ${add.lineNum}: \`${formatDiffishAdd(add)}\` references a Lid not in the preceding delete run. Use plain \`+TEXT\` for replacement lines, or delete \`${lid}\` before using a unified-diff recovery line for that Lid.`,
|
|
372
663
|
);
|
|
373
664
|
}
|
|
374
665
|
}
|
|
@@ -444,7 +735,12 @@ function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
|
|
|
444
735
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
445
736
|
|
|
446
737
|
export function parseAtom(diff: string): AtomEdit[] {
|
|
738
|
+
return parseAtomWithWarnings(diff).edits;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export function parseAtomWithWarnings(diff: string): { edits: AtomEdit[]; warnings: string[] } {
|
|
447
742
|
const edits: AtomEdit[] = [];
|
|
743
|
+
const warnings: string[] = [];
|
|
448
744
|
let cursor: AtomCursor = { kind: "eof" };
|
|
449
745
|
let index = 0;
|
|
450
746
|
|
|
@@ -473,7 +769,12 @@ export function parseAtom(diff: string): AtomEdit[] {
|
|
|
473
769
|
}
|
|
474
770
|
|
|
475
771
|
if (stmt.kind === "diffish_add") {
|
|
476
|
-
throw new Error("Internal
|
|
772
|
+
throw new Error("Internal edit error: unresolved diff-ish add reached parser.");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (stmt.kind === "before_anchor") {
|
|
776
|
+
cursor = { kind: "before_anchor", anchor: makeAnchor(stmt.anchor) };
|
|
777
|
+
continue;
|
|
477
778
|
}
|
|
478
779
|
|
|
479
780
|
const anchor = makeAnchor(stmt.anchor);
|
|
@@ -502,7 +803,7 @@ export function parseAtom(diff: string): AtomEdit[] {
|
|
|
502
803
|
index++;
|
|
503
804
|
}
|
|
504
805
|
|
|
505
|
-
return edits;
|
|
806
|
+
return { edits, warnings };
|
|
506
807
|
}
|
|
507
808
|
|
|
508
809
|
function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
|
|
@@ -523,7 +824,7 @@ function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
|
|
|
523
824
|
|
|
524
825
|
function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
|
|
525
826
|
if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
|
|
526
|
-
if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
|
|
827
|
+
if (edit.cursor.kind === "anchor" || edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
|
|
527
828
|
return [];
|
|
528
829
|
}
|
|
529
830
|
|
|
@@ -535,6 +836,7 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
535
836
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
536
837
|
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
537
838
|
}
|
|
839
|
+
if (anchor.hash === RANGE_INTERIOR_HASH) continue;
|
|
538
840
|
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
539
841
|
if (actualHash === anchor.hash) continue;
|
|
540
842
|
|
|
@@ -658,8 +960,8 @@ function applyFileCursorInserts(
|
|
|
658
960
|
|
|
659
961
|
function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
|
|
660
962
|
if (edit.kind !== "insert") return edit.anchor;
|
|
661
|
-
if (edit.cursor.kind !== "anchor") {
|
|
662
|
-
throw new Error("Internal
|
|
963
|
+
if (edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
|
|
964
|
+
throw new Error("Internal edit error: file-scoped insert reached anchor application.");
|
|
663
965
|
}
|
|
664
966
|
return edit.cursor.anchor;
|
|
665
967
|
}
|
|
@@ -670,6 +972,9 @@ function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
|
|
|
670
972
|
// missed one delete on the front or back of the deletion range, leaving a
|
|
671
973
|
// stale copy of a line the agent already re-emitted (e.g. inserting a new
|
|
672
974
|
// closing `}` while the original `}` was never deleted, producing `}\n}`).
|
|
975
|
+
// A single edit may damage multiple unrelated segments (e.g. two block
|
|
976
|
+
// rewrites that each missed their trailing `}`), so detection and auto-fix
|
|
977
|
+
// operate on every new adjacent duplicate at once.
|
|
673
978
|
//
|
|
674
979
|
// Auto-fix is gated on bracket balance: we only remove the duplicate line if
|
|
675
980
|
// its removal restores the original file's `{}`/`()`/`[]` delta. That makes
|
|
@@ -729,23 +1034,28 @@ function detectAndAutoFixDuplicates(
|
|
|
729
1034
|
|
|
730
1035
|
const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
|
|
731
1036
|
|
|
732
|
-
// Auto-fix
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1037
|
+
// Auto-fix when removing one line from each new adjacent duplicate pair
|
|
1038
|
+
// collectively restores the original bracket balance. The balance check is
|
|
1039
|
+
// the safety gate: if we over- or under-correct (e.g. when 3+ adjacent
|
|
1040
|
+
// identical lines confuse the per-pair scan), the trial balance will not
|
|
1041
|
+
// match and we fall through to warnings.
|
|
1042
|
+
const origBalance = computeBalance(originalLines);
|
|
1043
|
+
const finalBalance = computeBalance(finalLines);
|
|
1044
|
+
if (!balancesEqual(origBalance, finalBalance)) {
|
|
1045
|
+
const trial = finalLines.slice();
|
|
1046
|
+
// Remove in reverse so earlier indices remain valid.
|
|
1047
|
+
for (let i = newDupPositions.length - 1; i >= 0; i--) {
|
|
1048
|
+
trial.splice(newDupPositions[i], 1);
|
|
1049
|
+
}
|
|
1050
|
+
if (balancesEqual(computeBalance(trial), origBalance)) {
|
|
1051
|
+
const previews = newDupPositions.map(pos => `${pos + 1} (${formatPreview(finalLines[pos])})`).join(", ");
|
|
1052
|
+
const noun = newDupPositions.length === 1 ? "duplicate line" : "duplicate lines";
|
|
1053
|
+
return {
|
|
1054
|
+
fixed: trial,
|
|
1055
|
+
warnings: [
|
|
1056
|
+
`Auto-fixed: removed ${noun} ${previews}; the edit left adjacent identical lines and bracket balance was off. Verify the result.`,
|
|
1057
|
+
],
|
|
1058
|
+
};
|
|
749
1059
|
}
|
|
750
1060
|
}
|
|
751
1061
|
|
|
@@ -779,7 +1089,7 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
779
1089
|
const anchorEdits: IndexedAnchorEdit[] = [];
|
|
780
1090
|
const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
|
|
781
1091
|
edits.forEach((edit, idx) => {
|
|
782
|
-
if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
|
|
1092
|
+
if (edit.kind === "insert" && edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
|
|
783
1093
|
fileInserts.push(edit);
|
|
784
1094
|
return;
|
|
785
1095
|
}
|
|
@@ -808,12 +1118,17 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
808
1118
|
let replacement: string[] = [currentLine];
|
|
809
1119
|
let replacementSet = false;
|
|
810
1120
|
let anchorMutated = false;
|
|
1121
|
+
const beforeLines: string[] = [];
|
|
811
1122
|
const afterLines: string[] = [];
|
|
812
1123
|
|
|
813
1124
|
for (const { edit } of bucket) {
|
|
814
1125
|
switch (edit.kind) {
|
|
815
1126
|
case "insert":
|
|
816
|
-
|
|
1127
|
+
if (edit.cursor.kind === "before_anchor") {
|
|
1128
|
+
beforeLines.push(edit.text);
|
|
1129
|
+
} else {
|
|
1130
|
+
afterLines.push(edit.text);
|
|
1131
|
+
}
|
|
817
1132
|
break;
|
|
818
1133
|
case "set":
|
|
819
1134
|
replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
|
|
@@ -832,7 +1147,10 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
832
1147
|
}
|
|
833
1148
|
|
|
834
1149
|
const replacementProducesNoChange =
|
|
835
|
-
|
|
1150
|
+
beforeLines.length === 0 &&
|
|
1151
|
+
afterLines.length === 0 &&
|
|
1152
|
+
replacement.length === 1 &&
|
|
1153
|
+
replacement[0] === currentLine;
|
|
836
1154
|
if (replacementProducesNoChange) {
|
|
837
1155
|
const firstEdit = bucket[0]?.edit;
|
|
838
1156
|
const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
|
|
@@ -848,14 +1166,14 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
|
|
|
848
1166
|
continue;
|
|
849
1167
|
}
|
|
850
1168
|
|
|
851
|
-
const combined = [...replacement, ...afterLines];
|
|
1169
|
+
const combined = [...beforeLines, ...replacement, ...afterLines];
|
|
852
1170
|
fileLines.splice(idx, 1, ...combined);
|
|
853
|
-
if (anchorMutated) {
|
|
1171
|
+
if (anchorMutated || beforeLines.length > 0) {
|
|
854
1172
|
trackFirstChanged(line);
|
|
855
1173
|
} else if (afterLines.length > 0) {
|
|
856
1174
|
trackFirstChanged(line + 1);
|
|
857
1175
|
}
|
|
858
|
-
if (!replacementSet && afterLines.length === 0) continue;
|
|
1176
|
+
if (!replacementSet && beforeLines.length === 0 && afterLines.length === 0) continue;
|
|
859
1177
|
}
|
|
860
1178
|
|
|
861
1179
|
const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
|
|
@@ -928,7 +1246,7 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
|
|
|
928
1246
|
if (body.startsWith(" ")) body = body.slice(1);
|
|
929
1247
|
const parsedPath = normalizeAtomPath(body, cwd);
|
|
930
1248
|
if (parsedPath.length === 0) {
|
|
931
|
-
throw new Error(`
|
|
1249
|
+
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
932
1250
|
}
|
|
933
1251
|
return parsedPath;
|
|
934
1252
|
}
|
|
@@ -936,21 +1254,21 @@ function parseAtomHeaderLine(line: string, cwd?: string): string | null {
|
|
|
936
1254
|
function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
|
|
937
1255
|
const trimmed = rawPath.trim();
|
|
938
1256
|
if (trimmed.length === 0) {
|
|
939
|
-
throw new Error(`
|
|
1257
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
940
1258
|
}
|
|
941
1259
|
|
|
942
1260
|
const quote = trimmed[0];
|
|
943
1261
|
if (quote === '"' || quote === "'") {
|
|
944
1262
|
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
|
|
945
|
-
throw new Error(`
|
|
1263
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
946
1264
|
}
|
|
947
1265
|
} else if (/\s/.test(trimmed)) {
|
|
948
|
-
throw new Error(`
|
|
1266
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
949
1267
|
}
|
|
950
1268
|
|
|
951
1269
|
const destination = normalizeAtomPath(trimmed, cwd);
|
|
952
1270
|
if (destination.length === 0) {
|
|
953
|
-
throw new Error(`
|
|
1271
|
+
throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
954
1272
|
}
|
|
955
1273
|
return destination;
|
|
956
1274
|
}
|
|
@@ -965,11 +1283,11 @@ function parseAtomWholeFileOperationLine(
|
|
|
965
1283
|
return { kind: "delete", lineNum };
|
|
966
1284
|
}
|
|
967
1285
|
if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
|
|
968
|
-
throw new Error(`
|
|
1286
|
+
throw new Error(`Diff line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
|
|
969
1287
|
}
|
|
970
1288
|
|
|
971
1289
|
if (line === MOVE_FILE_OPERATION) {
|
|
972
|
-
throw new Error(`
|
|
1290
|
+
throw new Error(`Diff line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
|
|
973
1291
|
}
|
|
974
1292
|
if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
|
|
975
1293
|
const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
|
|
@@ -1001,7 +1319,7 @@ function getAtomWholeFileOperation(
|
|
|
1001
1319
|
if (parsed) {
|
|
1002
1320
|
if (operation) {
|
|
1003
1321
|
throw new Error(
|
|
1004
|
-
`
|
|
1322
|
+
`Edit section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
|
|
1005
1323
|
);
|
|
1006
1324
|
}
|
|
1007
1325
|
operation = parsed;
|
|
@@ -1014,7 +1332,7 @@ function getAtomWholeFileOperation(
|
|
|
1014
1332
|
|
|
1015
1333
|
if (operation && hasLineEdit) {
|
|
1016
1334
|
throw new Error(
|
|
1017
|
-
`
|
|
1335
|
+
`Edit section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
|
|
1018
1336
|
);
|
|
1019
1337
|
}
|
|
1020
1338
|
|
|
@@ -1032,8 +1350,10 @@ function containsRecognizableAtomOperations(input: string): boolean {
|
|
|
1032
1350
|
if (line.length === 0) continue;
|
|
1033
1351
|
if (line[0] === "+") return true;
|
|
1034
1352
|
if (line === "$" || line === "^") return true;
|
|
1035
|
-
if (
|
|
1036
|
-
if (
|
|
1353
|
+
if (/^\$\+.*$/.test(line)) return true;
|
|
1354
|
+
if (/^\^[1-9]\d*[a-z]{2}(?:\+.*)?$/.test(line)) return true;
|
|
1355
|
+
if (/^- ?[1-9]\d*[a-z]{2}(?:\.\.[1-9]\d*[a-z]{2})?(?:[ \t]*[=|].*| .*)?$/.test(line)) return true;
|
|
1356
|
+
if (/^@?[1-9]\d*[a-z]{2}(?:\+.*|[ \t]*[=|].*|\.\.[1-9]\d*[a-z]{2}[ \t]*=.*)?$/.test(line)) return true;
|
|
1037
1357
|
if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
|
|
1038
1358
|
}
|
|
1039
1359
|
return false;
|
|
@@ -1048,8 +1368,42 @@ function stripLeadingBlankLines(input: string): string {
|
|
|
1048
1368
|
return lines.join("\n");
|
|
1049
1369
|
}
|
|
1050
1370
|
|
|
1371
|
+
function normalizeStandaloneFileOpInput(input: string, cwd?: string): string | null {
|
|
1372
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1373
|
+
const lines = stripped.split("\n");
|
|
1374
|
+
let firstIdx = -1;
|
|
1375
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1376
|
+
if (lines[i].replace(/\r$/, "").trim().length > 0) {
|
|
1377
|
+
firstIdx = i;
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (firstIdx === -1) return null;
|
|
1382
|
+
const firstLine = lines[firstIdx].replace(/\r$/, "");
|
|
1383
|
+
const remaining = lines.slice(firstIdx + 1).join("\n");
|
|
1384
|
+
if (remaining.trim().length > 0) return null;
|
|
1385
|
+
|
|
1386
|
+
const rmMatch = /^!rm\s+(\S.*)$/.exec(firstLine);
|
|
1387
|
+
if (rmMatch) {
|
|
1388
|
+
const sourcePath = parseSingleAtomPathArgument(rmMatch[1], REMOVE_FILE_OPERATION, firstIdx + 1, cwd);
|
|
1389
|
+
return `${FILE_HEADER_PREFIX}${sourcePath}\n${REMOVE_FILE_OPERATION}`;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const mvMatch = /^!mv\s+(\S+)\s+(\S.*)$/.exec(firstLine);
|
|
1393
|
+
if (mvMatch) {
|
|
1394
|
+
const sourcePath = parseSingleAtomPathArgument(mvMatch[1], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
|
|
1395
|
+
const destPath = parseSingleAtomPathArgument(mvMatch[2], MOVE_FILE_OPERATION, firstIdx + 1, cwd);
|
|
1396
|
+
return `${FILE_HEADER_PREFIX}${sourcePath}\n${MOVE_FILE_OPERATION} ${destPath}`;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1051
1402
|
function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
|
|
1052
|
-
if (hasAtomHeaderLine(input)
|
|
1403
|
+
if (hasAtomHeaderLine(input)) return input;
|
|
1404
|
+
const standalone = normalizeStandaloneFileOpInput(input, options.cwd);
|
|
1405
|
+
if (standalone !== null) return standalone;
|
|
1406
|
+
if (!options.path || !containsRecognizableAtomOperations(input)) {
|
|
1053
1407
|
return input;
|
|
1054
1408
|
}
|
|
1055
1409
|
const fallbackPath = normalizeAtomPath(options.path, options.cwd);
|
|
@@ -1082,10 +1436,11 @@ export function splitAtomInputs(input: string, options: SplitAtomOptions = {}):
|
|
|
1082
1436
|
const lines = stripped.split("\n");
|
|
1083
1437
|
const firstLine = (lines[0] ?? "").replace(/\r$/, "");
|
|
1084
1438
|
if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
|
|
1439
|
+
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
1085
1440
|
throw new Error(
|
|
1086
|
-
`
|
|
1087
|
-
|
|
1088
|
-
|
|
1441
|
+
`input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${preview}.\n` +
|
|
1442
|
+
`Example: "${FILE_HEADER_PREFIX}src/foo.ts" then your edit ops on the following lines. ` +
|
|
1443
|
+
`To delete a file: "${FILE_HEADER_PREFIX}<path>\\n!rm". To rename: "${FILE_HEADER_PREFIX}<src>\\n!mv <dest>".`,
|
|
1089
1444
|
);
|
|
1090
1445
|
}
|
|
1091
1446
|
|
|
@@ -1146,7 +1501,13 @@ async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
|
|
|
1146
1501
|
}
|
|
1147
1502
|
|
|
1148
1503
|
function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
|
|
1149
|
-
return edits.some(
|
|
1504
|
+
return edits.some(
|
|
1505
|
+
edit =>
|
|
1506
|
+
edit.kind === "set" ||
|
|
1507
|
+
edit.kind === "delete" ||
|
|
1508
|
+
edit.cursor.kind === "anchor" ||
|
|
1509
|
+
edit.cursor.kind === "before_anchor",
|
|
1510
|
+
);
|
|
1150
1511
|
}
|
|
1151
1512
|
|
|
1152
1513
|
function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
|
|
@@ -1162,6 +1523,12 @@ function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string
|
|
|
1162
1523
|
})
|
|
1163
1524
|
.join("\n");
|
|
1164
1525
|
diagnostic += `\n${details}`;
|
|
1526
|
+
const setNoops = result.noopEdits.filter(e => e.reason.startsWith("replacement is identical"));
|
|
1527
|
+
if (setNoops.length > 0) {
|
|
1528
|
+
diagnostic +=
|
|
1529
|
+
"\n\nHint: each `Lid=TEXT` you emit MUST contain text that differs from the line currently anchored by Lid. " +
|
|
1530
|
+
"Do not echo lines back from `read` output unchanged. If you intended to leave a line as-is, omit it from the patch.";
|
|
1531
|
+
}
|
|
1165
1532
|
}
|
|
1166
1533
|
return diagnostic;
|
|
1167
1534
|
}
|
|
@@ -1219,6 +1586,60 @@ async function executeAtomWholeFileOperation(
|
|
|
1219
1586
|
};
|
|
1220
1587
|
}
|
|
1221
1588
|
|
|
1589
|
+
async function preflightAtomSection(options: ExecuteAtomSingleOptions & AtomInputSection): Promise<void> {
|
|
1590
|
+
const { session, path: sectionPath, diff } = options;
|
|
1591
|
+
if (options.wholeFileOperation) {
|
|
1592
|
+
const { wholeFileOperation } = options;
|
|
1593
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
1594
|
+
if (sectionPath.endsWith(".ipynb")) {
|
|
1595
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1596
|
+
}
|
|
1597
|
+
if (wholeFileOperation.kind === "delete") {
|
|
1598
|
+
enforcePlanModeWrite(session, sectionPath, { op: "delete" });
|
|
1599
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const destinationPath = wholeFileOperation.destination;
|
|
1604
|
+
if (destinationPath.endsWith(".ipynb")) {
|
|
1605
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1606
|
+
}
|
|
1607
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
|
|
1608
|
+
const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
|
|
1609
|
+
if (absoluteDestinationPath === absolutePath) {
|
|
1610
|
+
throw new Error("rename path is the same as source path");
|
|
1611
|
+
}
|
|
1612
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const { edits } = parseAtomWithWarnings(diff);
|
|
1617
|
+
if (edits.length === 0 && diff.trim().length > 0) {
|
|
1618
|
+
throw new Error(formatNoAtomEditDiagnostic(sectionPath, diff));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
1622
|
+
if (sectionPath.endsWith(".ipynb") && edits.length > 0) {
|
|
1623
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
1627
|
+
const source = await readAtomFile(absolutePath);
|
|
1628
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) {
|
|
1629
|
+
throw new Error(`File not found: ${sectionPath}`);
|
|
1630
|
+
}
|
|
1631
|
+
if (source.exists) {
|
|
1632
|
+
assertEditableFileContent(source.rawContent, sectionPath);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const { text } = stripBom(source.rawContent);
|
|
1636
|
+
const originalNormalized = normalizeToLF(text);
|
|
1637
|
+
const result = applyAtomEdits(originalNormalized, edits);
|
|
1638
|
+
if (originalNormalized === result.lines && (result.noopEdits?.length ?? 0) === 0) {
|
|
1639
|
+
throw new Error(formatNoChangeDiagnostic(sectionPath, result));
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1222
1643
|
async function executeAtomSection(
|
|
1223
1644
|
options: ExecuteAtomSingleOptions & AtomInputSection,
|
|
1224
1645
|
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
@@ -1227,7 +1648,7 @@ async function executeAtomSection(
|
|
|
1227
1648
|
return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
|
|
1228
1649
|
}
|
|
1229
1650
|
|
|
1230
|
-
const edits =
|
|
1651
|
+
const { edits, warnings: parseWarnings } = parseAtomWithWarnings(diff);
|
|
1231
1652
|
if (edits.length === 0 && diff.trim().length > 0) {
|
|
1232
1653
|
throw new Error(formatNoAtomEditDiagnostic(path, diff));
|
|
1233
1654
|
}
|
|
@@ -1253,7 +1674,18 @@ async function executeAtomSection(
|
|
|
1253
1674
|
const originalNormalized = normalizeToLF(text);
|
|
1254
1675
|
const result = applyAtomEdits(originalNormalized, edits);
|
|
1255
1676
|
if (originalNormalized === result.lines) {
|
|
1256
|
-
|
|
1677
|
+
const allNoop = (result.noopEdits?.length ?? 0) > 0;
|
|
1678
|
+
if (!allNoop) {
|
|
1679
|
+
throw new Error(formatNoChangeDiagnostic(path, result));
|
|
1680
|
+
}
|
|
1681
|
+
// Every edit was a no-op (TEXT identical to the anchored line). Returning
|
|
1682
|
+
// success here breaks retry loops where models hammer the same `Lid=TEXT`
|
|
1683
|
+
// when TEXT happens to already match. The response makes the no-op
|
|
1684
|
+
// explicit so the model knows nothing changed and to move on.
|
|
1685
|
+
return {
|
|
1686
|
+
content: [{ type: "text", text: formatNoChangeDiagnostic(path, result) }],
|
|
1687
|
+
details: { diff: "", op: "update", meta: outputMeta().get() },
|
|
1688
|
+
};
|
|
1257
1689
|
}
|
|
1258
1690
|
|
|
1259
1691
|
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
|
@@ -1272,7 +1704,8 @@ async function executeAtomSection(
|
|
|
1272
1704
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
1273
1705
|
.get();
|
|
1274
1706
|
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
1275
|
-
const
|
|
1707
|
+
const allWarnings = [...parseWarnings, ...(result.warnings ?? [])];
|
|
1708
|
+
const warningsBlock = allWarnings.length > 0 ? `\n\nWarnings:\n${allWarnings.join("\n")}` : "";
|
|
1276
1709
|
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
1277
1710
|
const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
|
|
1278
1711
|
|
|
@@ -1302,6 +1735,10 @@ export async function executeAtomSingle(
|
|
|
1302
1735
|
return executeAtomSection({ ...options, ...section });
|
|
1303
1736
|
}
|
|
1304
1737
|
|
|
1738
|
+
for (const section of sections) {
|
|
1739
|
+
await preflightAtomSection({ ...options, ...section });
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1305
1742
|
const results = [];
|
|
1306
1743
|
for (const section of sections) {
|
|
1307
1744
|
results.push({
|