@oh-my-pi/pi-coding-agent 14.5.2 → 14.5.5
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 +70 -0
- package/examples/extensions/plan-mode.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +104 -6
- package/src/config/settings-schema.ts +14 -13
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +4 -4
- package/src/edit/index.ts +111 -109
- package/src/edit/line-hash.ts +33 -3
- package/src/edit/modes/apply-patch.ts +6 -4
- package/src/edit/modes/atom.lark +27 -0
- package/src/edit/modes/atom.ts +1094 -642
- package/src/edit/modes/hashline.ts +9 -10
- package/src/edit/modes/patch.ts +23 -19
- package/src/edit/modes/replace.ts +19 -15
- package/src/edit/renderer.ts +65 -8
- package/src/edit/streaming.ts +47 -77
- package/src/extensibility/extensions/types.ts +11 -11
- package/src/extensibility/hooks/types.ts +6 -6
- package/src/lsp/edits.ts +8 -5
- package/src/lsp/index.ts +4 -4
- package/src/lsp/utils.ts +13 -43
- package/src/mcp/discoverable-tool-metadata.ts +1 -1
- package/src/mcp/manager.ts +3 -3
- package/src/mcp/tool-bridge.ts +4 -4
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/settings-defs.ts +3 -3
- package/src/modes/components/tree-selector.ts +2 -2
- package/src/modes/controllers/event-controller.ts +12 -0
- package/src/modes/utils/ui-helpers.ts +31 -7
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/librarian.md +2 -2
- package/src/prompts/agents/plan.md +2 -2
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +2 -2
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/system/system-prompt.md +34 -31
- package/src/prompts/tools/apply-patch.md +0 -2
- package/src/prompts/tools/atom.md +88 -97
- package/src/prompts/tools/bash.md +7 -4
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/find.md +6 -1
- package/src/prompts/tools/hashline.md +10 -11
- package/src/prompts/tools/patch.md +13 -13
- package/src/prompts/tools/read.md +5 -5
- package/src/prompts/tools/replace.md +3 -3
- package/src/prompts/tools/{grep.md → search.md} +4 -4
- package/src/sdk.ts +19 -9
- package/src/session/agent-session.ts +69 -1
- package/src/system-prompt.ts +15 -5
- package/src/task/executor.ts +5 -0
- package/src/task/index.ts +10 -1
- package/src/tools/ast-edit.ts +27 -50
- package/src/tools/ast-grep.ts +22 -48
- package/src/tools/bash.ts +1 -1
- package/src/tools/file-recorder.ts +6 -6
- package/src/tools/find.ts +11 -13
- package/src/tools/grouped-file-output.ts +96 -0
- package/src/tools/index.ts +7 -7
- package/src/tools/path-utils.ts +31 -4
- package/src/tools/read.ts +12 -6
- package/src/tools/renderers.ts +2 -2
- package/src/tools/{grep.ts → search.ts} +43 -86
- package/src/tools/todo-write.ts +0 -1
- package/src/tools/write.ts +8 -4
- package/src/web/search/index.ts +1 -1
package/src/edit/modes/atom.ts
CHANGED
|
@@ -1,506 +1,552 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Atom edit mode.
|
|
2
3
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* The runtime resolves those verbs into internal anchor-scoped edits and still
|
|
6
|
-
* reuses hashline's staleness scheme (`computeLineHash`) verbatim.
|
|
4
|
+
* Single-string compact wire format. Each file section starts with `---path`;
|
|
5
|
+
* each following line is one statement:
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* { path, loc: "$", post: [...] } // append to file
|
|
15
|
-
* { path, loc: "$", sed: { pat, rep, g?, F? } } // sed on every line
|
|
16
|
-
*
|
|
17
|
-
* `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
|
|
18
|
-
* a blank line. Line ranges are not supported.
|
|
19
|
-
* in the same entry.
|
|
20
|
-
*
|
|
21
|
-
* For deleting or moving files, the agent should use bash.
|
|
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
|
+
* +TEXT insert TEXT at the cursor
|
|
11
|
+
* $ move cursor to beginning of file
|
|
12
|
+
* ^ move cursor to end of file
|
|
22
13
|
*/
|
|
23
14
|
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import * as path from "node:path";
|
|
24
17
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
18
|
+
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
25
19
|
import { type Static, Type } from "@sinclair/typebox";
|
|
26
20
|
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
27
21
|
import type { ToolSession } from "../../tools";
|
|
28
|
-
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
29
|
-
import {
|
|
22
|
+
import { assertEditableFile, assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
23
|
+
import {
|
|
24
|
+
invalidateFsScanAfterDelete,
|
|
25
|
+
invalidateFsScanAfterRename,
|
|
26
|
+
invalidateFsScanAfterWrite,
|
|
27
|
+
} from "../../tools/fs-cache-invalidation";
|
|
30
28
|
import { outputMeta } from "../../tools/output-meta";
|
|
31
29
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
32
30
|
import { generateDiffString } from "../diff";
|
|
33
|
-
import { computeLineHash
|
|
31
|
+
import { computeLineHash } from "../line-hash";
|
|
34
32
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
35
33
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
36
34
|
import {
|
|
37
35
|
ANCHOR_REBASE_WINDOW,
|
|
38
36
|
type Anchor,
|
|
39
37
|
buildCompactHashlineDiffPreview,
|
|
40
|
-
formatFullAnchorRequirement,
|
|
41
38
|
HashlineMismatchError,
|
|
42
39
|
type HashMismatch,
|
|
43
|
-
hashlineParseText,
|
|
44
|
-
parseTag,
|
|
45
40
|
tryRebaseAnchor,
|
|
46
41
|
} from "./hashline";
|
|
47
42
|
|
|
48
43
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
49
44
|
// Schema
|
|
50
45
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
-
const textSchema = Type.Array(Type.String());
|
|
52
46
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* The runtime validator (`resolveAtomToolEdit`) enforces legal locator/verb
|
|
56
|
-
* combinations. Keeping the schema flat reduces tool-definition size and gives
|
|
57
|
-
* weaker models fewer branching shapes to sample from.
|
|
58
|
-
*/
|
|
59
|
-
export const atomEditSchema = Type.Object(
|
|
60
|
-
{
|
|
61
|
-
loc: Type.String({
|
|
62
|
-
description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
|
|
63
|
-
examples: ["1ab", "$", "src/foo.ts:1ab"],
|
|
64
|
-
}),
|
|
65
|
-
splice: Type.Optional(textSchema),
|
|
66
|
-
pre: Type.Optional(textSchema),
|
|
67
|
-
post: Type.Optional(textSchema),
|
|
68
|
-
sed: Type.Optional(
|
|
69
|
-
Type.Object(
|
|
70
|
-
{
|
|
71
|
-
pat: Type.String({ description: "pattern to find" }),
|
|
72
|
-
rep: Type.String({ description: "replacement text" }),
|
|
73
|
-
g: Type.Optional(Type.Boolean({ description: "global replace", default: false })),
|
|
74
|
-
F: Type.Optional(Type.Boolean({ description: "literal replace", default: false })),
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
additionalProperties: false,
|
|
78
|
-
},
|
|
79
|
-
),
|
|
80
|
-
),
|
|
81
|
-
},
|
|
82
|
-
{ additionalProperties: false },
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
export const atomEditParamsSchema = Type.Object(
|
|
86
|
-
{
|
|
87
|
-
path: Type.Optional(Type.String({ description: "default file path for edits" })),
|
|
88
|
-
edits: Type.Array(atomEditSchema, { description: "edit ops" }),
|
|
89
|
-
},
|
|
90
|
-
{ additionalProperties: false },
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
export type AtomToolEdit = Static<typeof atomEditSchema>;
|
|
47
|
+
export const atomEditParamsSchema = Type.Object({ input: Type.String() });
|
|
48
|
+
|
|
94
49
|
export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
95
50
|
|
|
96
51
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
97
|
-
//
|
|
52
|
+
// Parser
|
|
98
53
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
99
54
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
| { op: "sed_file"; spec: SedSpec; expression: string };
|
|
109
|
-
|
|
110
|
-
export interface SedSpec {
|
|
111
|
-
pattern: string;
|
|
112
|
-
replacement: string;
|
|
113
|
-
global: boolean;
|
|
114
|
-
literal: boolean;
|
|
55
|
+
// Permissive: any 2 lowercase letters. Invalid hashes flow through to a
|
|
56
|
+
// HashlineMismatchError downstream, matching the other hashline-backed modes.
|
|
57
|
+
const LID_RE = /^([1-9]\d*)([a-z]{2})/;
|
|
58
|
+
const LID_EXACT_RE = /^([1-9]\d*)([a-z]{2})$/;
|
|
59
|
+
|
|
60
|
+
interface ParsedAnchor {
|
|
61
|
+
line: number;
|
|
62
|
+
hash: string;
|
|
115
63
|
}
|
|
116
64
|
|
|
117
|
-
|
|
118
|
-
// Param guards
|
|
119
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
type ParsedOp = { op: "set"; text: string; allowOldNewRepair: boolean } | { op: "delete" };
|
|
120
66
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
67
|
+
type AnchorStmt =
|
|
68
|
+
| { kind: "bare_anchor"; anchor: ParsedAnchor; lineNum: number }
|
|
69
|
+
| { kind: "anchor_op"; anchor: ParsedAnchor; op: ParsedOp; lineNum: number }
|
|
70
|
+
| { kind: "bof"; lineNum: number }
|
|
71
|
+
| { kind: "eof"; lineNum: number };
|
|
124
72
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
73
|
+
type InsertStmt = {
|
|
74
|
+
kind: "insert";
|
|
75
|
+
text: string;
|
|
76
|
+
lineNum: number;
|
|
77
|
+
};
|
|
129
78
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// fails the anchor pattern.
|
|
137
|
-
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
138
|
-
const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
|
|
79
|
+
type DiffishAddStmt = {
|
|
80
|
+
kind: "diffish_add";
|
|
81
|
+
anchor: ParsedAnchor;
|
|
82
|
+
text: string;
|
|
83
|
+
lineNum: number;
|
|
84
|
+
};
|
|
139
85
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
86
|
+
type DeleteWithOldStmt = {
|
|
87
|
+
kind: "delete_with_old";
|
|
88
|
+
anchor: ParsedAnchor;
|
|
89
|
+
old: string;
|
|
90
|
+
lineNum: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type ParsedStmt = AnchorStmt | InsertStmt | DiffishAddStmt | DeleteWithOldStmt;
|
|
94
|
+
|
|
95
|
+
type AtomCursor = { kind: "bof" } | { kind: "eof" } | { kind: "anchor"; anchor: Anchor };
|
|
96
|
+
|
|
97
|
+
export type AtomEdit =
|
|
98
|
+
| { kind: "insert"; cursor: AtomCursor; text: string; lineNum: number; index: number }
|
|
99
|
+
| { kind: "set"; anchor: Anchor; text: string; lineNum: number; index: number; allowOldNewRepair: boolean }
|
|
100
|
+
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
|
|
101
|
+
|
|
102
|
+
interface AtomApplyResult {
|
|
103
|
+
lines: string;
|
|
104
|
+
firstChangedLine?: number;
|
|
105
|
+
warnings?: string[];
|
|
106
|
+
noopEdits?: AtomNoopEdit[];
|
|
149
107
|
}
|
|
150
108
|
|
|
151
|
-
|
|
109
|
+
interface AtomNoopEdit {
|
|
110
|
+
editIndex: number;
|
|
111
|
+
loc: string;
|
|
112
|
+
reason: string;
|
|
113
|
+
current: string;
|
|
114
|
+
}
|
|
152
115
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
116
|
+
interface IndexedAnchorEdit {
|
|
117
|
+
edit: Extract<AtomEdit, { kind: "insert" | "set" | "delete" }>;
|
|
118
|
+
idx: number;
|
|
119
|
+
}
|
|
156
120
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
return parseTag(raw);
|
|
174
|
-
} catch {
|
|
175
|
-
const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
|
|
176
|
-
if (lineMatch) {
|
|
177
|
-
const line = Number.parseInt(lineMatch[1], 10);
|
|
178
|
-
if (line >= 1) {
|
|
179
|
-
// Sentinel hash that will never match a real line, forcing the validator
|
|
180
|
-
// to report a mismatch with the actual hash + line content.
|
|
181
|
-
return { line, hash: "??" };
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
throw new Error(
|
|
185
|
-
`${opName} requires ${formatFullAnchorRequirement(raw)} Could not find a line number in the anchor.`,
|
|
186
|
-
);
|
|
121
|
+
function cloneCursor(cursor: AtomCursor): AtomCursor {
|
|
122
|
+
if (cursor.kind !== "anchor") return cursor;
|
|
123
|
+
return { kind: "anchor", anchor: { ...cursor.anchor } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseLidStmt(body: string, lineNum: number): AnchorStmt | null {
|
|
127
|
+
const m = LID_RE.exec(body);
|
|
128
|
+
if (!m) return null;
|
|
129
|
+
|
|
130
|
+
const ln = Number.parseInt(m[1], 10);
|
|
131
|
+
const hash = m[2];
|
|
132
|
+
const rest = body.slice(m[0].length);
|
|
133
|
+
const anchor = { line: ln, hash };
|
|
134
|
+
if (rest.length === 0) {
|
|
135
|
+
return { kind: "bare_anchor", anchor, lineNum };
|
|
187
136
|
}
|
|
137
|
+
|
|
138
|
+
const replacement = /^[ \t]*([=|])(.*)$/.exec(rest);
|
|
139
|
+
if (!replacement) return null;
|
|
140
|
+
return {
|
|
141
|
+
kind: "anchor_op",
|
|
142
|
+
anchor,
|
|
143
|
+
op: { op: "set", text: replacement[2], allowOldNewRepair: replacement[1] === "|" },
|
|
144
|
+
lineNum,
|
|
145
|
+
};
|
|
188
146
|
}
|
|
189
147
|
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
148
|
+
function parseDeleteStmt(body: string, lineNum: number): ParsedStmt[] | null {
|
|
149
|
+
const trimmedBody = body.trimStart();
|
|
150
|
+
const exact = LID_EXACT_RE.exec(trimmedBody);
|
|
151
|
+
if (exact) {
|
|
152
|
+
const ln = Number.parseInt(exact[1], 10);
|
|
153
|
+
return [{ kind: "anchor_op", anchor: { line: ln, hash: exact[2] }, op: { op: "delete" }, lineNum }];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const m = LID_RE.exec(trimmedBody);
|
|
157
|
+
if (m && (trimmedBody[m[0].length] === "|" || trimmedBody[m[0].length] === "=")) {
|
|
158
|
+
const ln = Number.parseInt(m[1], 10);
|
|
159
|
+
const old = trimmedBody.slice(m[0].length + 1);
|
|
160
|
+
return [{ kind: "delete_with_old", anchor: { line: ln, hash: m[2] }, old, lineNum }];
|
|
195
161
|
}
|
|
162
|
+
if (m && trimmedBody[m[0].length] === " ") {
|
|
163
|
+
const ln = Number.parseInt(m[1], 10);
|
|
164
|
+
const text = trimmedBody.slice(m[0].length + 1);
|
|
165
|
+
return [
|
|
166
|
+
{ kind: "anchor_op", anchor: { line: ln, hash: m[2] }, op: { op: "delete" }, lineNum },
|
|
167
|
+
{ kind: "insert", text, lineNum },
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
196
172
|
}
|
|
197
173
|
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
pathOverride = split[1];
|
|
210
|
-
loc = split[2]!;
|
|
211
|
-
}
|
|
174
|
+
function throwMalformedLidDiagnostic(line: string, lineNum: number, raw: string): never {
|
|
175
|
+
const text = line.trimStart();
|
|
176
|
+
const withoutLegacyMove = text.startsWith("@@ ") ? text.slice(3).trimStart() : text;
|
|
177
|
+
const withoutMove = withoutLegacyMove.startsWith("@") ? withoutLegacyMove.slice(1) : withoutLegacyMove;
|
|
178
|
+
const withoutDelete = withoutMove.startsWith("-") ? withoutMove.slice(1).trimStart() : withoutMove;
|
|
179
|
+
|
|
180
|
+
const partial = /^([a-z]{2})(?=[ \t]*[=|])/.exec(withoutDelete);
|
|
181
|
+
if (partial) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Diff line ${lineNum}: \`${partial[1]}\` is not a full Lid. Use the full Lid from read output, e.g. \`119${partial[1]}\`.`,
|
|
184
|
+
);
|
|
212
185
|
}
|
|
213
|
-
|
|
214
|
-
|
|
186
|
+
|
|
187
|
+
const missing = /^([1-9]\d*)(?=[ \t]*[=|]|$)/.exec(withoutDelete);
|
|
188
|
+
if (missing) {
|
|
189
|
+
const prefix = text.startsWith("@@ ") ? `@@ ${missing[1]}` : missing[1];
|
|
215
190
|
throw new Error(
|
|
216
|
-
`
|
|
191
|
+
`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\`.`,
|
|
217
192
|
);
|
|
218
193
|
}
|
|
219
|
-
return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
|
|
220
|
-
}
|
|
221
194
|
|
|
222
|
-
|
|
223
|
-
edits: readonly AtomToolEdit[],
|
|
224
|
-
topLevelPath: string | undefined,
|
|
225
|
-
): (AtomToolEdit & { path: string })[] {
|
|
226
|
-
return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
|
|
195
|
+
throw new Error(`Diff line ${lineNum}: cannot parse "${raw}".`);
|
|
227
196
|
}
|
|
228
197
|
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
198
|
+
function parseDiffLine(raw: string, lineNum: number): ParsedStmt[] {
|
|
199
|
+
// Strip trailing CR (CRLF tolerance).
|
|
200
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
201
|
+
if (line.length === 0) return [];
|
|
202
|
+
|
|
203
|
+
// `+TEXT` inserts at the cursor. Everything after `+` is content. A
|
|
204
|
+
// `+Lid|TEXT` or `+Lid=TEXT` line is a diff-ish add (unified-diff trap):
|
|
205
|
+
// emit a tagged stmt so the normalizer can fuse it with a preceding `-Lid`.
|
|
206
|
+
if (line[0] === "+") {
|
|
207
|
+
const body = line.slice(1);
|
|
208
|
+
const m = LID_RE.exec(body);
|
|
209
|
+
if (m) {
|
|
210
|
+
const sep = body[m[0].length];
|
|
211
|
+
if (sep === "=" || sep === "|") {
|
|
212
|
+
const ln = Number.parseInt(m[1], 10);
|
|
213
|
+
const text = body.slice(m[0].length + 1);
|
|
214
|
+
return [{ kind: "diffish_add", anchor: { line: ln, hash: m[2] }, text, lineNum }];
|
|
215
|
+
}
|
|
241
216
|
}
|
|
217
|
+
|
|
218
|
+
// Auto-fix: `+@Lid` and `+-Lid` are almost always typos where the agent
|
|
219
|
+
// prefixed a cursor-move or delete op with `+`. Insert content matching
|
|
220
|
+
// these op shapes is essentially never legitimate in source code, and
|
|
221
|
+
// silently emitting them as literal text corrupts the file (e.g. a stray
|
|
222
|
+
// `@12ly` line in a C++ source). Split into the op + a blank `+` insert
|
|
223
|
+
// so the line count of the edit script is preserved for any downstream
|
|
224
|
+
// offset-sensitive logic.
|
|
225
|
+
if (body.length > 1 && (body[0] === "@" || body[0] === "-")) {
|
|
226
|
+
try {
|
|
227
|
+
const opStmts = parseDiffLine(body, lineNum);
|
|
228
|
+
const allOps = opStmts.length > 0 && opStmts.every(s => s.kind !== "insert" && s.kind !== "diffish_add");
|
|
229
|
+
if (allOps) {
|
|
230
|
+
return [...opStmts, { kind: "insert", text: "", lineNum }];
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// Body looked op-shaped but failed to parse; fall through to literal insert.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return [{ kind: "insert", text: body, lineNum }];
|
|
242
237
|
}
|
|
243
|
-
const pos = parseAnchor(raw, "loc");
|
|
244
|
-
// Capture an optional content suffix after the anchor: `82zu| for (...)`.
|
|
245
|
-
// The suffix acts as a hint for anchor disambiguation when the model's hash
|
|
246
|
-
// is wrong but the content reveals the intended line.
|
|
247
|
-
const hint = extractAnchorContentHint(raw);
|
|
248
|
-
if (hint !== undefined) {
|
|
249
|
-
pos.contentHint = hint;
|
|
250
|
-
}
|
|
251
|
-
return { kind: "anchor", pos };
|
|
252
|
-
}
|
|
253
238
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
const rest = raw.slice(match[0].length);
|
|
258
|
-
// Accept either the canonical `|` (HASHLINE_CONTENT_SEPARATOR) or the legacy
|
|
259
|
-
// `:` separator. Models trained on older docs still emit `82zu: for (...)`.
|
|
260
|
-
const sep = rest[0];
|
|
261
|
-
if (sep !== HASHLINE_CONTENT_SEPARATOR && sep !== ":") return undefined;
|
|
262
|
-
const hint = rest.slice(1);
|
|
263
|
-
if (hint.trim().length === 0) return undefined;
|
|
264
|
-
return hint;
|
|
265
|
-
}
|
|
239
|
+
// Canonical file-scope locators.
|
|
240
|
+
if (line === "$") return [{ kind: "bof", lineNum }];
|
|
241
|
+
if (line === "^") return [{ kind: "eof", lineNum }];
|
|
266
242
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
243
|
+
// `-Lid` deletes the anchored line. Leniently accept `- Lid` and the
|
|
244
|
+
// historical `-Lid TEXT` delete-then-insert recovery.
|
|
245
|
+
if (line[0] === "-") {
|
|
246
|
+
const parsed = parseDeleteStmt(line.slice(1), lineNum);
|
|
247
|
+
if (parsed) return parsed;
|
|
248
|
+
throw new Error(`Diff line ${lineNum}: \`-\` must be followed by a Lid (e.g. \`-5xx\`). Got "${raw}".`);
|
|
270
249
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
250
|
+
|
|
251
|
+
// Legacy move prefix. Runtime accepts old locators and common slipped edit
|
|
252
|
+
// operations, while the grammar/prompt bias models to canonical syntax.
|
|
253
|
+
if (line.startsWith("@@ ")) {
|
|
254
|
+
const body = line.slice(3);
|
|
255
|
+
if (body === "BOF") return [{ kind: "bof", lineNum }];
|
|
256
|
+
if (body === "EOF") return [{ kind: "eof", lineNum }];
|
|
257
|
+
|
|
258
|
+
const deleteStmt = body.startsWith("-") ? parseDeleteStmt(body.slice(1), lineNum) : null;
|
|
259
|
+
if (deleteStmt) return deleteStmt;
|
|
260
|
+
|
|
261
|
+
const lidStmt = parseLidStmt(body, lineNum);
|
|
262
|
+
if (lidStmt) return [lidStmt];
|
|
263
|
+
|
|
264
|
+
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
276
265
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
266
|
+
|
|
267
|
+
// Canonical `@Lid` cursor moves. Leniently recover `@Lid=TEXT`,
|
|
268
|
+
// `@Lid|TEXT`, `@$`, and `@^`.
|
|
269
|
+
if (line[0] === "@") {
|
|
270
|
+
const body = line.slice(1);
|
|
271
|
+
if (body === "$") return [{ kind: "bof", lineNum }];
|
|
272
|
+
if (body === "^") return [{ kind: "eof", lineNum }];
|
|
273
|
+
const lidStmt = parseLidStmt(body, lineNum);
|
|
274
|
+
if (lidStmt) return [lidStmt];
|
|
275
|
+
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
281
276
|
}
|
|
282
|
-
|
|
283
|
-
|
|
277
|
+
|
|
278
|
+
// `Lid=TEXT` sets the anchored line. Legacy `Lid|TEXT` remains accepted.
|
|
279
|
+
// A bare `Lid` is a cursor move.
|
|
280
|
+
const lidStmt = parseLidStmt(line, lineNum);
|
|
281
|
+
if (lidStmt) return [lidStmt];
|
|
282
|
+
|
|
283
|
+
if (/^[a-z]{2}(?=[ \t]*[=|])/.test(line) || /^[1-9]\d*(?=[ \t]*[=|]|$)/.test(line)) {
|
|
284
|
+
throwMalformedLidDiagnostic(line, lineNum, raw);
|
|
284
285
|
}
|
|
285
|
-
const readBool = (key: "g" | "F", defaultValue: boolean): boolean => {
|
|
286
|
-
const v = obj[key];
|
|
287
|
-
if (v === undefined) return defaultValue;
|
|
288
|
-
if (typeof v !== "boolean") {
|
|
289
|
-
throw new Error(`Edit ${editIndex}: sed.${key} must be a boolean when provided.`);
|
|
290
|
-
}
|
|
291
|
-
return v;
|
|
292
|
-
};
|
|
293
|
-
const global = readBool("g", false);
|
|
294
|
-
const literal = readBool("F", false);
|
|
295
|
-
return { pattern: pat, replacement: rep, global, literal };
|
|
296
|
-
}
|
|
297
286
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (spec.literal) obj.F = true;
|
|
306
|
-
return JSON.stringify(obj);
|
|
287
|
+
// Reject any line that doesn't match a recognized op. Common case: a model
|
|
288
|
+
// emitted multi-line content after a `Lid=` or similar without `+` prefixes,
|
|
289
|
+
// or pasted raw context. Silently treating these as inserts corrupts files.
|
|
290
|
+
const preview = line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Diff line ${lineNum}: unrecognized op. Lines must start with \`+\`, \`-\`, \`@\`, \`$\`, \`^\`, or a Lid (\`Lid=TEXT\`). To insert literal text use \`+TEXT\`. Got "${preview}".`,
|
|
293
|
+
);
|
|
307
294
|
}
|
|
308
295
|
|
|
309
|
-
function
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
296
|
+
function tokenizeDiff(diff: string): ParsedStmt[] {
|
|
297
|
+
const out: ParsedStmt[] = [];
|
|
298
|
+
const lines = diff.split("\n");
|
|
299
|
+
for (let i = 0; i < lines.length; i++) {
|
|
300
|
+
const lineNum = i + 1;
|
|
301
|
+
const stmts = parseDiffLine(lines[i], lineNum);
|
|
302
|
+
for (const stmt of stmts) {
|
|
303
|
+
// Last-set-wins: when the same anchor (line+hash) gets a second `set`,
|
|
304
|
+
// drop the earlier one. Models sometimes echo the OLD line and then the
|
|
305
|
+
// NEW line as replacements (e.g. `119yh|OLD` / `119yh|NEW`); the last is
|
|
306
|
+
// the intended value.
|
|
307
|
+
if (stmt.kind === "anchor_op" && stmt.op.op === "set") {
|
|
308
|
+
const key = `${stmt.anchor.line}:${stmt.anchor.hash}`;
|
|
309
|
+
for (let j = out.length - 1; j >= 0; j--) {
|
|
310
|
+
const prior = out[j];
|
|
311
|
+
if (
|
|
312
|
+
prior.kind === "anchor_op" &&
|
|
313
|
+
prior.op.op === "set" &&
|
|
314
|
+
`${prior.anchor.line}:${prior.anchor.hash}` === key
|
|
315
|
+
) {
|
|
316
|
+
out.splice(j, 1);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
out.push(stmt);
|
|
322
|
+
}
|
|
314
323
|
}
|
|
315
|
-
return
|
|
316
|
-
result: currentLine.slice(0, idx) + spec.replacement + currentLine.slice(idx + spec.pattern.length),
|
|
317
|
-
matched: true,
|
|
318
|
-
};
|
|
324
|
+
return normalizeHunks(out);
|
|
319
325
|
}
|
|
320
326
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
let
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return { result: currentLine, matched: false, error: compileError };
|
|
360
|
-
}
|
|
361
|
-
return { result: currentLine, matched: false };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function classifyAtomEdit(edit: AtomToolEdit): string {
|
|
365
|
-
const entry = stripNullAtomFields(edit);
|
|
366
|
-
const verbs = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
367
|
-
return verbs.length > 0 ? verbs.join("+") : "unknown";
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
371
|
-
const entry = stripNullAtomFields(edit);
|
|
372
|
-
const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
373
|
-
if (verbKeysPresent.length === 0) {
|
|
374
|
-
throw new Error(
|
|
375
|
-
`Edit ${editIndex}: missing verb. Each entry must include at least one of: ${ATOM_VERB_KEYS.join(", ")}.`,
|
|
327
|
+
// Detect contiguous `[delete | delete_with_old]+ [insert | diffish_add]+`
|
|
328
|
+
// hunks and reorder so adds land at the FIRST delete's slot (block
|
|
329
|
+
// replacement). Single-line `-Lid` + `+Lid|TEXT` (same Lid) fuses to a
|
|
330
|
+
// `set`. Standalone `+Lid|TEXT` and `+Lid|TEXT` referencing a Lid not in
|
|
331
|
+
function normalizeHunks(stmts: ParsedStmt[]): ParsedStmt[] {
|
|
332
|
+
const isDelete = (s: ParsedStmt): boolean =>
|
|
333
|
+
(s.kind === "anchor_op" && s.op.op === "delete") || s.kind === "delete_with_old";
|
|
334
|
+
const isAdd = (s: ParsedStmt): boolean => s.kind === "insert" || s.kind === "diffish_add";
|
|
335
|
+
const out: ParsedStmt[] = [];
|
|
336
|
+
let i = 0;
|
|
337
|
+
while (i < stmts.length) {
|
|
338
|
+
const stmt = stmts[i];
|
|
339
|
+
if (!isDelete(stmt)) {
|
|
340
|
+
if (stmt.kind === "diffish_add") {
|
|
341
|
+
const lid = `${stmt.anchor.line}${stmt.anchor.hash}`;
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Diff line ${stmt.lineNum}: \`+${lid}|...\` looks like a unified-diff replacement marker. Use \`${lid}=TEXT\` to replace, or precede with \`-${lid}\` to delete-then-replace.`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
out.push(stmt);
|
|
347
|
+
i++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const deletes: ParsedStmt[] = [];
|
|
351
|
+
while (i < stmts.length && isDelete(stmts[i])) {
|
|
352
|
+
deletes.push(stmts[i]);
|
|
353
|
+
i++;
|
|
354
|
+
}
|
|
355
|
+
const adds: ParsedStmt[] = [];
|
|
356
|
+
while (i < stmts.length && isAdd(stmts[i])) {
|
|
357
|
+
adds.push(stmts[i]);
|
|
358
|
+
i++;
|
|
359
|
+
}
|
|
360
|
+
const deletedLids = new Set(
|
|
361
|
+
deletes.map(d => {
|
|
362
|
+
const a = (d as { anchor: ParsedAnchor }).anchor;
|
|
363
|
+
return `${a.line}${a.hash}`;
|
|
364
|
+
}),
|
|
376
365
|
);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
366
|
+
for (const add of adds) {
|
|
367
|
+
if (add.kind !== "diffish_add") continue;
|
|
368
|
+
const lid = `${add.anchor.line}${add.anchor.hash}`;
|
|
369
|
+
if (!deletedLids.has(lid)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Diff line ${add.lineNum}: \`+${lid}|...\` references a Lid that was not deleted in the preceding run. Use \`${lid}=TEXT\` to replace, or precede with \`-${lid}\`.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Split the delete run into file-contiguous sub-runs. The block
|
|
376
|
+
// reorder (inserts land at the FIRST delete's slot) is meaningful only
|
|
377
|
+
// when the deletes describe a single contiguous file range. When the
|
|
378
|
+
// agent stacks deletes that target far-apart lines (e.g. `-186 -197
|
|
379
|
+
// -198 -199` to remove a debug line at 186 AND replace 197-199), each
|
|
380
|
+
// far-apart delete moves the cursor on its own; only the LAST
|
|
381
|
+
// contiguous group should attract the inserts.
|
|
382
|
+
const subruns = splitContiguousDeletes(deletes);
|
|
383
|
+
for (let r = 0; r < subruns.length - 1; r++) {
|
|
384
|
+
for (const d of subruns[r]) out.push(d);
|
|
388
385
|
}
|
|
389
|
-
|
|
390
|
-
|
|
386
|
+
const lastDeletes = subruns[subruns.length - 1];
|
|
387
|
+
|
|
388
|
+
// Single-line case: 1 delete in the last sub-run + 1 diffish_add same Lid → fuse to set.
|
|
389
|
+
if (lastDeletes.length === 1 && adds.length === 1 && adds[0].kind === "diffish_add") {
|
|
390
|
+
const dAnchor = (lastDeletes[0] as { anchor: ParsedAnchor }).anchor;
|
|
391
|
+
const a = adds[0];
|
|
392
|
+
if (a.anchor.line === dAnchor.line && a.anchor.hash === dAnchor.hash) {
|
|
393
|
+
out.push({
|
|
394
|
+
kind: "anchor_op",
|
|
395
|
+
anchor: a.anchor,
|
|
396
|
+
op: { op: "set", text: a.text, allowOldNewRepair: false },
|
|
397
|
+
lineNum: a.lineNum,
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
391
401
|
}
|
|
392
|
-
|
|
393
|
-
|
|
402
|
+
// Block: emit lastDeletes[0], then all inserts (which land at lastDeletes[0]'s slot
|
|
403
|
+
// because the cursor binds to lastDeletes[0] before the inserts), then the
|
|
404
|
+
// remaining lastDeletes.
|
|
405
|
+
out.push(lastDeletes[0]);
|
|
406
|
+
for (const add of adds) {
|
|
407
|
+
const text = add.kind === "insert" ? add.text : (add as DiffishAddStmt).text;
|
|
408
|
+
out.push({ kind: "insert", text, lineNum: add.lineNum });
|
|
394
409
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
resolved.push({ op: "sed_file", spec, expression: formatSedExpression(spec) });
|
|
410
|
+
for (let j = 1; j < lastDeletes.length; j++) {
|
|
411
|
+
out.push(lastDeletes[j]);
|
|
398
412
|
}
|
|
399
|
-
return resolved;
|
|
400
413
|
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
401
416
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
417
|
+
function makeAnchor(anchor: ParsedAnchor): Anchor {
|
|
418
|
+
return { line: anchor.line, hash: anchor.hash };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function splitContiguousDeletes(deletes: ParsedStmt[]): ParsedStmt[][] {
|
|
422
|
+
if (deletes.length === 0) return [];
|
|
423
|
+
const getLine = (s: ParsedStmt): number => {
|
|
424
|
+
if (s.kind === "anchor_op") return s.anchor.line;
|
|
425
|
+
if (s.kind === "delete_with_old") return s.anchor.line;
|
|
426
|
+
throw new Error("internal: splitContiguousDeletes received non-delete stmt");
|
|
427
|
+
};
|
|
428
|
+
const subruns: ParsedStmt[][] = [];
|
|
429
|
+
let current: ParsedStmt[] = [deletes[0]];
|
|
430
|
+
for (let i = 1; i < deletes.length; i++) {
|
|
431
|
+
if (getLine(deletes[i]) === getLine(deletes[i - 1]) + 1) {
|
|
432
|
+
current.push(deletes[i]);
|
|
414
433
|
} else {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
if (entry.post !== undefined) {
|
|
419
|
-
resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
|
|
420
|
-
}
|
|
421
|
-
if (entry.sed !== undefined) {
|
|
422
|
-
const spliceIsExplicitReplacement = Array.isArray(entry.splice) && entry.splice.length > 0;
|
|
423
|
-
// Models often duplicate intent by sending both an explicit `splice` and a
|
|
424
|
-
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
425
|
-
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
426
|
-
if (!spliceIsExplicitReplacement) {
|
|
427
|
-
const spec = parseSedSpec(entry.sed, editIndex);
|
|
428
|
-
resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
|
|
434
|
+
subruns.push(current);
|
|
435
|
+
current = [deletes[i]];
|
|
429
436
|
}
|
|
430
437
|
}
|
|
431
|
-
|
|
438
|
+
subruns.push(current);
|
|
439
|
+
return subruns;
|
|
432
440
|
}
|
|
433
441
|
|
|
434
442
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
435
|
-
//
|
|
443
|
+
// Build cursor-program from ParsedStmt[]
|
|
436
444
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
437
445
|
|
|
438
|
-
function
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
case "post":
|
|
443
|
-
case "del":
|
|
444
|
-
case "sed":
|
|
445
|
-
yield edit.pos;
|
|
446
|
-
return;
|
|
447
|
-
default:
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
446
|
+
export function parseAtom(diff: string): AtomEdit[] {
|
|
447
|
+
const edits: AtomEdit[] = [];
|
|
448
|
+
let cursor: AtomCursor = { kind: "eof" };
|
|
449
|
+
let index = 0;
|
|
451
450
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (fileLines[line - 1].trim() !== hint) continue;
|
|
467
|
-
const distance = Math.abs(line - anchor.line);
|
|
468
|
-
if (best === null || distance < best.distance) {
|
|
469
|
-
best = { line, distance };
|
|
451
|
+
for (const stmt of tokenizeDiff(diff)) {
|
|
452
|
+
if (stmt.kind === "insert") {
|
|
453
|
+
edits.push({ kind: "insert", cursor: cloneCursor(cursor), text: stmt.text, lineNum: stmt.lineNum, index });
|
|
454
|
+
index++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (stmt.kind === "bof") {
|
|
459
|
+
cursor = { kind: "bof" };
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (stmt.kind === "eof") {
|
|
463
|
+
cursor = { kind: "eof" };
|
|
464
|
+
continue;
|
|
470
465
|
}
|
|
466
|
+
|
|
467
|
+
if (stmt.kind === "delete_with_old") {
|
|
468
|
+
const anchor = makeAnchor(stmt.anchor);
|
|
469
|
+
cursor = { kind: "anchor", anchor: { ...anchor } };
|
|
470
|
+
edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index, oldAssertion: stmt.old });
|
|
471
|
+
index++;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (stmt.kind === "diffish_add") {
|
|
476
|
+
throw new Error("Internal atom error: unresolved diff-ish add reached parseAtom.");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const anchor = makeAnchor(stmt.anchor);
|
|
480
|
+
cursor = { kind: "anchor", anchor: { ...anchor } };
|
|
481
|
+
if (stmt.kind === "bare_anchor") continue;
|
|
482
|
+
|
|
483
|
+
if (stmt.op.op === "set") {
|
|
484
|
+
if (stmt.op.text.includes("\r")) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`Diff line ${stmt.lineNum}: set value contains a carriage return; use a single-line value.`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
edits.push({
|
|
490
|
+
kind: "set",
|
|
491
|
+
anchor,
|
|
492
|
+
text: stmt.op.text,
|
|
493
|
+
lineNum: stmt.lineNum,
|
|
494
|
+
index,
|
|
495
|
+
allowOldNewRepair: stmt.op.allowOldNewRepair,
|
|
496
|
+
});
|
|
497
|
+
index++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
edits.push({ kind: "delete", anchor, lineNum: stmt.lineNum, index });
|
|
502
|
+
index++;
|
|
471
503
|
}
|
|
472
|
-
|
|
504
|
+
|
|
505
|
+
return edits;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function formatNoAtomEditDiagnostic(_path: string, diff: string): string {
|
|
509
|
+
const body = diff
|
|
510
|
+
.split("\n")
|
|
511
|
+
.map(line => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
512
|
+
.filter(line => line.trim().length > 0)
|
|
513
|
+
.slice(0, 3)
|
|
514
|
+
.map(line => ` ${line}`)
|
|
515
|
+
.join("\n");
|
|
516
|
+
const preview = body.length > 0 ? `\nReceived only locator/context lines:\n${body}` : "";
|
|
517
|
+
return `Cursor moved but no mutation found. Add +TEXT to insert, -Lid to delete, or Lid=TEXT to replace.${preview}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
521
|
+
// Apply cursor-program
|
|
522
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
523
|
+
|
|
524
|
+
function getAtomEditAnchors(edit: AtomEdit): Anchor[] {
|
|
525
|
+
if (edit.kind === "set" || edit.kind === "delete") return [edit.anchor];
|
|
526
|
+
if (edit.cursor.kind === "anchor") return [edit.cursor.anchor];
|
|
527
|
+
return [];
|
|
473
528
|
}
|
|
474
529
|
|
|
475
530
|
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
476
531
|
const mismatches: HashMismatch[] = [];
|
|
532
|
+
const rebasedAnchors = new Map<Anchor, HashMismatch>();
|
|
533
|
+
const rebasedMutatingAnchors: { original: string; rebased: number; hash: string }[] = [];
|
|
477
534
|
for (const edit of edits) {
|
|
478
|
-
for (const anchor of
|
|
535
|
+
for (const anchor of getAtomEditAnchors(edit)) {
|
|
479
536
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
480
537
|
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
481
538
|
}
|
|
482
539
|
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
483
540
|
if (actualHash === anchor.hash) continue;
|
|
484
|
-
|
|
485
|
-
// `82zu| for (...)`), prefer rebasing to the line that actually matches
|
|
486
|
-
// that content. This avoids false positives from hash-only rebasing where
|
|
487
|
-
// a coincidentally matching hash on a nearby line silently retargets the
|
|
488
|
-
// edit to the wrong line.
|
|
489
|
-
const hinted = findLineByContentHint(anchor, fileLines);
|
|
490
|
-
if (hinted !== null) {
|
|
491
|
-
const original = `${anchor.line}${anchor.hash}`;
|
|
492
|
-
const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
|
|
493
|
-
anchor.line = hinted;
|
|
494
|
-
anchor.hash = hintedHash;
|
|
495
|
-
warnings.push(
|
|
496
|
-
`Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
|
|
497
|
-
);
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
541
|
+
|
|
500
542
|
const rebased = tryRebaseAnchor(anchor, fileLines);
|
|
501
543
|
if (rebased !== null) {
|
|
502
544
|
const original = `${anchor.line}${anchor.hash}`;
|
|
545
|
+
rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
503
546
|
anchor.line = rebased;
|
|
547
|
+
if (edit.kind === "set" || edit.kind === "delete") {
|
|
548
|
+
rebasedMutatingAnchors.push({ original, rebased, hash: anchor.hash });
|
|
549
|
+
}
|
|
504
550
|
warnings.push(
|
|
505
551
|
`Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
|
|
506
552
|
);
|
|
@@ -509,53 +555,231 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
509
555
|
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
510
556
|
}
|
|
511
557
|
}
|
|
558
|
+
|
|
559
|
+
// Rebase cap: a single stale anchor (e.g. unrelated upstream edit) is fine,
|
|
560
|
+
// but multiple mutating anchors all rebasing is the signature of a miscounted
|
|
561
|
+
// block edit (agent stacked `Lid=X` ops over a contiguous range whose new
|
|
562
|
+
// length differs from the old). Refuse so the agent retries with the
|
|
563
|
+
// `-Lid`+`+TEXT` block-rewrite recipe instead of silently corrupting the file.
|
|
564
|
+
if (rebasedMutatingAnchors.length > 1) {
|
|
565
|
+
const detail = rebasedMutatingAnchors.map(r => `${r.original} → ${r.rebased}${r.hash}`).join(", ");
|
|
566
|
+
throw new Error(
|
|
567
|
+
`Refusing edit: ${rebasedMutatingAnchors.length} mutating anchors needed auto-rebase (${detail}). ` +
|
|
568
|
+
"This usually means a `Lid=X` chain was used to rewrite a contiguous block whose new length differs from the old. " +
|
|
569
|
+
"Rewrite the block by deleting each original line with `-Lid` (one per line) and emitting the new content as `+TEXT` lines.",
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Detect post-rebase conflicts. If any conflicting anchor was rebased, surface
|
|
574
|
+
// the original hash mismatch instead — the rebase itself is what created the
|
|
575
|
+
// conflict, and the model needs to fix the stale anchor, not deduplicate.
|
|
576
|
+
const seenLines = new Map<number, Anchor>();
|
|
577
|
+
for (const edit of edits) {
|
|
578
|
+
if (edit.kind !== "set" && edit.kind !== "delete") continue;
|
|
579
|
+
const existing = seenLines.get(edit.anchor.line);
|
|
580
|
+
if (existing) {
|
|
581
|
+
const rebasedA = rebasedAnchors.get(edit.anchor);
|
|
582
|
+
const rebasedB = rebasedAnchors.get(existing);
|
|
583
|
+
if (rebasedA) mismatches.push(rebasedA);
|
|
584
|
+
else if (rebasedB) mismatches.push(rebasedB);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
seenLines.set(edit.anchor.line, edit.anchor);
|
|
588
|
+
}
|
|
512
589
|
return mismatches;
|
|
513
590
|
}
|
|
514
591
|
|
|
515
|
-
function
|
|
516
|
-
// For each anchor line, at most one mutating op (splice/del). Multiple `sed`
|
|
517
|
-
// ops on the same line are allowed and applied sequentially. `pre`/`post`
|
|
518
|
-
// (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
592
|
+
function validateNoConflictingAtomMutations(edits: AtomEdit[]): void {
|
|
519
593
|
const mutatingPerLine = new Map<number, string>();
|
|
520
594
|
for (const edit of edits) {
|
|
521
|
-
if (edit.
|
|
522
|
-
const existing = mutatingPerLine.get(edit.
|
|
595
|
+
if (edit.kind !== "set" && edit.kind !== "delete") continue;
|
|
596
|
+
const existing = mutatingPerLine.get(edit.anchor.line);
|
|
523
597
|
if (existing) {
|
|
524
|
-
if (existing === "sed" && edit.op === "sed") continue;
|
|
525
598
|
throw new Error(
|
|
526
|
-
`Conflicting ops on anchor line ${edit.
|
|
527
|
-
|
|
599
|
+
`Conflicting ops on anchor line ${edit.anchor.line}: \`${existing}\` and \`${edit.kind}\`. ` +
|
|
600
|
+
"At most one mutating op (set/delete) is allowed per anchor.",
|
|
528
601
|
);
|
|
529
602
|
}
|
|
530
|
-
mutatingPerLine.set(edit.
|
|
603
|
+
mutatingPerLine.set(edit.anchor.line, edit.kind);
|
|
531
604
|
}
|
|
532
605
|
}
|
|
533
606
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
607
|
+
function repairAtomOldNewSetLine(currentLine: string, nextLine: string): string {
|
|
608
|
+
const marker = `${currentLine}|`;
|
|
609
|
+
if (!nextLine.startsWith(marker)) return nextLine;
|
|
610
|
+
const repaired = nextLine.slice(marker.length);
|
|
611
|
+
return repaired.length > 0 ? repaired : nextLine;
|
|
612
|
+
}
|
|
537
613
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
614
|
+
function insertAtStart(fileLines: string[], lines: string[]): void {
|
|
615
|
+
if (lines.length === 0) return;
|
|
616
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
617
|
+
fileLines.splice(0, 1, ...lines);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
fileLines.splice(0, 0, ...lines);
|
|
543
621
|
}
|
|
544
622
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
)
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
623
|
+
function insertAtEnd(fileLines: string[], lines: string[]): number | undefined {
|
|
624
|
+
if (lines.length === 0) return undefined;
|
|
625
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
626
|
+
fileLines.splice(0, 1, ...lines);
|
|
627
|
+
return 1;
|
|
628
|
+
}
|
|
629
|
+
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
|
630
|
+
const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
631
|
+
fileLines.splice(insertIdx, 0, ...lines);
|
|
632
|
+
return insertIdx + 1;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function isSameFileCursor(a: AtomCursor, b: AtomCursor): boolean {
|
|
636
|
+
return a.kind === b.kind && a.kind !== "anchor";
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function collectFileInsertRuns(
|
|
640
|
+
fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
|
|
641
|
+
): Array<{ cursor: AtomCursor; lines: string[] }> {
|
|
642
|
+
const runs: Array<{ cursor: AtomCursor; lines: string[] }> = [];
|
|
643
|
+
for (const edit of fileInserts.sort((a, b) => a.index - b.index)) {
|
|
644
|
+
const prev = runs[runs.length - 1];
|
|
645
|
+
if (prev && isSameFileCursor(prev.cursor, edit.cursor)) {
|
|
646
|
+
prev.lines.push(edit.text);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
runs.push({ cursor: edit.cursor, lines: [edit.text] });
|
|
650
|
+
}
|
|
651
|
+
return runs;
|
|
652
|
+
}
|
|
653
|
+
function applyFileCursorInserts(
|
|
654
|
+
fileLines: string[],
|
|
655
|
+
fileInserts: Extract<AtomEdit, { kind: "insert" }>[],
|
|
656
|
+
): number | undefined {
|
|
657
|
+
let firstChangedLine: number | undefined;
|
|
658
|
+
const trackFirstChanged = (line: number) => {
|
|
659
|
+
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
for (const run of collectFileInsertRuns(fileInserts)) {
|
|
663
|
+
if (run.cursor.kind === "bof") {
|
|
664
|
+
insertAtStart(fileLines, run.lines);
|
|
665
|
+
trackFirstChanged(1);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (run.cursor.kind === "eof") {
|
|
669
|
+
const changedLine = insertAtEnd(fileLines, run.lines);
|
|
670
|
+
if (changedLine !== undefined) trackFirstChanged(changedLine);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return firstChangedLine;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
|
|
678
|
+
if (edit.kind !== "insert") return edit.anchor;
|
|
679
|
+
if (edit.cursor.kind !== "anchor") {
|
|
680
|
+
throw new Error("Internal atom error: file-scoped insert reached anchor application.");
|
|
681
|
+
}
|
|
682
|
+
return edit.cursor.anchor;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Heuristic: detect (and when safe, auto-fix) lines that became adjacent
|
|
686
|
+
// duplicates of themselves after the edit, when they were not adjacent
|
|
687
|
+
// duplicates before. This is the signature of a botched block rewrite that
|
|
688
|
+
// missed one delete on the front or back of the deletion range, leaving a
|
|
689
|
+
// stale copy of a line the agent already re-emitted (e.g. inserting a new
|
|
690
|
+
// closing `}` while the original `}` was never deleted, producing `}\n}`).
|
|
691
|
+
//
|
|
692
|
+
// Auto-fix is gated on bracket balance: we only remove the duplicate line if
|
|
693
|
+
// its removal restores the original file's `{}`/`()`/`[]` delta. That makes
|
|
694
|
+
// the fix safe in the common case (a stray closing brace shifts balance by
|
|
695
|
+
// one) and conservative when the duplicate is intentional (balance unchanged
|
|
696
|
+
// → warning only). When two adjacent lines are textually identical, removing
|
|
697
|
+
// either yields the same content, so we don't have to decide which is "the
|
|
698
|
+
// stale copy" — we just remove one and verify balance restores.
|
|
699
|
+
function detectAndAutoFixDuplicates(
|
|
700
|
+
originalLines: string[],
|
|
701
|
+
finalLines: string[],
|
|
702
|
+
): { fixed: string[] | null; warnings: string[] } {
|
|
703
|
+
const countAdjacent = (lines: string[]): Map<string, number> => {
|
|
704
|
+
const counts = new Map<string, number>();
|
|
705
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
706
|
+
if (lines[i] !== lines[i + 1]) continue;
|
|
707
|
+
if (lines[i].trim().length === 0) continue;
|
|
708
|
+
counts.set(lines[i], (counts.get(lines[i]) ?? 0) + 1);
|
|
709
|
+
}
|
|
710
|
+
return counts;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const computeBalance = (lines: string[]): { brace: number; paren: number; bracket: number } => {
|
|
714
|
+
let brace = 0;
|
|
715
|
+
let paren = 0;
|
|
716
|
+
let bracket = 0;
|
|
717
|
+
for (const line of lines) {
|
|
718
|
+
for (const ch of line) {
|
|
719
|
+
if (ch === "{") brace++;
|
|
720
|
+
else if (ch === "}") brace--;
|
|
721
|
+
else if (ch === "(") paren++;
|
|
722
|
+
else if (ch === ")") paren--;
|
|
723
|
+
else if (ch === "[") bracket++;
|
|
724
|
+
else if (ch === "]") bracket--;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return { brace, paren, bracket };
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const balancesEqual = (
|
|
731
|
+
a: { brace: number; paren: number; bracket: number },
|
|
732
|
+
b: { brace: number; paren: number; bracket: number },
|
|
733
|
+
): boolean => a.brace === b.brace && a.paren === b.paren && a.bracket === b.bracket;
|
|
734
|
+
|
|
735
|
+
const orig = countAdjacent(originalLines);
|
|
736
|
+
const fin = countAdjacent(finalLines);
|
|
737
|
+
const newDupPositions: number[] = [];
|
|
738
|
+
for (let i = 0; i + 1 < finalLines.length; i++) {
|
|
739
|
+
if (finalLines[i] !== finalLines[i + 1]) continue;
|
|
740
|
+
if (finalLines[i].trim().length === 0) continue;
|
|
741
|
+
const text = finalLines[i];
|
|
742
|
+
if ((fin.get(text) ?? 0) <= (orig.get(text) ?? 0)) continue;
|
|
743
|
+
newDupPositions.push(i);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (newDupPositions.length === 0) return { fixed: null, warnings: [] };
|
|
747
|
+
|
|
748
|
+
const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
|
|
749
|
+
|
|
750
|
+
// Auto-fix only when there is exactly one new adjacent duplicate AND the
|
|
751
|
+
// edit shifted bracket balance. Removing one of the two identical lines
|
|
752
|
+
// must restore the original delta exactly.
|
|
753
|
+
if (newDupPositions.length === 1) {
|
|
754
|
+
const pos = newDupPositions[0];
|
|
755
|
+
const origBalance = computeBalance(originalLines);
|
|
756
|
+
const finalBalance = computeBalance(finalLines);
|
|
757
|
+
if (!balancesEqual(origBalance, finalBalance)) {
|
|
758
|
+
const trial = finalLines.slice(0, pos).concat(finalLines.slice(pos + 1));
|
|
759
|
+
if (balancesEqual(computeBalance(trial), origBalance)) {
|
|
760
|
+
return {
|
|
761
|
+
fixed: trial,
|
|
762
|
+
warnings: [
|
|
763
|
+
`Auto-fixed: removed duplicate line ${pos + 1} (${formatPreview(finalLines[pos])}); the edit left two adjacent identical lines and bracket balance was off. Verify the result.`,
|
|
764
|
+
],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const warnings = newDupPositions.slice(0, 3).map(pos => {
|
|
771
|
+
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.`;
|
|
772
|
+
});
|
|
773
|
+
return { fixed: null, warnings };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult {
|
|
554
777
|
if (edits.length === 0) {
|
|
555
778
|
return { lines: text, firstChangedLine: undefined };
|
|
556
779
|
}
|
|
557
780
|
|
|
558
781
|
const fileLines = text.split("\n");
|
|
782
|
+
const originalLines = fileLines.slice();
|
|
559
783
|
const warnings: string[] = [];
|
|
560
784
|
let firstChangedLine: number | undefined;
|
|
561
785
|
const noopEdits: AtomNoopEdit[] = [];
|
|
@@ -564,55 +788,31 @@ export function applyAtomEdits(
|
|
|
564
788
|
if (mismatches.length > 0) {
|
|
565
789
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
566
790
|
}
|
|
567
|
-
|
|
568
|
-
// entries), the `del` is almost always a hallucinated cleanup the model added on top
|
|
569
|
-
// of the real replacement. Drop the `del` silently so the replacement wins, matching
|
|
570
|
-
// the in-entry handling for `splice: []` paired with `sed`.
|
|
571
|
-
const replacedLines = new Set<number>();
|
|
572
|
-
for (const e of edits) {
|
|
573
|
-
if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
|
|
574
|
-
}
|
|
575
|
-
let effective = edits;
|
|
576
|
-
if (replacedLines.size > 0) {
|
|
577
|
-
effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
|
|
578
|
-
}
|
|
579
|
-
validateNoConflictingAnchorOps(effective);
|
|
791
|
+
validateNoConflictingAtomMutations(edits);
|
|
580
792
|
|
|
581
793
|
const trackFirstChanged = (line: number) => {
|
|
582
|
-
if (firstChangedLine === undefined || line < firstChangedLine)
|
|
583
|
-
firstChangedLine = line;
|
|
584
|
-
}
|
|
794
|
+
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
585
795
|
};
|
|
586
796
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
596
|
-
effective.forEach((edit, idx) => {
|
|
597
|
-
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
598
|
-
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
599
|
-
else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
|
|
600
|
-
else anchorEdits.push({ edit, idx });
|
|
797
|
+
const anchorEdits: IndexedAnchorEdit[] = [];
|
|
798
|
+
const fileInserts: Extract<AtomEdit, { kind: "insert" }>[] = [];
|
|
799
|
+
edits.forEach((edit, idx) => {
|
|
800
|
+
if (edit.kind === "insert" && edit.cursor.kind !== "anchor") {
|
|
801
|
+
fileInserts.push(edit);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
anchorEdits.push({ edit, idx });
|
|
601
805
|
});
|
|
602
806
|
|
|
603
|
-
|
|
604
|
-
// single splice. This makes the per-anchor outcome independent of index
|
|
605
|
-
// shifts caused by sibling ops (e.g. `post` paired with `del` on the same
|
|
606
|
-
// anchor, or repeated `pre`/`post` inserts that previously reversed).
|
|
607
|
-
const byLine = new Map<number, Indexed<AnchorEdit>[]>();
|
|
807
|
+
const byLine = new Map<number, IndexedAnchorEdit[]>();
|
|
608
808
|
for (const entry of anchorEdits) {
|
|
609
|
-
const line = entry.edit.
|
|
610
|
-
|
|
611
|
-
if (
|
|
612
|
-
bucket
|
|
613
|
-
|
|
809
|
+
const line = getAnchorForAnchorEdit(entry.edit).line;
|
|
810
|
+
const bucket = byLine.get(line);
|
|
811
|
+
if (bucket) {
|
|
812
|
+
bucket.push(entry);
|
|
813
|
+
} else {
|
|
814
|
+
byLine.set(line, [entry]);
|
|
614
815
|
}
|
|
615
|
-
bucket.push(entry);
|
|
616
816
|
}
|
|
617
817
|
|
|
618
818
|
const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
|
|
@@ -626,237 +826,454 @@ export function applyAtomEdits(
|
|
|
626
826
|
let replacement: string[] = [currentLine];
|
|
627
827
|
let replacementSet = false;
|
|
628
828
|
let anchorMutated = false;
|
|
629
|
-
let anchorDeleted = false;
|
|
630
|
-
const beforeLines: string[] = [];
|
|
631
829
|
const afterLines: string[] = [];
|
|
632
830
|
|
|
633
831
|
for (const { edit } of bucket) {
|
|
634
|
-
switch (edit.
|
|
635
|
-
case "
|
|
636
|
-
|
|
637
|
-
break;
|
|
638
|
-
case "post":
|
|
639
|
-
afterLines.push(...edit.lines);
|
|
832
|
+
switch (edit.kind) {
|
|
833
|
+
case "insert":
|
|
834
|
+
afterLines.push(edit.text);
|
|
640
835
|
break;
|
|
641
|
-
case "
|
|
642
|
-
replacement = [];
|
|
643
|
-
replacementSet = true;
|
|
644
|
-
anchorDeleted = true;
|
|
645
|
-
break;
|
|
646
|
-
case "splice":
|
|
647
|
-
replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
|
|
836
|
+
case "set":
|
|
837
|
+
replacement = [edit.allowOldNewRepair ? repairAtomOldNewSetLine(currentLine, edit.text) : edit.text];
|
|
648
838
|
replacementSet = true;
|
|
649
839
|
anchorMutated = true;
|
|
650
840
|
break;
|
|
651
|
-
case "
|
|
652
|
-
|
|
653
|
-
const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
|
|
654
|
-
if (error) {
|
|
655
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
|
|
656
|
-
}
|
|
657
|
-
if (!matched) {
|
|
841
|
+
case "delete":
|
|
842
|
+
if (edit.oldAssertion !== undefined && edit.oldAssertion !== currentLine) {
|
|
658
843
|
throw new Error(
|
|
659
|
-
`
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
if (literalFallback) {
|
|
663
|
-
warnings.push(
|
|
664
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
|
|
844
|
+
`Diff line ${edit.lineNum}: \`-${edit.anchor.line}${edit.anchor.hash}\` asserts the deleted line is ${JSON.stringify(edit.oldAssertion)}, but the file has ${JSON.stringify(currentLine)}. Re-anchor and retry.`,
|
|
665
845
|
);
|
|
666
846
|
}
|
|
667
|
-
replacement = [
|
|
847
|
+
replacement = [];
|
|
668
848
|
replacementSet = true;
|
|
669
849
|
anchorMutated = true;
|
|
670
850
|
break;
|
|
671
|
-
}
|
|
672
851
|
}
|
|
673
852
|
}
|
|
674
853
|
|
|
675
|
-
const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
|
|
676
|
-
if (noOp) continue;
|
|
677
|
-
|
|
678
|
-
const originalLine = fileLines[idx];
|
|
679
854
|
const replacementProducesNoChange =
|
|
680
|
-
|
|
681
|
-
afterLines.length === 0 &&
|
|
682
|
-
replacement.length === 1 &&
|
|
683
|
-
replacement[0] === originalLine;
|
|
855
|
+
afterLines.length === 0 && replacement.length === 1 && replacement[0] === currentLine;
|
|
684
856
|
if (replacementProducesNoChange) {
|
|
685
857
|
const firstEdit = bucket[0]?.edit;
|
|
686
|
-
const
|
|
687
|
-
const reason = "replacement is identical to the current line content";
|
|
858
|
+
const anchor = firstEdit ? getAnchorForAnchorEdit(firstEdit) : undefined;
|
|
688
859
|
noopEdits.push({
|
|
689
860
|
editIndex: bucket[0]?.idx ?? 0,
|
|
690
|
-
loc
|
|
691
|
-
reason
|
|
692
|
-
|
|
861
|
+
loc: anchor ? `${anchor.line}${anchor.hash}` : `${line}`,
|
|
862
|
+
reason:
|
|
863
|
+
firstEdit?.kind === "set"
|
|
864
|
+
? "replacement is identical to the current line content; use `Lid=NEW_TEXT` and do not copy an unchanged read line"
|
|
865
|
+
: "replacement is identical to the current line content",
|
|
866
|
+
current: currentLine,
|
|
693
867
|
});
|
|
694
868
|
continue;
|
|
695
869
|
}
|
|
696
870
|
|
|
697
|
-
const combined = [...
|
|
871
|
+
const combined = [...replacement, ...afterLines];
|
|
698
872
|
fileLines.splice(idx, 1, ...combined);
|
|
699
|
-
|
|
700
|
-
if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
|
|
873
|
+
if (anchorMutated) {
|
|
701
874
|
trackFirstChanged(line);
|
|
702
875
|
} else if (afterLines.length > 0) {
|
|
703
876
|
trackFirstChanged(line + 1);
|
|
704
877
|
}
|
|
878
|
+
if (!replacementSet && afterLines.length === 0) continue;
|
|
705
879
|
}
|
|
706
880
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
prependEdits.sort((a, b) => a.idx - b.idx);
|
|
710
|
-
for (const { edit } of prependEdits) {
|
|
711
|
-
if (edit.lines.length === 0) continue;
|
|
712
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
713
|
-
fileLines.splice(0, 1, ...edit.lines);
|
|
714
|
-
} else {
|
|
715
|
-
// Insert in reverse cumulative order so later splices push earlier
|
|
716
|
-
// content further down, preserving the original op order.
|
|
717
|
-
fileLines.splice(0, 0, ...edit.lines);
|
|
718
|
-
}
|
|
719
|
-
trackFirstChanged(1);
|
|
720
|
-
}
|
|
881
|
+
const fileFirstChangedLine = applyFileCursorInserts(fileLines, fileInserts);
|
|
882
|
+
if (fileFirstChangedLine !== undefined) trackFirstChanged(fileFirstChangedLine);
|
|
721
883
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
for (const { edit } of appendEdits) {
|
|
727
|
-
if (edit.lines.length === 0) continue;
|
|
728
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
729
|
-
fileLines.splice(0, 1, ...edit.lines);
|
|
730
|
-
trackFirstChanged(1);
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
|
734
|
-
const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
735
|
-
fileLines.splice(insertIdx, 0, ...edit.lines);
|
|
736
|
-
trackFirstChanged(insertIdx + 1);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// Apply sed_file ops last so they observe the post-anchor / post-prepend /
|
|
740
|
-
// post-append state of the file. Each op runs across every content line and
|
|
741
|
-
let warnedLiteralFallback = false;
|
|
742
|
-
sedFileEdits.sort((a, b) => a.idx - b.idx);
|
|
743
|
-
for (const { edit } of sedFileEdits) {
|
|
744
|
-
const hasTrailingNewline = fileLines.length > 1 && fileLines[fileLines.length - 1] === "";
|
|
745
|
-
const upper = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
746
|
-
let anyMatched = false;
|
|
747
|
-
let lastCompileError: string | undefined;
|
|
748
|
-
for (let i = 0; i < upper; i++) {
|
|
749
|
-
const line = fileLines[i] ?? "";
|
|
750
|
-
const r = applySedToLine(line, edit.spec);
|
|
751
|
-
if (r.error) lastCompileError = r.error;
|
|
752
|
-
if (!r.matched) continue;
|
|
753
|
-
anyMatched = true;
|
|
754
|
-
if (r.literalFallback && !warnedLiteralFallback) {
|
|
755
|
-
warnings.push(
|
|
756
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
|
|
757
|
-
);
|
|
758
|
-
warnedLiteralFallback = true;
|
|
759
|
-
}
|
|
760
|
-
if (r.result !== line) {
|
|
761
|
-
fileLines[i] = r.result;
|
|
762
|
-
trackFirstChanged(i + 1);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
if (!anyMatched) {
|
|
766
|
-
if (lastCompileError !== undefined) {
|
|
767
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${lastCompileError}`);
|
|
768
|
-
}
|
|
769
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
|
|
770
|
-
}
|
|
884
|
+
const dupCheck = detectAndAutoFixDuplicates(originalLines, fileLines);
|
|
885
|
+
if (dupCheck.fixed !== null) {
|
|
886
|
+
fileLines.length = 0;
|
|
887
|
+
fileLines.push(...dupCheck.fixed);
|
|
771
888
|
}
|
|
889
|
+
for (const w of dupCheck.warnings) warnings.push(w);
|
|
772
890
|
|
|
773
891
|
return {
|
|
774
892
|
lines: fileLines.join("\n"),
|
|
775
893
|
firstChangedLine,
|
|
776
894
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
777
|
-
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
|
895
|
+
...(noopEdits.length > 0 && firstChangedLine === undefined ? { noopEdits } : {}),
|
|
778
896
|
};
|
|
779
897
|
}
|
|
780
898
|
|
|
781
899
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
900
|
+
// Wire-format split: extract `---` headers from the input string.
|
|
901
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
902
|
+
|
|
903
|
+
const FILE_HEADER_PREFIX = "---";
|
|
904
|
+
const REMOVE_FILE_OPERATION = "!rm";
|
|
905
|
+
const MOVE_FILE_OPERATION = "!mv";
|
|
906
|
+
|
|
907
|
+
type AtomWholeFileOperation =
|
|
908
|
+
| { kind: "delete"; lineNum: number }
|
|
909
|
+
| { kind: "move"; destination: string; lineNum: number };
|
|
910
|
+
|
|
911
|
+
interface AtomInputSection {
|
|
912
|
+
path: string;
|
|
913
|
+
diff: string;
|
|
914
|
+
wholeFileOperation?: AtomWholeFileOperation;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export interface SplitAtomOptions {
|
|
918
|
+
cwd?: string;
|
|
919
|
+
path?: string;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function isBlankHeaderPreamble(line: string): boolean {
|
|
923
|
+
return line.replace(/\r$/, "").trim().length === 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function unquoteAtomPath(pathText: string): string {
|
|
927
|
+
if (pathText.length < 2) return pathText;
|
|
928
|
+
const first = pathText[0];
|
|
929
|
+
const last = pathText[pathText.length - 1];
|
|
930
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
931
|
+
return pathText.slice(1, -1);
|
|
932
|
+
}
|
|
933
|
+
return pathText;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function normalizeAtomPath(rawPath: string, cwd?: string): string {
|
|
937
|
+
const unquoted = unquoteAtomPath(rawPath.trim());
|
|
938
|
+
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
|
939
|
+
|
|
940
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
|
941
|
+
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
942
|
+
return isWithinCwd ? relative || "." : unquoted;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function parseAtomHeaderLine(line: string, cwd?: string): string | null {
|
|
946
|
+
if (!line.startsWith(FILE_HEADER_PREFIX)) return null;
|
|
947
|
+
let body = line.slice(FILE_HEADER_PREFIX.length);
|
|
948
|
+
if (body.startsWith(" ")) body = body.slice(1);
|
|
949
|
+
const parsedPath = normalizeAtomPath(body, cwd);
|
|
950
|
+
if (parsedPath.length === 0) {
|
|
951
|
+
throw new Error(`atom input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
952
|
+
}
|
|
953
|
+
return parsedPath;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function parseSingleAtomPathArgument(rawPath: string, directive: string, lineNum: number, cwd?: string): string {
|
|
957
|
+
const trimmed = rawPath.trim();
|
|
958
|
+
if (trimmed.length === 0) {
|
|
959
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const quote = trimmed[0];
|
|
963
|
+
if (quote === '"' || quote === "'") {
|
|
964
|
+
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== quote) {
|
|
965
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
966
|
+
}
|
|
967
|
+
} else if (/\s/.test(trimmed)) {
|
|
968
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one destination path.`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const destination = normalizeAtomPath(trimmed, cwd);
|
|
972
|
+
if (destination.length === 0) {
|
|
973
|
+
throw new Error(`Atom line ${lineNum}: ${directive} requires exactly one non-empty destination path.`);
|
|
974
|
+
}
|
|
975
|
+
return destination;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function parseAtomWholeFileOperationLine(
|
|
979
|
+
rawLine: string,
|
|
980
|
+
lineNum: number,
|
|
981
|
+
cwd?: string,
|
|
982
|
+
): AtomWholeFileOperation | null {
|
|
983
|
+
const line = rawLine.replace(/\r$/, "").trimEnd();
|
|
984
|
+
if (line === REMOVE_FILE_OPERATION) {
|
|
985
|
+
return { kind: "delete", lineNum };
|
|
986
|
+
}
|
|
987
|
+
if (line.startsWith(`${REMOVE_FILE_OPERATION} `) || line.startsWith(`${REMOVE_FILE_OPERATION}\t`)) {
|
|
988
|
+
throw new Error(`Atom line ${lineNum}: ${REMOVE_FILE_OPERATION} does not take a destination path.`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (line === MOVE_FILE_OPERATION) {
|
|
992
|
+
throw new Error(`Atom line ${lineNum}: ${MOVE_FILE_OPERATION} requires exactly one non-empty destination path.`);
|
|
993
|
+
}
|
|
994
|
+
if (line.startsWith(`${MOVE_FILE_OPERATION} `) || line.startsWith(`${MOVE_FILE_OPERATION}\t`)) {
|
|
995
|
+
const rawDestination = line.slice(MOVE_FILE_OPERATION.length);
|
|
996
|
+
return {
|
|
997
|
+
kind: "move",
|
|
998
|
+
destination: parseSingleAtomPathArgument(rawDestination, MOVE_FILE_OPERATION, lineNum, cwd),
|
|
999
|
+
lineNum,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function getAtomWholeFileOperation(
|
|
1007
|
+
sectionPath: string,
|
|
1008
|
+
lines: string[],
|
|
1009
|
+
cwd?: string,
|
|
1010
|
+
): AtomWholeFileOperation | undefined {
|
|
1011
|
+
let operation: AtomWholeFileOperation | undefined;
|
|
1012
|
+
let operationToken = "";
|
|
1013
|
+
let hasLineEdit = false;
|
|
1014
|
+
|
|
1015
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1016
|
+
const lineNum = i + 1;
|
|
1017
|
+
const line = lines[i].replace(/\r$/, "");
|
|
1018
|
+
if (line.trim().length === 0) continue;
|
|
1019
|
+
|
|
1020
|
+
const parsed = parseAtomWholeFileOperationLine(line, lineNum, cwd);
|
|
1021
|
+
if (parsed) {
|
|
1022
|
+
if (operation) {
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`Atom section ${sectionPath}: use only one ${REMOVE_FILE_OPERATION} or ${MOVE_FILE_OPERATION} operation.`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
operation = parsed;
|
|
1028
|
+
operationToken = parsed.kind === "delete" ? REMOVE_FILE_OPERATION : MOVE_FILE_OPERATION;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
hasLineEdit = true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (operation && hasLineEdit) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
`Atom section ${sectionPath} mixes ${operationToken} with line edits; ${REMOVE_FILE_OPERATION} and ${MOVE_FILE_OPERATION} must be the only operation in their section.`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return operation;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function hasAtomHeaderLine(input: string): boolean {
|
|
1045
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1046
|
+
return stripped.split("\n").some(rawLine => rawLine.replace(/\r$/, "").startsWith(FILE_HEADER_PREFIX));
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function containsRecognizableAtomOperations(input: string): boolean {
|
|
1050
|
+
for (const rawLine of input.split("\n")) {
|
|
1051
|
+
const line = rawLine.replace(/\r$/, "");
|
|
1052
|
+
if (line.length === 0) continue;
|
|
1053
|
+
if (line[0] === "+") return true;
|
|
1054
|
+
if (line === "$" || line === "^") return true;
|
|
1055
|
+
if (/^- ?[1-9]\d*[a-z]{2}(?: .*)?$/.test(line)) return true;
|
|
1056
|
+
if (/^@?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?$/.test(line)) return true;
|
|
1057
|
+
if (/^@@ (?:BOF|EOF|(?:- ?)?[1-9]\d*[a-z]{2}(?:[ \t]*[=|].*)?)$/.test(line)) return true;
|
|
1058
|
+
}
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function stripLeadingBlankLines(input: string): string {
|
|
1063
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
1064
|
+
const lines = stripped.split("\n");
|
|
1065
|
+
while (lines.length > 0 && isBlankHeaderPreamble(lines[0] ?? "")) {
|
|
1066
|
+
lines.shift();
|
|
1067
|
+
}
|
|
1068
|
+
return lines.join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function normalizeFallbackInput(input: string, options: SplitAtomOptions): string {
|
|
1072
|
+
if (hasAtomHeaderLine(input) || !options.path || !containsRecognizableAtomOperations(input)) {
|
|
1073
|
+
return input;
|
|
1074
|
+
}
|
|
1075
|
+
const fallbackPath = normalizeAtomPath(options.path, options.cwd);
|
|
1076
|
+
if (fallbackPath.length === 0) return input;
|
|
1077
|
+
return `${FILE_HEADER_PREFIX}${fallbackPath}\n${input}`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function getTextContent(result: AgentToolResult<EditToolDetails>): string {
|
|
1081
|
+
return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
|
|
1085
|
+
if (result.details === undefined) {
|
|
1086
|
+
return { diff: "" };
|
|
1087
|
+
}
|
|
1088
|
+
return result.details;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Split the wire-format `input` string into `{ path, diff }`. The first
|
|
1093
|
+
* non-empty line MUST be `---<path>` or `--- <path>`. Tolerates a leading BOM.
|
|
1094
|
+
*/
|
|
1095
|
+
export function splitAtomInput(input: string, options: SplitAtomOptions = {}): { path: string; diff: string } {
|
|
1096
|
+
const [section] = splitAtomInputs(input, options);
|
|
1097
|
+
return section;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function splitAtomInputs(input: string, options: SplitAtomOptions = {}): AtomInputSection[] {
|
|
1101
|
+
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
|
|
1102
|
+
const lines = stripped.split("\n");
|
|
1103
|
+
const firstLine = (lines[0] ?? "").replace(/\r$/, "");
|
|
1104
|
+
if (!firstLine.startsWith(FILE_HEADER_PREFIX)) {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
`atom input must begin with "${FILE_HEADER_PREFIX}<path>" on the first non-blank line; got: ${JSON.stringify(
|
|
1107
|
+
firstLine.slice(0, 120),
|
|
1108
|
+
)}`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const sections: AtomInputSection[] = [];
|
|
1113
|
+
let currentPath = "";
|
|
1114
|
+
let currentLines: string[] = [];
|
|
1115
|
+
const flush = () => {
|
|
1116
|
+
if (currentPath.length === 0) return;
|
|
1117
|
+
const wholeFileOperation = getAtomWholeFileOperation(currentPath, currentLines, options.cwd);
|
|
1118
|
+
sections.push({
|
|
1119
|
+
path: currentPath,
|
|
1120
|
+
diff: currentLines.join("\n"),
|
|
1121
|
+
...(wholeFileOperation ? { wholeFileOperation } : {}),
|
|
1122
|
+
});
|
|
1123
|
+
currentLines = [];
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
for (const rawLine of lines) {
|
|
1127
|
+
const line = rawLine.replace(/\r$/, "");
|
|
1128
|
+
const headerPath = parseAtomHeaderLine(line, options.cwd);
|
|
1129
|
+
if (headerPath !== null) {
|
|
1130
|
+
flush();
|
|
1131
|
+
currentPath = headerPath;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
currentLines.push(rawLine);
|
|
1135
|
+
}
|
|
1136
|
+
flush();
|
|
1137
|
+
return sections;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
782
1141
|
// Executor
|
|
783
1142
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
784
1143
|
|
|
785
1144
|
export interface ExecuteAtomSingleOptions {
|
|
786
1145
|
session: ToolSession;
|
|
787
|
-
|
|
788
|
-
|
|
1146
|
+
input: string;
|
|
1147
|
+
path?: string;
|
|
789
1148
|
signal?: AbortSignal;
|
|
790
1149
|
batchRequest?: LspBatchRequest;
|
|
791
1150
|
writethrough: WritethroughCallback;
|
|
792
1151
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
793
1152
|
}
|
|
794
1153
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1154
|
+
interface ReadAtomFileResult {
|
|
1155
|
+
exists: boolean;
|
|
1156
|
+
rawContent: string;
|
|
1157
|
+
}
|
|
799
1158
|
|
|
800
|
-
|
|
1159
|
+
async function readAtomFile(absolutePath: string): Promise<ReadAtomFileResult> {
|
|
1160
|
+
try {
|
|
1161
|
+
return { exists: true, rawContent: await Bun.file(absolutePath).text() };
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (isEnoent(error)) return { exists: false, rawContent: "" };
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
801
1167
|
|
|
802
|
-
|
|
1168
|
+
function hasAnchorScopedEdit(edits: AtomEdit[]): boolean {
|
|
1169
|
+
return edits.some(edit => edit.kind === "set" || edit.kind === "delete" || edit.cursor.kind === "anchor");
|
|
1170
|
+
}
|
|
803
1171
|
|
|
804
|
-
|
|
805
|
-
|
|
1172
|
+
function formatNoChangeDiagnostic(path: string, result: AtomApplyResult): string {
|
|
1173
|
+
let diagnostic = `Edits to ${path} resulted in no changes being made.`;
|
|
1174
|
+
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
1175
|
+
const details = result.noopEdits
|
|
1176
|
+
.map(e => {
|
|
1177
|
+
const preview =
|
|
1178
|
+
e.current.length > 0
|
|
1179
|
+
? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
|
|
1180
|
+
: "";
|
|
1181
|
+
return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
|
|
1182
|
+
})
|
|
1183
|
+
.join("\n");
|
|
1184
|
+
diagnostic += `\n${details}`;
|
|
806
1185
|
}
|
|
1186
|
+
return diagnostic;
|
|
1187
|
+
}
|
|
807
1188
|
|
|
808
|
-
|
|
1189
|
+
async function executeAtomWholeFileOperation(
|
|
1190
|
+
options: ExecuteAtomSingleOptions & AtomInputSection & { wholeFileOperation: AtomWholeFileOperation },
|
|
1191
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
1192
|
+
const { session, path: sectionPath, wholeFileOperation } = options;
|
|
1193
|
+
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
809
1194
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
if (!sourceExists) {
|
|
814
|
-
const lines: string[] = [];
|
|
815
|
-
for (const edit of contentEdits) {
|
|
816
|
-
if (edit.op === "append_file") {
|
|
817
|
-
lines.push(...edit.lines);
|
|
818
|
-
} else if (edit.op === "prepend_file") {
|
|
819
|
-
lines.unshift(...edit.lines);
|
|
820
|
-
} else {
|
|
821
|
-
throw new Error(`File not found: ${path}`);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
1195
|
+
if (sectionPath.endsWith(".ipynb")) {
|
|
1196
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1197
|
+
}
|
|
824
1198
|
|
|
825
|
-
|
|
826
|
-
|
|
1199
|
+
if (wholeFileOperation.kind === "delete") {
|
|
1200
|
+
enforcePlanModeWrite(session, sectionPath, { op: "delete" });
|
|
1201
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1202
|
+
try {
|
|
1203
|
+
await fs.unlink(absolutePath);
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
|
|
1206
|
+
throw error;
|
|
1207
|
+
}
|
|
1208
|
+
invalidateFsScanAfterDelete(absolutePath);
|
|
827
1209
|
return {
|
|
828
|
-
content: [{ type: "text", text: `
|
|
829
|
-
details: {
|
|
830
|
-
diff: "",
|
|
831
|
-
op: "create",
|
|
832
|
-
meta: outputMeta().get(),
|
|
833
|
-
},
|
|
1210
|
+
content: [{ type: "text", text: `Deleted ${sectionPath}` }],
|
|
1211
|
+
details: { diff: "", op: "delete", meta: outputMeta().get() },
|
|
834
1212
|
};
|
|
835
1213
|
}
|
|
836
1214
|
|
|
837
|
-
const
|
|
838
|
-
|
|
1215
|
+
const destinationPath = wholeFileOperation.destination;
|
|
1216
|
+
if (destinationPath.endsWith(".ipynb")) {
|
|
1217
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
enforcePlanModeWrite(session, sectionPath, { op: "update", move: destinationPath });
|
|
1221
|
+
const absoluteDestinationPath = resolvePlanPath(session, destinationPath);
|
|
1222
|
+
if (absoluteDestinationPath === absolutePath) {
|
|
1223
|
+
throw new Error("rename path is the same as source path");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
await assertEditableFile(absolutePath, sectionPath);
|
|
1227
|
+
try {
|
|
1228
|
+
await fs.mkdir(path.dirname(absoluteDestinationPath), { recursive: true });
|
|
1229
|
+
await fs.rename(absolutePath, absoluteDestinationPath);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
if (isEnoent(error)) throw new Error(`File not found: ${sectionPath}`);
|
|
1232
|
+
throw error;
|
|
1233
|
+
}
|
|
1234
|
+
invalidateFsScanAfterRename(absolutePath, absoluteDestinationPath);
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
content: [{ type: "text", text: `Moved ${sectionPath} to ${destinationPath}` }],
|
|
1238
|
+
details: { diff: "", op: "update", move: destinationPath, meta: outputMeta().get() },
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function executeAtomSection(
|
|
1243
|
+
options: ExecuteAtomSingleOptions & AtomInputSection,
|
|
1244
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
1245
|
+
const { session, path, diff, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
1246
|
+
if (options.wholeFileOperation) {
|
|
1247
|
+
return executeAtomWholeFileOperation({ ...options, wholeFileOperation: options.wholeFileOperation });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const edits = parseAtom(diff);
|
|
1251
|
+
if (edits.length === 0 && diff.trim().length > 0) {
|
|
1252
|
+
throw new Error(formatNoAtomEditDiagnostic(path, diff));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
enforcePlanModeWrite(session, path, { op: "update" });
|
|
1256
|
+
|
|
1257
|
+
if (path.endsWith(".ipynb") && edits.length > 0) {
|
|
1258
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const absolutePath = resolvePlanPath(session, path);
|
|
1262
|
+
const source = await readAtomFile(absolutePath);
|
|
1263
|
+
if (!source.exists && hasAnchorScopedEdit(edits)) {
|
|
1264
|
+
throw new Error(`File not found: ${path}`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (source.exists) {
|
|
1268
|
+
assertEditableFileContent(source.rawContent, path);
|
|
1269
|
+
}
|
|
839
1270
|
|
|
840
|
-
const { bom, text } = stripBom(rawContent);
|
|
1271
|
+
const { bom, text } = stripBom(source.rawContent);
|
|
841
1272
|
const originalEnding = detectLineEnding(text);
|
|
842
1273
|
const originalNormalized = normalizeToLF(text);
|
|
843
|
-
|
|
844
|
-
const result = applyAtomEdits(originalNormalized, contentEdits);
|
|
1274
|
+
const result = applyAtomEdits(originalNormalized, edits);
|
|
845
1275
|
if (originalNormalized === result.lines) {
|
|
846
|
-
|
|
847
|
-
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
848
|
-
const details = result.noopEdits
|
|
849
|
-
.map(e => {
|
|
850
|
-
const preview =
|
|
851
|
-
e.current.length > 0
|
|
852
|
-
? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
|
|
853
|
-
: "";
|
|
854
|
-
return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
|
|
855
|
-
})
|
|
856
|
-
.join("\n");
|
|
857
|
-
diagnostic += `\n${details}`;
|
|
858
|
-
}
|
|
859
|
-
throw new Error(diagnostic);
|
|
1276
|
+
throw new Error(formatNoChangeDiagnostic(path, result));
|
|
860
1277
|
}
|
|
861
1278
|
|
|
862
1279
|
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
|
@@ -874,31 +1291,66 @@ export async function executeAtomSingle(
|
|
|
874
1291
|
const meta = outputMeta()
|
|
875
1292
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
876
1293
|
.get();
|
|
877
|
-
|
|
878
|
-
const resultText = `Updated ${path}`;
|
|
879
1294
|
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
880
|
-
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
|
|
881
|
-
preview.preview ? "" : " (no textual diff preview)"
|
|
882
|
-
}`;
|
|
883
1295
|
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
884
|
-
const previewBlock = preview.preview ? `\n
|
|
1296
|
+
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
1297
|
+
const resultText = preview.preview ? `${path}:` : source.exists ? `Updated ${path}` : `Created ${path}`;
|
|
885
1298
|
|
|
886
1299
|
return {
|
|
887
1300
|
content: [
|
|
888
1301
|
{
|
|
889
1302
|
type: "text",
|
|
890
|
-
text: `${resultText}
|
|
1303
|
+
text: `${resultText}${previewBlock}${warningsBlock}`,
|
|
891
1304
|
},
|
|
892
1305
|
],
|
|
893
1306
|
details: {
|
|
894
1307
|
diff: diffResult.diff,
|
|
895
1308
|
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
896
1309
|
diagnostics,
|
|
897
|
-
op: "update",
|
|
1310
|
+
op: source.exists ? "update" : "create",
|
|
898
1311
|
meta,
|
|
899
1312
|
},
|
|
900
1313
|
};
|
|
901
1314
|
}
|
|
902
1315
|
|
|
903
|
-
|
|
904
|
-
|
|
1316
|
+
export async function executeAtomSingle(
|
|
1317
|
+
options: ExecuteAtomSingleOptions,
|
|
1318
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
1319
|
+
const sections = splitAtomInputs(options.input, { cwd: options.session.cwd, path: options.path });
|
|
1320
|
+
if (sections.length === 1) {
|
|
1321
|
+
const [section] = sections;
|
|
1322
|
+
return executeAtomSection({ ...options, ...section });
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const results = [];
|
|
1326
|
+
for (const section of sections) {
|
|
1327
|
+
results.push({
|
|
1328
|
+
path: section.path,
|
|
1329
|
+
result: await executeAtomSection({ ...options, ...section }),
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
return {
|
|
1334
|
+
content: [
|
|
1335
|
+
{
|
|
1336
|
+
type: "text",
|
|
1337
|
+
text: results.map(({ result }) => getTextContent(result)).join("\n\n"),
|
|
1338
|
+
},
|
|
1339
|
+
],
|
|
1340
|
+
details: {
|
|
1341
|
+
diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
|
|
1342
|
+
perFileResults: results.map(({ path, result }) => {
|
|
1343
|
+
const details = getEditDetails(result);
|
|
1344
|
+
return {
|
|
1345
|
+
path,
|
|
1346
|
+
diff: details.diff,
|
|
1347
|
+
firstChangedLine: details.firstChangedLine,
|
|
1348
|
+
diagnostics: details.diagnostics,
|
|
1349
|
+
op: details.op,
|
|
1350
|
+
move: details.move,
|
|
1351
|
+
meta: details.meta,
|
|
1352
|
+
};
|
|
1353
|
+
}),
|
|
1354
|
+
},
|
|
1355
|
+
};
|
|
1356
|
+
}
|