@oh-my-pi/pi-coding-agent 14.5.13 → 14.6.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/commit/pipeline.ts +4 -3
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/config/settings.ts +20 -1
  20. package/src/config.ts +9 -6
  21. package/src/cursor.ts +1 -1
  22. package/src/edit/index.ts +9 -31
  23. package/src/edit/line-hash.ts +70 -43
  24. package/src/edit/modes/hashline.lark +26 -0
  25. package/src/edit/modes/hashline.ts +898 -1099
  26. package/src/edit/modes/patch.ts +0 -7
  27. package/src/edit/modes/replace.ts +0 -4
  28. package/src/edit/renderer.ts +22 -20
  29. package/src/edit/streaming.ts +8 -28
  30. package/src/eval/eval.lark +24 -30
  31. package/src/eval/js/context-manager.ts +5 -162
  32. package/src/eval/js/prelude.txt +0 -12
  33. package/src/eval/parse.ts +129 -129
  34. package/src/eval/py/kernel.ts +4 -4
  35. package/src/eval/py/prelude.py +1 -219
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +2 -2
  38. package/src/internal-urls/docs-index.generated.ts +1 -1
  39. package/src/main.ts +10 -0
  40. package/src/mcp/manager.ts +22 -0
  41. package/src/modes/components/session-observer-overlay.ts +5 -2
  42. package/src/modes/components/status-line/segments.ts +1 -1
  43. package/src/modes/components/status-line.ts +3 -5
  44. package/src/modes/components/tree-selector.ts +4 -5
  45. package/src/modes/components/welcome.ts +11 -1
  46. package/src/modes/controllers/command-controller.ts +2 -6
  47. package/src/modes/controllers/event-controller.ts +1 -2
  48. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  49. package/src/modes/controllers/input-controller.ts +0 -1
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +5 -7
  52. package/src/modes/rpc/rpc-client.ts +9 -0
  53. package/src/modes/rpc/rpc-mode.ts +6 -0
  54. package/src/modes/rpc/rpc-types.ts +9 -0
  55. package/src/prompts/system/system-prompt.md +14 -38
  56. package/src/prompts/tools/ast-edit.md +8 -8
  57. package/src/prompts/tools/ast-grep.md +10 -10
  58. package/src/prompts/tools/eval.md +13 -31
  59. package/src/prompts/tools/find.md +2 -1
  60. package/src/prompts/tools/hashline.md +66 -57
  61. package/src/prompts/tools/search.md +2 -2
  62. package/src/sdk.ts +19 -4
  63. package/src/session/agent-session.ts +110 -4
  64. package/src/session/session-manager.ts +17 -13
  65. package/src/task/agents.ts +4 -5
  66. package/src/tools/archive-reader.ts +9 -3
  67. package/src/tools/ast-edit.ts +141 -44
  68. package/src/tools/ast-grep.ts +112 -36
  69. package/src/tools/browser/readable.ts +11 -6
  70. package/src/tools/browser/tab-supervisor.ts +2 -2
  71. package/src/tools/browser.ts +5 -3
  72. package/src/tools/eval.ts +2 -53
  73. package/src/tools/find.ts +16 -15
  74. package/src/tools/image-gen.ts +2 -2
  75. package/src/tools/path-utils.ts +36 -196
  76. package/src/tools/search.ts +56 -35
  77. package/src/tools/write.ts +8 -1
  78. package/src/utils/edit-mode.ts +2 -11
  79. package/src/utils/file-display-mode.ts +1 -1
  80. package/src/utils/git.ts +17 -0
  81. package/src/utils/session-color.ts +0 -12
  82. package/src/utils/title-generator.ts +22 -38
  83. package/src/web/scrapers/crossref.ts +3 -3
  84. package/src/web/scrapers/devto.ts +1 -1
  85. package/src/web/scrapers/discourse.ts +5 -5
  86. package/src/web/scrapers/firefox-addons.ts +1 -1
  87. package/src/web/scrapers/flathub.ts +2 -2
  88. package/src/web/scrapers/gitlab.ts +1 -1
  89. package/src/web/scrapers/go-pkg.ts +2 -2
  90. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  91. package/src/web/scrapers/mastodon.ts +9 -9
  92. package/src/web/scrapers/mdn.ts +11 -7
  93. package/src/web/scrapers/pub-dev.ts +1 -1
  94. package/src/web/scrapers/rawg.ts +3 -3
  95. package/src/web/scrapers/readthedocs.ts +1 -1
  96. package/src/web/scrapers/spdx.ts +1 -1
  97. package/src/web/scrapers/stackoverflow.ts +2 -2
  98. package/src/web/scrapers/types.ts +53 -39
  99. package/src/web/scrapers/w3c.ts +1 -1
  100. package/src/web/search/providers/gemini.ts +2 -2
  101. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  102. package/src/autoresearch/contract.ts +0 -288
  103. package/src/edit/modes/atom.lark +0 -29
  104. package/src/edit/modes/atom.ts +0 -1773
  105. package/src/prompts/tools/atom.md +0 -150
