@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.
- package/CHANGELOG.md +52 -0
- package/package.json +7 -7
- package/src/autoresearch/command-resume.md +5 -8
- package/src/autoresearch/git.ts +41 -51
- package/src/autoresearch/helpers.ts +43 -359
- package/src/autoresearch/index.ts +281 -273
- package/src/autoresearch/prompt-setup.md +43 -0
- package/src/autoresearch/prompt.md +52 -193
- package/src/autoresearch/resume-message.md +2 -8
- package/src/autoresearch/state.ts +59 -166
- package/src/autoresearch/storage.ts +687 -0
- package/src/autoresearch/tools/init-experiment.ts +201 -290
- package/src/autoresearch/tools/log-experiment.ts +304 -517
- package/src/autoresearch/tools/run-experiment.ts +117 -296
- package/src/autoresearch/tools/update-notes.ts +116 -0
- package/src/autoresearch/types.ts +16 -66
- package/src/commit/pipeline.ts +4 -3
- package/src/config/settings-schema.ts +1 -1
- package/src/config/settings.ts +20 -1
- package/src/config.ts +9 -6
- package/src/cursor.ts +1 -1
- package/src/edit/index.ts +9 -31
- package/src/edit/line-hash.ts +70 -43
- package/src/edit/modes/hashline.lark +26 -0
- package/src/edit/modes/hashline.ts +898 -1099
- package/src/edit/modes/patch.ts +0 -7
- package/src/edit/modes/replace.ts +0 -4
- package/src/edit/renderer.ts +22 -20
- package/src/edit/streaming.ts +8 -28
- package/src/eval/eval.lark +24 -30
- package/src/eval/js/context-manager.ts +5 -162
- package/src/eval/js/prelude.txt +0 -12
- package/src/eval/parse.ts +129 -129
- package/src/eval/py/kernel.ts +4 -4
- package/src/eval/py/prelude.py +1 -219
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +2 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +10 -0
- package/src/mcp/manager.ts +22 -0
- package/src/modes/components/session-observer-overlay.ts +5 -2
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line.ts +3 -5
- package/src/modes/components/tree-selector.ts +4 -5
- package/src/modes/components/welcome.ts +11 -1
- package/src/modes/controllers/command-controller.ts +2 -6
- package/src/modes/controllers/event-controller.ts +1 -2
- package/src/modes/controllers/extension-ui-controller.ts +3 -15
- package/src/modes/controllers/input-controller.ts +0 -1
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +5 -7
- package/src/modes/rpc/rpc-client.ts +9 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/prompts/system/system-prompt.md +14 -38
- package/src/prompts/tools/ast-edit.md +8 -8
- package/src/prompts/tools/ast-grep.md +10 -10
- package/src/prompts/tools/eval.md +13 -31
- package/src/prompts/tools/find.md +2 -1
- package/src/prompts/tools/hashline.md +66 -57
- package/src/prompts/tools/search.md +2 -2
- package/src/sdk.ts +19 -4
- package/src/session/agent-session.ts +110 -4
- package/src/session/session-manager.ts +17 -13
- package/src/task/agents.ts +4 -5
- package/src/tools/archive-reader.ts +9 -3
- package/src/tools/ast-edit.ts +141 -44
- package/src/tools/ast-grep.ts +112 -36
- package/src/tools/browser/readable.ts +11 -6
- package/src/tools/browser/tab-supervisor.ts +2 -2
- package/src/tools/browser.ts +5 -3
- package/src/tools/eval.ts +2 -53
- package/src/tools/find.ts +16 -15
- package/src/tools/image-gen.ts +2 -2
- package/src/tools/path-utils.ts +36 -196
- package/src/tools/search.ts +56 -35
- package/src/tools/write.ts +8 -1
- package/src/utils/edit-mode.ts +2 -11
- package/src/utils/file-display-mode.ts +1 -1
- package/src/utils/git.ts +17 -0
- package/src/utils/session-color.ts +0 -12
- package/src/utils/title-generator.ts +22 -38
- package/src/web/scrapers/crossref.ts +3 -3
- package/src/web/scrapers/devto.ts +1 -1
- package/src/web/scrapers/discourse.ts +5 -5
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/flathub.ts +2 -2
- package/src/web/scrapers/gitlab.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
- package/src/web/scrapers/mastodon.ts +9 -9
- package/src/web/scrapers/mdn.ts +11 -7
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/rawg.ts +3 -3
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/spdx.ts +1 -1
- package/src/web/scrapers/stackoverflow.ts +2 -2
- package/src/web/scrapers/types.ts +53 -39
- package/src/web/scrapers/w3c.ts +1 -1
- package/src/web/search/providers/gemini.ts +2 -2
- package/src/autoresearch/apply-contract-to-state.ts +0 -24
- package/src/autoresearch/contract.ts +0 -288
- package/src/edit/modes/atom.lark +0 -29
- package/src/edit/modes/atom.ts +0 -1773
- package/src/prompts/tools/atom.md +0 -150
package/src/edit/modes/atom.ts
DELETED
|
@@ -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
|
-
}
|