@@ -1,1773 +0,0 @@
1
- /**
2
- * Atom edit mode.
3
- *
4
- * Single-string compact wire format. Each file section starts with `---path`;
5
- * each following line is one statement:
6
- *
7
- * @Lid move cursor to just after the anchored line
8
- * Lid=TEXT set the anchored line to TEXT and move cursor after it
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)
12
- * +TEXT insert TEXT at the cursor
13
- * ^ move cursor to beginning of file
14
- * $ move cursor to end of file
15
- */
16
-
17
- import * as fs from "node:fs/promises";
18
- import * as path from "node:path";
19
- import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
20
- import { isEnoent } from "@oh-my-pi/pi-utils";
21
- import { type Static, Type } from "@sinclair/typebox";
22
- import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
23
- import type { ToolSession } from "../../tools";
24
- import { assertEditableFile, assertEditableFileContent } from "../../tools/auto-generated-guard";
25
- import {
26
- invalidateFsScanAfterDelete,
27
- invalidateFsScanAfterRename,
28
- invalidateFsScanAfterWrite,
29
- } from "../../tools/fs-cache-invalidation";
30
- import { outputMeta } from "../../tools/output-meta";
31
- import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
32
- import { generateDiffString } from "../diff";
33
- import { computeLineHash } from "../line-hash";
34
- import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
35
- import type { EditToolDetails, LspBatchRequest } from "../renderer";
36
- import {
37
- ANCHOR_REBASE_WINDOW,
38
- type Anchor,
39
- buildCompactHashlineDiffPreview,
40
- HashlineMismatchError,
41
- type HashMismatch,
42
- tryRebaseAnchor,
43
- } from "./hashline";
44
-
45
- // ═══════════════════════════════════════════════════════════════════════════
46
- // Schema
47
- // ═══════════════════════════════════════════════════════════════════════════
48
-
49
- export const atomEditParamsSchema = Type.Object({ input: Type.String() });
50
-
51
- export type AtomParams = Static<typeof atomEditParamsSchema>;
52
-
53
- // ═══════════════════════════════════════════════════════════════════════════
54
- // Parser
55
- // ═══════════════════════════════════════════════════════════════════════════
56
-
57
- // Permissive: any 2 lowercase letters. Invalid hashes flow through to a
58
- // HashlineMismatchError downstream, matching the other hashline-backed modes.
59
- const LID_RE = /^([1-9]\d*)([a-z]{2})/;
60
- const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
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
-
67
- interface ParsedAnchor {
68
- line: number;
69
- hash: string;
70
- }
71
-
72
- type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op: "delete" };
73
-
74
- type AnchorStmt =
75
- | { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
76
- | { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
77
- | { kind: "before_anchor"; anchor: ParsedAnchor; lineNum: number }
78
- | { kind: "bof"; lineNum: number }
79
- | { kind: "eof"; lineNum: number };
80
-
81
- type InsertStmt = {
82
- kind: "insert";
83
- text: string;
84
- lineNum: number;
85
- };
86
-
87
- type DiffishAddStmt = {
88
- kind: "diffish_add";
89
- anchor: ParsedAnchor;
90
- separator: "=" | "|";
91
- text: string;
92
- lineNum: number;
93
- };
94
-
95
- type DeleteWithOldStmt = {
96
- kind: "delete_with_old";
97
- anchor: ParsedAnchor;
98
- old: string;
99
- lineNum: number;
100
- };
101
-
102
- type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
103
-
104
- type AtomCursor =
105
- | { kind: "bof" }
106
- | { kind: "eof" }
107
- | { kind: "anchor"; anchor: Anchor }
108
- | { kind: "before_anchor"; anchor: Anchor };
109
-
110
- export type AtomEdit =
111
- | { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
112
- | { kind: "set"; anchor: Anchor; text: string; lineNum: number; index: number; allowOldNewRepair: boolean }
113
- | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
114
-
115
- interface AtomApplyResult {
116
- lines: string;
117
- firstChangedLine?: number;
118
- warnings?: string[];
119
- noopEdits?: AtomNoopEdit[];
120
- }
121
-
122
- interface AtomNoopEdit {
123
- editIndex: number;
124
- loc: string;
125
- reason: string;
126
- current: string;
127
- }
128
-
129
- interface IndexedAnchorEdit {
130
- edit: Extract<AtomEdit, { kind: "insert" | "set" | "delete" }>;
131
- idx: number;
132
- }
133
-
134
- function cloneCursor(cursor: AtomCursor): AtomCursor {
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;
138
- }
139
-
140
- function parseLidStmt(body: string, lineNum: number): ParsedStmt[] | null {
141
- const m = LID_RE.exec(body);
142
- if (!m) return null;
143
-
144
- const ln = Number.parseInt(m[1], 10);
145
- const hash = m[2];
146
- const rest = body.slice(m[0].length);
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
-
196
- if (rest.length === 0) {
197
- return [{ kind: "bare_anchor", anchor, lineNum }];
198
- }
199
-
200
- const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
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;
225
- }
226
-
227
- function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
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
-
275
- const exact = LID_EXACT_RE.exec(trimmedBody);
276
- if (exact) {
277
- const ln = Number.parseInt(exact[1], 10);
278
- return [{ kind: "anchor_op", anchor: { line: ln, hash: exact[2] }, op: { op: "delete" }, lineNum }];
279
- }
280
-
281
- const m = LID_RE.exec(trimmedBody);
282
- if (m && (trimmedBody[m[0].length] === "|" || trimmedBody[m[0].length] === "=")) {
283
- const ln = Number.parseInt(m[1], 10);
284
- const old = trimmedBody.slice(m[0].length + 1);
285
- return [{ kind: "delete_with_old", anchor: { line: ln, hash: m[2] }, old, lineNum }];
286
- }
287
- if (m && trimmedBody[m[0].length] === " ") {
288
- const ln = Number.parseInt(m[1], 10);
289
- const text = trimmedBody.slice(m[0].length + 1);
290
- return [
291
- { kind: "anchor_op", anchor: { line: ln, hash: m[2] }, op: { op: "delete" }, lineNum },
292
- { kind: "insert", text, lineNum },
293
- ];
294
- }
295
-
296
- return null;
297
- }
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
-
310
- function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
311
- const text = line.trimStart();
312
- const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
313
- const withoutMove = withoutLegacyMove.startsWith("@") ? withoutLegacyMove.slice(1) : withoutLegacyMove;
314
- const withoutDelete = withoutMove.startsWith("-") ? withoutMove.slice(1).trimStart() : withoutMove;
315
-
316
- const partial = /^([a-z]{2})(?=[ \t]*[=|])/.exec(withoutDelete);
317
- if (partial) {
318
- throw new Error(
319
- `Diff line ${lineNum}: \`${partial[1]}\` is not a full Lid. Use the full Lid from read output, e.g. \`119${partial[1]}\`.`,
320
- );
321
- }
322
-
323
- const missing = /^([1-9]\d*)(?=[ \t]*[=|]|$)/.exec(withoutDelete);
324
- if (missing) {
325
- const prefix = text.startsWith("@@ ") ? `@@ ${missing[1]}` : missing[1];
326
- throw new Error(
327
- `Diff line ${lineNum}: \`${prefix}\` is missing the two-letter Lid suffix. Use the full Lid from read output, e.g. \`${prefix.startsWith("@@ ") ? "@@ " : ""}${missing[1]}ab\`.`,
328
- );
329
- }
330
-
331
- throw new Error(`Diff line ${lineNum}: cannot parse "${raw}".`);
332
- }
333
-
334
- function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
335
- // Strip trailing CR (CRLF tolerance).
336
- const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
337
- if (line.length === 0) return [];
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
-
347
- // `+TEXT` inserts at the cursor. Everything after `+` is content. A
348
- // `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
349
- // emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
350
- if (line[0] === "+") {
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
- }
355
- const m = LID_RE.exec(body);
356
- if (m) {
357
- const sep = body[m[0].length];
358
- if (sep === "=" || sep === "|") {
359
- const ln = Number.parseInt(m[1], 10);
360
- const text = body.slice(m[0].length + 1);
361
- return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, separator: sep, text, lineNum }];
362
- }
363
- }
364
-
365
- // Auto-fix: `+@Lid` and `+-Lid` are almost always typos where the agent
366
- // prefixed a cursor-move or delete op with `+`. Insert content matching
367
- // these op shapes is essentially never legitimate in source code, and
368
- // silently emitting them as literal text corrupts the file (e.g. a stray
369
- // `@12ly` line in a C++ source). Split into the op + a blank `+` insert
370
- // so the line count of the edit script is preserved for any downstream
371
- // offset-sensitive logic.
372
- if (body.length > 1 && (body[0] === "@" || body[0] === "-")) {
373
- try {
374
- const opStmts = parseDiffLine(body, lineNum);
375
- const allOps = opStmts.length > 0 && opStmts.every(s => s.kind !== "insert" && s.kind !== "diffish_add");
376
- if (allOps) {
377
- return [...opStmts, { kind: "insert", text: "", lineNum }];
378
- }
379
- } catch {
380
- // Body looked op-shaped but failed to parse; fall through to literal insert.
381
- }
382
- }
383
- return [{ kind: "insert", text: body, lineNum }];
384
- }
385
-
386
- // Canonical file-scope locators.
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
- }
439
-
440
- // `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
441
- // historical `-Lid TEXT` delete-then-insert recovery.
442
- if (line[0] === "-") {
443
- const parsed = parseDeleteStmt(line.slice(1), lineNum);
444
- if (parsed) return parsed;
445
- throw new Error(`Diff line ${lineNum}: \`-\` must be followed by a Lid (e.g. \`-5xx\`). Got "${raw}".`);
446
- }
447
-
448
- // Legacy move prefix. Runtime accepts old locators and common slipped edit
449
- // operations, while the grammar/prompt bias models to canonical syntax.
450
- if (line.startsWith("@@ ")) {
451
- const body = line.slice(3);
452
- if (body === "BOF") return [{ kind: "bof", lineNum }];
453
- if (body === "EOF") return [{ kind: "eof", lineNum }];
454
-
455
- const deleteStmt = body.startsWith("-") ? parseDeleteStmt(body.slice(1), lineNum) : null;
456
- if (deleteStmt) return deleteStmt;
457
-
458
- const lidStmt = parseLidStmt(body, lineNum);
459
- if (lidStmt) return lidStmt;
460
-
461
- throwMalformedLidDiagnostic(line, lineNum, raw);
462
- }
463
-
464
- // Canonical `@Lid` cursor moves. Leniently recover `@Lid=TEXT`,
465
- // `@Lid|TEXT`, `@$`, and `@^`.
466
- if (line[0] === "@") {
467
- const body = line.slice(1);
468
- if (body === "^") return [{ kind: "bof", lineNum }];
469
- if (body === "$") return [{ kind: "eof", lineNum }];
470
- const lidStmt = parseLidStmt(body, lineNum);
471
- if (lidStmt) return lidStmt;
472
- throwMalformedLidDiagnostic(line, lineNum, raw);
473
- }
474
-
475
- // `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
476
- // A bare `Lid` is a cursor move.
477
- const lidStmt = parseLidStmt(line, lineNum);
478
- if (lidStmt) return lidStmt;
479
-
480
- if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
481
- throwMalformedLidDiagnostic(line, lineNum, raw);
482
- }
483
-
484
- // Reject any line that doesn't match a recognized op. Common case: a model
485
- // emitted multi-line content after a `Lid=` or similar without `+` prefixes,
486
- // or pasted raw context. Silently treating these as inserts corrupts files.
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
- }
494
- throw new Error(
495
- `Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
496
- );
497
- }
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
-
584
- function tokenizeDiff(diff: string): ParsedStmt[] {
585
- const out: ParsedStmt[] = [];
586
- const lines = preprocessRangeReplaceContinuation(diff).split("\n");
587
- for (let i = 0; i < lines.length; i++) {
588
- const lineNum = i + 1;
589
- const stmts = parseDiffLine(lines[i], lineNum);
590
- for (const stmt of stmts) {
591
- // Last-set-wins: when the same anchor (line+hash) gets a second `set`,
592
- // drop the earlier one. Models sometimes echo the OLD line and then the
593
- // NEW line as replacements (e.g. `119yh|OLD` / `119yh|NEW`); the last is
594
- // the intended value.
595
- if (stmt.kind === "anchor_op" && stmt.op.op === "set") {
596
- const key = `${stmt.anchor.line}:${stmt.anchor.hash}`;
597
- for (let j = out.length - 1; j >= 0; j--) {
598
- const prior = out[j];
599
- if (
600
- prior.kind === "anchor_op" &&
601
- prior.op.op === "set" &&
602
- `${prior.anchor.line}:${prior.anchor.hash}` === key
603
- ) {
604
- out.splice(j, 1);
605
- break;
606
- }
607
- }
608
- }
609
- out.push(stmt);
610
- }
611
- }
612
- return normalizeHunks(out);
613
- }
614
-
615
- // Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
616
- // hunks and reorder so adds land at the FIRST delete's slot (block
617
- // replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
618
- // `set`; malformed standalone or mismatched `+Lid|TEXT`/`+Lid=TEXT` lines
619
- // throw instead of silently dropping the Lid prefix.
620
- function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
621
- const isDelete = (s: ParsedStmt): boolean =>
622
- (s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
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}`;
626
- const out: ParsedStmt[] = [];
627
- let i = 0;
628
- while (i < stmts.length) {
629
- const stmt = stmts[i];
630
- if (!isDelete(stmt)) {
631
- if (stmt.kind === "diffish_add") {
632
- const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
633
- throw new Error(
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.`,
635
- );
636
- }
637
- out.push(stmt);
638
- i++;
639
- continue;
640
- }
641
- const deletes: ParsedStmt[] = [];
642
- while (i < stmts.length && isDelete(stmts[i])) {
643
- deletes.push(stmts[i]);
644
- i++;
645
- }
646
- const adds: ParsedStmt[] = [];
647
- while (i < stmts.length && isAdd(stmts[i])) {
648
- adds.push(stmts[i]);
649
- i++;
650
- }
651
- const deletedLids = new Set(
652
- deletes.map(d => {
653
- const a = (d as { anchor: ParsedAnchor }).anchor;
654
- return `${a.line}${a.hash}`;
655
- }),
656
- );
657
- for (const add of adds) {
658
- if (add.kind !== "diffish_add") continue;
659
- const lid = `${add.anchor.line}${add.anchor.hash}`;
660
- if (!deletedLids.has(lid)) {
661
- throw new Error(
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.`,
663
- );
664
- }
665
- }
666
- // Split the delete run into file-contiguous sub-runs. The block
667
- // reorder (inserts land at the FIRST delete's slot) is meaningful only
668
- // when the deletes describe a single contiguous file range. When the
669
- // agent stacks deletes that target far-apart lines (e.g. `-186 -197
670
- // -198 -199` to remove a debug line at 186 AND replace 197-199), each
671
- // far-apart delete moves the cursor on its own; only the LAST
672
- // contiguous group should attract the inserts.
673
- const subruns = splitContiguousDeletes(deletes);
674
- for (let r = 0; r < subruns.length - 1; r++) {
675
- for (const d of subruns[r]) out.push(d);
676
- }
677
- const lastDeletes = subruns[subruns.length - 1];
678
-
679
- // Single-line case: 1 delete in the last sub-run + 1 diffish_add same Lid → fuse to set.
680
- if (lastDeletes.length === 1 && adds.length === 1 && adds[0].kind === "diffish_add") {
681
- const dAnchor = (lastDeletes[0] as { anchor: ParsedAnchor }).anchor;
682
- const a = adds[0];
683
- if (a.anchor.line === dAnchor.line && a.anchor.hash === dAnchor.hash) {
684
- out.push({
685
- kind: "anchor_op",
686
- anchor: a.anchor,
687
- op: { op: "set", text: a.text, allowOldNewRepair: false },
688
- lineNum: a.lineNum,
689
- });
690
- continue;
691
- }
692
- }
693
- // Block: emit lastDeletes[0], then all inserts (which land at lastDeletes[0]'s slot
694
- // because the cursor binds to lastDeletes[0] before the inserts), then the
695
- // remaining lastDeletes.
696
- out.push(lastDeletes[0]);
697
- for (const add of adds) {
698
- const text = add.kind === "insert" ? add.text : (add as DiffishAddStmt).text;
699
- out.push({ kind: "insert", text, lineNum: add.lineNum });
700
- }
701
- for (let j = 1; j < lastDeletes.length; j++) {
702
- out.push(lastDeletes[j]);
703
- }
704
- }
705
- return out;
706
- }
707
-
708
- function makeAnchor(anchor: ParsedAnchor): Anchor {
709
- return { line: anchor.line, hash: anchor.hash };
710
- }
711
-
712
- function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
713
- if (deletes.length === 0) return [];
714
- const getLine = (s: ParsedStmt): number => {
715
- if (s.kind === "anchor_op") return s.anchor.line;
716
- if (s.kind === "delete_with_old") return s.anchor.line;
717
- throw new Error("internal: splitContiguousDeletes received non-delete stmt");
718
- };
719
- const subruns: ParsedStmt[][] = [];
720
- let current: ParsedStmt[] = [deletes[0]];
721
- for (let i = 1; i < deletes.length; i++) {
722
- if (getLine(deletes[i]) === getLine(deletes[i - 1]) + 1) {
723
- current.push(deletes[i]);
724
- } else {
725
- subruns.push(current);
726
- current = [deletes[i]];
727
- }
728
- }
729
- subruns.push(current);
730
- return subruns;
731
- }
732
-
733
- // ═══════════════════════════════════════════════════════════════════════════
734
- // Build cursor-program from ParsedStmt[]
735
- // ═══════════════════════════════════════════════════════════════════════════
736
-
737
- export function parseAtom(diff: string): AtomEdit[] {
738
- return parseAtomWithWarnings(diff).edits;
739
- }
740
-
741
- export function parseAtomWithWarnings(diff: string): { edits: AtomEdit[]; warnings: string[] } {
742
- const edits: AtomEdit[] = [];
743
- const warnings: string[] = [];
744
- let cursor: AtomCursor = { kind: "eof" };
745
- let index = 0;
746
-
747
- for (const stmt of tokenizeDiff(diff)) {
748
- if (stmt.kind === "insert") {
749
- edits.push({ kind: "insert", cursor: cloneCursor(cursor), text: stmt.text, lineNum: stmt.lineNum, index });
750
- index++;
751
- continue;
752
- }
753
-
754
- if (stmt.kind === "bof") {
755
- cursor = { kind: "bof" };
756
- continue;
757
- }
758
- if (stmt.kind === "eof") {
759
- cursor = { kind: "eof" };
760
- continue;
761
- }
762
-
763
- if (stmt.kind === "delete_with_old") {
764
- const anchor = makeAnchor(stmt.anchor);
765
- cursor = { kind: "anchor", anchor: { ...anchor } };
766
- edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index, oldAssertion: stmt.old });
767
- index++;
768
- continue;
769
- }
770
-
771
- if (stmt.kind === "diffish_add") {
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;
778
- }
779
-
780
- const anchor = makeAnchor(stmt.anchor);
781
- cursor = { kind: "anchor", anchor: { ...anchor } };
782
- if (stmt.kind === "bare_anchor") continue;
783
-
784
- if (stmt.op.op === "set") {
785
- if (stmt.op.text.includes("\r")) {
786
- throw new Error(
787
- `Diff line ${stmt.lineNum}: set value contains a carriage return; use a single-line value.`,
788
- );
789
- }
790
- edits.push({
791
- kind: "set",
792
- anchor,
793
- text: stmt.op.text,
794
- lineNum: stmt.lineNum,
795
- index,
796
- allowOldNewRepair: stmt.op.allowOldNewRepair,
797
- });
798
- index++;
799
- continue;
800
- }
801
-
802
- edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index });
803
- index++;
804
- }
805
-
806
- return { edits, warnings };
807
- }
808
-
809
- function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
810
- const body = diff
811
- .split("\n")
812
- .map(line => (line.endsWith("\r") ? line.slice(0, -1) : line))
813
- .filter(line => line.trim().length > 0)
814
- .slice(0, 3)
815
- .map(line => ` ${line}`)
816
- .join("\n");
817
- const preview = body.length > 0 ? `\nReceived only locator/context lines:\n${body}` : "";
818
- return `Cursor moved but no mutation found. Add +TEXT to insert, -Lid to delete, or Lid=TEXT to replace.${preview}`;
819
- }
820
-
821
- // ═══════════════════════════════════════════════════════════════════════════
822
- // Apply cursor-program
823
- // ═══════════════════════════════════════════════════════════════════════════
824
-
825
- function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
826
- if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
827
- if (edit.cursor.kind === "anchor" || edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
828
- return [];
829
- }
830
-
831
- function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
832
- const mismatches: HashMismatch[] = [];
833
- const rebasedAnchors = new Map<Anchor, HashMismatch>();
834
- for (const edit of edits) {
835
- for (const anchor of getAtomEditAnchors(edit)) {
836
- if (anchor.line < 1 || anchor.line > fileLines.length) {
837
- throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
838
- }
839
- if (anchor.hash === RANGE_INTERIOR_HASH) continue;
840
- const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
841
- if (actualHash === anchor.hash) continue;
842
-
843
- const rebased = tryRebaseAnchor(anchor, fileLines);
844
- if (rebased !== null) {
845
- const original = `${anchor.line}${anchor.hash}`;
846
- rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
847
- anchor.line = rebased;
848
- warnings.push(
849
- `Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
850
- );
851
- continue;
852
- }
853
- mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
854
- }
855
- }
856
-
857
- // Detect post-rebase conflicts. If any conflicting anchor was rebased, surface
858
- // the original hash mismatch instead — the rebase itself is what created the
859
- // conflict, and the model needs to fix the stale anchor, not deduplicate.
860
- const seenLines = new Map<number, Anchor>();
861
- for (const edit of edits) {
862
- if (edit.kind !== "set" && edit.kind !== "delete") continue;
863
- const existing = seenLines.get(edit.anchor.line);
864
- if (existing) {
865
- const rebasedA = rebasedAnchors.get(edit.anchor);
866
- const rebasedB = rebasedAnchors.get(existing);
867
- if (rebasedA) mismatches.push(rebasedA);
868
- else if (rebasedB) mismatches.push(rebasedB);
869
- continue;
870
- }
871
- seenLines.set(edit.anchor.line, edit.anchor);
872
- }
873
- return mismatches;
874
- }
875
-
876
- function validateNoConflictingAtomMutations(edits: AtomEdit[]): void {
877
- const mutatingPerLine = new Map<number, string>();
878
- for (const edit of edits) {
879
- if (edit.kind !== "set" && edit.kind !== "delete") continue;
880
- const existing = mutatingPerLine.get(edit.anchor.line);
881
- if (existing) {
882
- throw new Error(
883
- `Conflicting ops on anchor line ${edit.anchor.line}: \`${existing}\` and \`${edit.kind}\`. ` +
884
- "At most one mutating op (set/delete) is allowed per anchor.",
885
- );
886
- }
887
- mutatingPerLine.set(edit.anchor.line, edit.kind);
888
- }
889
- }
890
-
891
- function repairAtomOldNewSetLine(currentLine: string, nextLine: string): string {
892
- const marker = `${currentLine}|`;
893
- if (!nextLine.startsWith(marker)) return nextLine;
894
- const repaired = nextLine.slice(marker.length);
895
- return repaired.length > 0 ? repaired : nextLine;
896
- }
897
-
898
- function insertAtStart(fileLines: string[], lines: string[]): void {
899
- if (lines.length === 0) return;
900
- if (fileLines.length === 1 && fileLines[0] === "") {
901
- fileLines.splice(0, 1, ...lines);
902
- return;
903
- }
904
- fileLines.splice(0, 0, ...lines);
905
- }
906
-
907
- function insertAtEnd(fileLines: string[], lines: string[]): number | undefined {
908
- if (lines.length === 0) return undefined;
909
- if (fileLines.length === 1 && fileLines[0] === "") {
910
- fileLines.splice(0, 1, ...lines);
911
- return 1;
912
- }
913
- const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
914
- const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
915
- fileLines.splice(insertIdx, 0, ...lines);
916
- return insertIdx + 1;
917
- }
918
-
919
- function isSameFileCursor(a: AtomCursor, b: AtomCursor): boolean {
920
- return a.kind === b.kind && a.kind !== "anchor";
921
- }
922
-
923
- function collectFileInsertRuns(
924
- fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
925
- ): Array<{ cursor: AtomCursor; lines: string[] }> {
926
- const runs: Array<{ cursor: AtomCursor; lines: string[] }> = [];
927
- for (const edit of fileInserts.sort((a, b) => a.index - b.index)) {
928
- const prev = runs[runs.length - 1];
929
- if (prev && isSameFileCursor(prev.cursor, edit.cursor)) {
930
- prev.lines.push(edit.text);
931
- continue;
932
- }
933
- runs.push({ cursor: edit.cursor, lines: [edit.text] });
934
- }
935
- return runs;
936
- }
937
- function applyFileCursorInserts(
938
- fileLines: string[],
939
- fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
940
- ): number | undefined {
941
- let firstChangedLine: number | undefined;
942
- const trackFirstChanged = (line: number) => {
943
- if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
944
- };
945
-
946
- for (const run of collectFileInsertRuns(fileInserts)) {
947
- if (run.cursor.kind === "bof") {
948
- insertAtStart(fileLines, run.lines);
949
- trackFirstChanged(1);
950
- continue;
951
- }
952
- if (run.cursor.kind === "eof") {
953
- const changedLine = insertAtEnd(fileLines, run.lines);
954
- if (changedLine !== undefined) trackFirstChanged(changedLine);
955
- }
956
- }
957
-
958
- return firstChangedLine;
959
- }
960
-
961
- function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
962
- if (edit.kind !== "insert") return edit.anchor;
963
- if (edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
964
- throw new Error("Internal edit error: file-scoped insert reached anchor application.");
965
- }
966
- return edit.cursor.anchor;
967
- }
968
-
969
- // Heuristic: detect (and when safe, auto-fix) lines that became adjacent
970
- // duplicates of themselves after the edit, when they were not adjacent
971
- // duplicates before. This is the signature of a botched block rewrite that
972
- // missed one delete on the front or back of the deletion range, leaving a
973
- // stale copy of a line the agent already re-emitted (e.g. inserting a new
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.
978
- //
979
- // Auto-fix is gated on bracket balance: we only remove the duplicate line if
980
- // its removal restores the original file's `{}`/`()`/`[]` delta. That makes
981
- // the fix safe in the common case (a stray closing brace shifts balance by
982
- // one) and conservative when the duplicate is intentional (balance unchanged
983
- // → warning only). When two adjacent lines are textually identical, removing
984
- // either yields the same content, so we don't have to decide which is "the
985
- // stale copy" — we just remove one and verify balance restores.
986
- function detectAndAutoFixDuplicates(
987
- originalLines: string[],
988
- finalLines: string[],
989
- ): { fixed: string[] | null; warnings: string[] } {
990
- const countAdjacent = (lines: string[]): Map<string, number> => {
991
- const counts = new Map<string, number>();
992
- for (let i = 0; i + 1 < lines.length; i++) {
993
- if (lines[i] !== lines[i + 1]) continue;
994
- if (lines[i].trim().length === 0) continue;
995
- counts.set(lines[i], (counts.get(lines[i]) ?? 0) + 1);
996
- }
997
- return counts;
998
- };
999
-
1000
- const computeBalance = (lines: string[]): { brace: number; paren: number; bracket: number } => {
1001
- let brace = 0;
1002
- let paren = 0;
1003
- let bracket = 0;
1004
- for (const line of lines) {
1005
- for (const ch of line) {
1006
- if (ch === "{") brace++;
1007
- else if (ch === "}") brace--;
1008
- else if (ch === "(") paren++;
1009
- else if (ch === ")") paren--;
1010
- else if (ch === "[") bracket++;
1011
- else if (ch === "]") bracket--;
1012
- }
1013
- }
1014
- return { brace, paren, bracket };
1015
- };
1016
-
1017
- const balancesEqual = (
1018
- a: { brace: number; paren: number; bracket: number },
1019
- b: { brace: number; paren: number; bracket: number },
1020
- ): boolean => a.brace === b.brace && a.paren === b.paren && a.bracket === b.bracket;
1021
-
1022
- const orig = countAdjacent(originalLines);
1023
- const fin = countAdjacent(finalLines);
1024
- const newDupPositions: number[] = [];
1025
- for (let i = 0; i + 1 < finalLines.length; i++) {
1026
- if (finalLines[i] !== finalLines[i + 1]) continue;
1027
- if (finalLines[i].trim().length === 0) continue;
1028
- const text = finalLines[i];
1029
- if ((fin.get(text) ?? 0) <= (orig.get(text) ?? 0)) continue;
1030
- newDupPositions.push(i);
1031
- }
1032
-
1033
- if (newDupPositions.length === 0) return { fixed: null, warnings: [] };
1034
-
1035
- const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
1036
-
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
- };
1059
- }
1060
- }
1061
-
1062
- const warnings = newDupPositions.slice(0, 3).map(pos => {
1063
- return `Suspicious duplicate: lines ${pos + 1} and ${pos + 2} are both ${formatPreview(finalLines[pos])}. The edit may have left a stale copy of a line you meant to replace — verify the result.`;
1064
- });
1065
- return { fixed: null, warnings };
1066
- }
1067
-
1068
- export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult {
1069
- if (edits.length === 0) {
1070
- return { lines: text, firstChangedLine: undefined };
1071
- }
1072
-
1073
- const fileLines = text.split("\n");
1074
- const originalLines = fileLines.slice();
1075
- const warnings: string[] = [];
1076
- let firstChangedLine: number | undefined;
1077
- const noopEdits: AtomNoopEdit[] = [];
1078
-
1079
- const mismatches = validateAtomAnchors(edits, fileLines, warnings);
1080
- if (mismatches.length > 0) {
1081
- throw new HashlineMismatchError(mismatches, fileLines);
1082
- }
1083
- validateNoConflictingAtomMutations(edits);
1084
-
1085
- const trackFirstChanged = (line: number) => {
1086
- if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
1087
- };
1088
-
1089
- const anchorEdits: IndexedAnchorEdit[] = [];
1090
- const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
1091
- edits.forEach((edit, idx) => {
1092
- if (edit.kind === "insert" && edit.cursor.kind !== "anchor" && edit.cursor.kind !== "before_anchor") {
1093
- fileInserts.push(edit);
1094
- return;
1095
- }
1096
- anchorEdits.push({ edit, idx });
1097
- });
1098
-
1099
- const byLine = new Map<number, IndexedAnchorEdit[]>();
1100
- for (const entry of anchorEdits) {
1101
- const line = getAnchorForAnchorEdit(entry.edit).line;
1102
- const bucket = byLine.get(line);
1103
- if (bucket) {
1104
- bucket.push(entry);
1105
- } else {
1106
- byLine.set(line, [entry]);
1107
- }
1108
- }
1109
-
1110
- const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
1111
- for (const line of anchorLines) {
1112
- const bucket = byLine.get(line);
1113
- if (!bucket) continue;
1114
- bucket.sort((a, b) => a.idx - b.idx);
1115
-
1116
- const idx = line - 1;
1117
- const currentLine = fileLines[idx];
1118
- let replacement: string[] = [currentLine];
1119
- let replacementSet = false;
1120
- let anchorMutated = false;
1121
- const beforeLines: string[] = [];
1122
- const afterLines: string[] = [];
1123
-
1124
- for (const { edit } of bucket) {
1125
- switch (edit.kind) {
1126
- case "insert":
1127
- if (edit.cursor.kind === "before_anchor") {
1128
- beforeLines.push(edit.text);
1129
- } else {
1130
- afterLines.push(edit.text);
1131
- }
1132
- break;
1133
- case "set":
1134
- replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
1135
- replacementSet = true;
1136
- anchorMutated = true;
1137
- break;
1138
- case "delete":
1139
- // `-Lid|OLD` / `-Lid=OLD`: the OLD payload is informational only.
1140
- // The Lid hash already validates the line content (and auto-rebases
1141
- // when lines have shifted), so we ignore any OLD mismatch here.
1142
- replacement = [];
1143
- replacementSet = true;
1144
- anchorMutated = true;
1145
- break;
1146
- }
1147
- }
1148
-
1149
- const replacementProducesNoChange =
1150
- beforeLines.length === 0 &&
1151
- afterLines.length === 0 &&
1152
- replacement.length === 1 &&
1153
- replacement[0] === currentLine;
1154
- if (replacementProducesNoChange) {
1155
- const firstEdit = bucket[0]?.edit;
1156
- const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
1157
- noopEdits.push({
1158
- editIndex: bucket[0]?.idx ?? 0,
1159
- loc: anchor ? `${anchor.line}${anchor.hash}` : `${line}`,
1160
- reason:
1161
- firstEdit?.kind === "set"
1162
- ? "replacement is identical to the current line content; use `Lid=NEW_TEXT` and do not copy an unchanged read line"
1163
- : "replacement is identical to the current line content",
1164
- current: currentLine,
1165
- });
1166
- continue;
1167
- }
1168
-
1169
- const combined = [...beforeLines, ...replacement, ...afterLines];
1170
- fileLines.splice(idx, 1, ...combined);
1171
- if (anchorMutated || beforeLines.length > 0) {
1172
- trackFirstChanged(line);
1173
- } else if (afterLines.length > 0) {
1174
- trackFirstChanged(line + 1);
1175
- }
1176
- if (!replacementSet && beforeLines.length === 0 && afterLines.length === 0) continue;
1177
- }
1178
-
1179
- const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
1180
- if (fileFirstChangedLine !== undefined) trackFirstChanged(fileFirstChangedLine);
1181
-
1182
- const dupCheck = detectAndAutoFixDuplicates(originalLines, fileLines);
1183
- if (dupCheck.fixed !== null) {
1184
- fileLines.length = 0;
1185
- fileLines.push(...dupCheck.fixed);
1186
- }
1187
- for (const w of dupCheck.warnings) warnings.push(w);
1188
-
1189
- return {
1190
- lines: fileLines.join("\n"),
1191
- firstChangedLine,
1192
- ...(warnings.length > 0 ? { warnings } : {}),
1193
- ...(noopEdits.length > 0 && firstChangedLine === undefined ? { noopEdits } : {}),
1194
- };
1195
- }
1196
-
1197
- // ═══════════════════════════════════════════════════════════════════════════
1198
- // Wire-format split: extract `---` headers from the input string.
1199
- // ═══════════════════════════════════════════════════════════════════════════
1200
-
1201
- const FILE_HEADER_PREFIX = "---";
1202
- const REMOVE_FILE_OPERATION = "!rm";
1203
- const MOVE_FILE_OPERATION = "!mv";
1204
-
1205
- type AtomWholeFileOperation =
1206
- | { kind: "delete"; lineNum: number }
1207
- | { kind: "move"; destination: string; lineNum: number };
1208
-
1209
- interface AtomInputSection {
1210
- path: string;
1211
- diff: string;
1212
- wholeFileOperation?: AtomWholeFileOperation;
1213
- }
1214
-
1215
- export interface SplitAtomOptions {
1216
- cwd?: string;
1217
- path?: string;
1218
- }
1219
-
1220
- function isBlankHeaderPreamble(line: string): boolean {
1221
- return line.replace(/\r$/, "").trim().length === 0;
1222
- }
1223
-
1224
- function unquoteAtomPath(pathText: string): string {
1225
- if (pathText.length < 2) return pathText;
1226
- const first = pathText[0];
1227
- const last = pathText[pathText.length - 1];
1228
- if ((first === '"' || first === "'") && first === last) {
1229
- return pathText.slice(1, -1);
1230
- }
1231
- return pathText;
1232
- }
1233
-
1234
- function normalizeAtomPath(rawPath: string, cwd?: string): string {
1235
- const unquoted = unquoteAtomPath(rawPath.trim());
1236
- if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
1237
-
1238
- const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
1239
- const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
1240
- return isWithinCwd ? relative || "." : unquoted;
1241
- }
1242
-
1243
- function parseAtomHeaderLine(line: string, cwd?: string): string | null {
1244
- if (!line.startsWith(FILE_HEADER_PREFIX)) return null;
1245
- let body = line.slice(FILE_HEADER_PREFIX.length);
1246
- if (body.startsWith(" ")) body = body.slice(1);
1247
- const parsedPath = normalizeAtomPath(body, cwd);
1248
- if (parsedPath.length === 0) {
1249
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
1250
- }
1251
- return parsedPath;
1252
- }
1253
-
1254
- function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
1255
- const trimmed = rawPath.trim();
1256
- if (trimmed.length === 0) {
1257
- throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
1258
- }
1259
-
1260
- const quote = trimmed[0];
1261
- if (quote === '"' || quote === "'") {
1262
- if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
1263
- throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
1264
- }
1265
- } else if (/\s/.test(trimmed)) {
1266
- throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one destination path.`);
1267
- }
1268
-
1269
- const destination = normalizeAtomPath(trimmed, cwd);
1270
- if (destination.length === 0) {
1271
- throw new Error(`Diff line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
1272
- }
1273
- return destination;
1274
- }
1275
-
1276
- function parseAtomWholeFileOperationLine(
1277
- rawLine: string,
1278
- lineNum: number,
1279
- cwd?: string,
1280
- ): AtomWholeFileOperation | null {
1281
- const line = rawLine.replace(/\r$/, "").trimEnd();
1282
- if (line === REMOVE_FILE_OPERATION) {
1283
- return { kind: "delete", lineNum };
1284
- }
1285
- if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
1286
- throw new Error(`Diff line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
1287
- }
1288
-
1289
- if (line === MOVE_FILE_OPERATION) {
1290
- throw new Error(`Diff line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
1291
- }
1292
- if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
1293
- const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
1294
- return {
1295
- kind: "move",
1296
- destination: parseSingleAtomPathArgument(rawDestination, MOVE_FILE_OPERATION, lineNum, cwd),
1297
- lineNum,
1298
- };
1299
- }
1300
-
1301
- return null;
1302
- }
1303
-
1304
- function getAtomWholeFileOperation(
1305
- sectionPath: string,
1306
- lines: string[],
1307
- cwd?: string,
1308
- ): AtomWholeFileOperation | undefined {
1309
- let operation: AtomWholeFileOperation | undefined;
1310
- let operationToken = "";
1311
- let hasLineEdit = false;
1312
-
1313
- for (let i = 0; i < lines.length; i++) {
1314
- const lineNum = i + 1;
1315
- const line = lines[i].replace(/\r$/, "");
1316
- if (line.trim().length === 0) continue;
1317
-
1318
- const parsed = parseAtomWholeFileOperationLine(line, lineNum, cwd);
1319
- if (parsed) {
1320
- if (operation) {
1321
- throw new Error(
1322
- `Edit section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
1323
- );
1324
- }
1325
- operation = parsed;
1326
- operationToken = parsed.kind === "delete" ? REMOVE_FILE_OPERATION : MOVE_FILE_OPERATION;
1327
- continue;
1328
- }
1329
-
1330
- hasLineEdit = true;
1331
- }
1332
-
1333
- if (operation && hasLineEdit) {
1334
- throw new Error(
1335
- `Edit section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
1336
- );
1337
- }
1338
-
1339
- return operation;
1340
- }
1341
-
1342
- function hasAtomHeaderLine(input: string): boolean {
1343
- const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1344
- return stripped.split("\n").some(rawLine => rawLine.replace(/\r$/, "").startsWith(FILE_HEADER_PREFIX));
1345
- }
1346
-
1347
- function containsRecognizableAtomOperations(input: string): boolean {
1348
- for (const rawLine of input.split("\n")) {
1349
- const line = rawLine.replace(/\r$/, "");
1350
- if (line.length === 0) continue;
1351
- if (line[0] === "+") return true;
1352
- if (line === "$" || line === "^") return true;
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;
1357
- if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
1358
- }
1359
- return false;
1360
- }
1361
-
1362
- function stripLeadingBlankLines(input: string): string {
1363
- const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1364
- const lines = stripped.split("\n");
1365
- while (lines.length > 0 && isBlankHeaderPreamble(lines[0] ?? "")) {
1366
- lines.shift();
1367
- }
1368
- return lines.join("\n");
1369
- }
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
-
1402
- function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
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)) {
1407
- return input;
1408
- }
1409
- const fallbackPath = normalizeAtomPath(options.path, options.cwd);
1410
- if (fallbackPath.length === 0) return input;
1411
- return `${FILE_HEADER_PREFIX}${fallbackPath}\n${input}`;
1412
- }
1413
-
1414
- function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1415
- return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1416
- }
1417
-
1418
- function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
1419
- if (result.details === undefined) {
1420
- return { diff: "" };
1421
- }
1422
- return result.details;
1423
- }
1424
-
1425
- /**
1426
- * Split the wire-format `input` string into `{ path, diff }`. The first
1427
- * non-empty line MUST be `---<path>` or `--- <path>`. Tolerates a leading BOM.
1428
- */
1429
- export function splitAtomInput(input: string, options: SplitAtomOptions = {}): { path: string; diff: string } {
1430
- const [section] = splitAtomInputs(input, options);
1431
- return section;
1432
- }
1433
-
1434
- export function splitAtomInputs(input: string, options: SplitAtomOptions = {}): AtomInputSection[] {
1435
- const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
1436
- const lines = stripped.split("\n");
1437
- const firstLine = (lines[0] ?? "").replace(/\r$/, "");
1438
- if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
1439
- const preview = JSON.stringify(firstLine.slice(0, 120));
1440
- throw new Error(
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>".`,
1444
- );
1445
- }
1446
-
1447
- const sections: AtomInputSection[] = [];
1448
- let currentPath = "";
1449
- let currentLines: string[] = [];
1450
- const flush = () => {
1451
- if (currentPath.length === 0) return;
1452
- const wholeFileOperation = getAtomWholeFileOperation(currentPath, currentLines, options.cwd);
1453
- sections.push({
1454
- path: currentPath,
1455
- diff: currentLines.join("\n"),
1456
- ...(wholeFileOperation ? { wholeFileOperation } : {}),
1457
- });
1458
- currentLines = [];
1459
- };
1460
-
1461
- for (const rawLine of lines) {
1462
- const line = rawLine.replace(/\r$/, "");
1463
- const headerPath = parseAtomHeaderLine(line, options.cwd);
1464
- if (headerPath !== null) {
1465
- flush();
1466
- currentPath = headerPath;
1467
- continue;
1468
- }
1469
- currentLines.push(rawLine);
1470
- }
1471
- flush();
1472
- return sections;
1473
- }
1474
-
1475
- // ═════════════════════════════════════════════════════════════════════════════
1476
- // Executor
1477
- // ═══════════════════════════════════════════════════════════════════════════
1478
-
1479
- export interface ExecuteAtomSingleOptions {
1480
- session: ToolSession;
1481
- input: string;
1482
- path?: string;
1483
- signal?: AbortSignal;
1484
- batchRequest?: LspBatchRequest;
1485
- writethrough: WritethroughCallback;
1486
- beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1487
- }
1488
-
1489
- interface ReadAtomFileResult {
1490
- exists: boolean;
1491
- rawContent: string;
1492
- }
1493
-
1494
- async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
1495
- try {
1496
- return { exists: true, rawContent: await Bun.file(absolutePath).text() };
1497
- } catch (error) {
1498
- if (isEnoent(error)) return { exists: false, rawContent: "" };
1499
- throw error;
1500
- }
1501
- }
1502
-
1503
- function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
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
- );
1511
- }
1512
-
1513
- function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
1514
- let diagnostic = `Edits to ${path} resulted in no changes being made.`;
1515
- if (result.noopEdits && result.noopEdits.length > 0) {
1516
- const details = result.noopEdits
1517
- .map(e => {
1518
- const preview =
1519
- e.current.length > 0
1520
- ? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
1521
- : "";
1522
- return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
1523
- })
1524
- .join("\n");
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
- }
1532
- }
1533
- return diagnostic;
1534
- }
1535
-
1536
- async function executeAtomWholeFileOperation(
1537
- options: ExecuteAtomSingleOptions & AtomInputSection & { wholeFileOperation: AtomWholeFileOperation },
1538
- ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1539
- const { session, path: sectionPath, wholeFileOperation } = options;
1540
- const absolutePath = resolvePlanPath(session, sectionPath);
1541
-
1542
- if (sectionPath.endsWith(".ipynb")) {
1543
- throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1544
- }
1545
-
1546
- if (wholeFileOperation.kind === "delete") {
1547
- enforcePlanModeWrite(session, sectionPath, { op: "delete" });
1548
- await assertEditableFile(absolutePath, sectionPath);
1549
- try {
1550
- await fs.unlink(absolutePath);
1551
- } catch (error) {
1552
- if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
1553
- throw error;
1554
- }
1555
- invalidateFsScanAfterDelete(absolutePath);
1556
- return {
1557
- content: [{ type: "text", text: `Deleted ${sectionPath}` }],
1558
- details: { diff: "", op: "delete", meta: outputMeta().get() },
1559
- };
1560
- }
1561
-
1562
- const destinationPath = wholeFileOperation.destination;
1563
- if (destinationPath.endsWith(".ipynb")) {
1564
- throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1565
- }
1566
-
1567
- enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
1568
- const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
1569
- if (absoluteDestinationPath === absolutePath) {
1570
- throw new Error("rename path is the same as source path");
1571
- }
1572
-
1573
- await assertEditableFile(absolutePath, sectionPath);
1574
- try {
1575
- await fs.mkdir(path.dirname(absoluteDestinationPath), { recursive: true });
1576
- await fs.rename(absolutePath, absoluteDestinationPath);
1577
- } catch (error) {
1578
- if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
1579
- throw error;
1580
- }
1581
- invalidateFsScanAfterRename(absolutePath, absoluteDestinationPath);
1582
-
1583
- return {
1584
- content: [{ type: "text", text: `Moved ${sectionPath} to ${destinationPath}` }],
1585
- details: { diff: "", op: "update", move: destinationPath, meta: outputMeta().get() },
1586
- };
1587
- }
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
-
1643
- async function executeAtomSection(
1644
- options: ExecuteAtomSingleOptions & AtomInputSection,
1645
- ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1646
- const { session, path, diff, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1647
- if (options.wholeFileOperation) {
1648
- return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
1649
- }
1650
-
1651
- const { edits, warnings: parseWarnings } = parseAtomWithWarnings(diff);
1652
- if (edits.length === 0 && diff.trim().length > 0) {
1653
- throw new Error(formatNoAtomEditDiagnostic(path, diff));
1654
- }
1655
-
1656
- enforcePlanModeWrite(session, path, { op: "update" });
1657
-
1658
- if (path.endsWith(".ipynb") && edits.length > 0) {
1659
- throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1660
- }
1661
-
1662
- const absolutePath = resolvePlanPath(session, path);
1663
- const source = await readAtomFile(absolutePath);
1664
- if (!source.exists && hasAnchorScopedEdit(edits)) {
1665
- throw new Error(`File not found: ${path}`);
1666
- }
1667
-
1668
- if (source.exists) {
1669
- assertEditableFileContent(source.rawContent, path);
1670
- }
1671
-
1672
- const { bom, text } = stripBom(source.rawContent);
1673
- const originalEnding = detectLineEnding(text);
1674
- const originalNormalized = normalizeToLF(text);
1675
- const result = applyAtomEdits(originalNormalized, edits);
1676
- if (originalNormalized === result.lines) {
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
- };
1689
- }
1690
-
1691
- const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
1692
- const diagnostics = await writethrough(
1693
- absolutePath,
1694
- finalContent,
1695
- signal,
1696
- Bun.file(absolutePath),
1697
- batchRequest,
1698
- dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1699
- );
1700
- invalidateFsScanAfterWrite(absolutePath);
1701
-
1702
- const diffResult = generateDiffString(originalNormalized, result.lines);
1703
- const meta = outputMeta()
1704
- .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1705
- .get();
1706
- const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1707
- const allWarnings = [...parseWarnings, ...(result.warnings ?? [])];
1708
- const warningsBlock = allWarnings.length > 0 ? `\n\nWarnings:\n${allWarnings.join("\n")}` : "";
1709
- const previewBlock = preview.preview ? `\n${preview.preview}` : "";
1710
- const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
1711
-
1712
- return {
1713
- content: [
1714
- {
1715
- type: "text",
1716
- text: `${resultText}${previewBlock}${warningsBlock}`,
1717
- },
1718
- ],
1719
- details: {
1720
- diff: diffResult.diff,
1721
- firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1722
- diagnostics,
1723
- op: source.exists ? "update" : "create",
1724
- meta,
1725
- },
1726
- };
1727
- }
1728
-
1729
- export async function executeAtomSingle(
1730
- options: ExecuteAtomSingleOptions,
1731
- ): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
1732
- const sections = splitAtomInputs(options.input, { cwd: options.session.cwd, path: options.path });
1733
- if (sections.length === 1) {
1734
- const [section] = sections;
1735
- return executeAtomSection({ ...options, ...section });
1736
- }
1737
-
1738
- for (const section of sections) {
1739
- await preflightAtomSection({ ...options, ...section });
1740
- }
1741
-
1742
- const results = [];
1743
- for (const section of sections) {
1744
- results.push({
1745
- path: section.path,
1746
- result: await executeAtomSection({ ...options, ...section }),
1747
- });
1748
- }
1749
-
1750
- return {
1751
- content: [
1752
- {
1753
- type: "text",
1754
- text: results.map(({ result }) => getTextContent(result)).join("\n\n"),
1755
- },
1756
- ],
1757
- details: {
1758
- diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
1759
- perFileResults: results.map(({ path, result }) => {
1760
- const details = getEditDetails(result);
1761
- return {
1762
- path,
1763
- diff: details.diff,
1764
- firstChangedLine: details.firstChangedLine,
1765
- diagnostics: details.diagnostics,
1766
- op: details.op,
1767
- move: details.move,
1768
- meta: details.meta,
1769
- };
1770
- }),
1771
- },
1772
- };
1773
- }