@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.3
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 +43 -0
- package/package.json +8 -8
- package/src/config/prompt-templates.ts +6 -3
- package/src/edit/block.ts +308 -0
- package/src/edit/indent.ts +150 -0
- package/src/edit/modes/atom.ts +341 -114
- package/src/lsp/utils.ts +6 -36
- package/src/modes/components/status-line.ts +36 -0
- package/src/modes/controllers/event-controller.ts +27 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/atom.md +64 -86
- package/src/prompts/tools/read.md +1 -1
- package/src/session/agent-session.ts +28 -1
- package/src/tools/ast-edit.ts +23 -44
- package/src/tools/ast-grep.ts +18 -42
- package/src/tools/checkpoint.ts +2 -0
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/grep.ts +11 -46
- package/src/tools/grouped-file-output.ts +96 -0
- package/src/tools/read.ts +6 -0
- package/src/tools/report-tool-issue.ts +1 -0
- package/src/tools/resolve.ts +2 -0
- package/src/tools/review.ts +1 -0
- package/src/tools/todo-write.ts +1 -1
- package/src/tools/yield.ts +1 -0
- package/src/utils/tool-choice.ts +6 -1
package/src/edit/modes/atom.ts
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
*
|
|
3
3
|
* Flat locator + verb edit mode backed by hashline anchors. Each entry carries
|
|
4
|
-
* one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
|
|
4
|
+
* one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`, `sed`).
|
|
5
5
|
* The runtime resolves those verbs into internal anchor-scoped edits and still
|
|
6
6
|
* reuses hashline's staleness scheme (`computeLineHash`) verbatim.
|
|
7
7
|
*
|
|
8
8
|
* External shapes (one entry):
|
|
9
|
-
* { path, loc: "5th", splice: ["..."] }
|
|
10
|
-
* { path, loc: "5th",
|
|
11
|
-
* { path, loc: "5th",
|
|
12
|
-
* { path, loc: "5th",
|
|
13
|
-
* { path, loc: "
|
|
14
|
-
* { path, loc: "
|
|
15
|
-
* { path, loc: "$",
|
|
9
|
+
* { path, loc: "5th", splice: ["..."] } // line replace
|
|
10
|
+
* { path, loc: "(5th)", splice: ["..."] } // block body replace
|
|
11
|
+
* { path, loc: "[5th]", splice: ["..."] } // whole node replace
|
|
12
|
+
* { path, loc: "[5th", splice: ["..."] } // anchor (incl) → closer-1
|
|
13
|
+
* { path, loc: "5th]", splice: ["..."] } // opener+1 → anchor (incl)
|
|
14
|
+
* { path, loc: "5th", pre: [...], splice: [...], post: [...] } // line verbs combinable
|
|
15
|
+
* { path, loc: "$", pre: [...] | post: [...] | sed: {...} } // file-scoped
|
|
16
16
|
*
|
|
17
|
-
* `splice: []`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* `splice: []` deletes; `splice: [""]` replaces with a single blank line. These
|
|
18
|
+
* apply uniformly to single-line and bracketed (region) locators.
|
|
19
|
+
*
|
|
20
|
+
* Bracket forms in `loc` are reserved for `splice` (region replacement). `pre`,
|
|
21
|
+
* `post`, and `sed` reject bracketed locators — they are line-only.
|
|
20
22
|
*
|
|
21
23
|
* For deleting or moving files, the agent should use bash.
|
|
22
24
|
*/
|
|
@@ -29,7 +31,9 @@ import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
|
29
31
|
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
30
32
|
import { outputMeta } from "../../tools/output-meta";
|
|
31
33
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
34
|
+
import { checkBodyBraceBalance, type DelimiterKind, findEnclosingBlock } from "../block";
|
|
32
35
|
import { generateDiffString } from "../diff";
|
|
36
|
+
import { applyIndent, detectIndentStyle, stripCommonIndent } from "../indent";
|
|
33
37
|
import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
34
38
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
35
39
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
@@ -59,17 +63,23 @@ const textSchema = Type.Array(Type.String());
|
|
|
59
63
|
export const atomEditSchema = Type.Object(
|
|
60
64
|
{
|
|
61
65
|
loc: Type.String({
|
|
62
|
-
description:
|
|
66
|
+
description: "edit location",
|
|
63
67
|
examples: ["1ab", "$", "src/foo.ts:1ab"],
|
|
64
68
|
}),
|
|
65
69
|
splice: Type.Optional(textSchema),
|
|
66
70
|
pre: Type.Optional(textSchema),
|
|
67
71
|
post: Type.Optional(textSchema),
|
|
68
72
|
sed: Type.Optional(
|
|
69
|
-
Type.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
Type.Object(
|
|
74
|
+
{
|
|
75
|
+
pat: Type.String({ description: "regex" }),
|
|
76
|
+
rep: Type.String({ description: "expression to replace with" }),
|
|
77
|
+
g: Type.Optional(Type.Boolean({ description: "global flag", default: false })),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
),
|
|
73
83
|
),
|
|
74
84
|
},
|
|
75
85
|
{ additionalProperties: false },
|
|
@@ -98,14 +108,41 @@ export type AtomEdit =
|
|
|
98
108
|
| { op: "append_file"; lines: string[] }
|
|
99
109
|
| { op: "prepend_file"; lines: string[] }
|
|
100
110
|
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
|
|
101
|
-
| { op: "sed_file"; spec: SedSpec; expression: string }
|
|
111
|
+
| { op: "sed_file"; spec: SedSpec; expression: string }
|
|
112
|
+
| { op: "splice_block"; pos: Anchor; spec: SpliceBlockSpec; bracket: BracketShape };
|
|
102
113
|
|
|
103
114
|
export interface SedSpec {
|
|
104
115
|
pattern: string;
|
|
105
116
|
replacement: string;
|
|
106
117
|
global: boolean;
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SpliceBlockSpec {
|
|
121
|
+
body: string[];
|
|
122
|
+
kind: DelimiterKind;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type BracketShape = "none" | "body" | "node" | "left_incl" | "left_excl" | "right_incl" | "right_excl";
|
|
126
|
+
|
|
127
|
+
// File-extension lookup for the block delimiter family used when `loc`
|
|
128
|
+
// has bracket forms. Most languages are brace-family; lisp-family uses `(`.
|
|
129
|
+
// Anything not listed defaults to `{` (covers the long tail of brace-style
|
|
130
|
+
// languages without enumerating every extension).
|
|
131
|
+
const LISP_EXTENSIONS = new Set(["clj", "cljs", "cljc", "edn", "lisp", "lsp", "el", "scm", "ss", "rkt", "fnl"]);
|
|
132
|
+
|
|
133
|
+
function fileExtension(path: string | undefined): string | undefined {
|
|
134
|
+
if (!path) return undefined;
|
|
135
|
+
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
136
|
+
const base = slash >= 0 ? path.slice(slash + 1) : path;
|
|
137
|
+
const dot = base.lastIndexOf(".");
|
|
138
|
+
if (dot <= 0) return undefined;
|
|
139
|
+
return base.slice(dot + 1).toLowerCase();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveBlockDelimiterForPath(path: string | undefined): DelimiterKind {
|
|
143
|
+
const ext = fileExtension(path);
|
|
144
|
+
if (ext && LISP_EXTENSIONS.has(ext)) return "(";
|
|
145
|
+
return "{";
|
|
109
146
|
}
|
|
110
147
|
|
|
111
148
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -129,7 +166,9 @@ const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC
|
|
|
129
166
|
// `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
|
|
130
167
|
// fails the anchor pattern.
|
|
131
168
|
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
132
|
-
const PATH_LOC_SPLIT_RE = new RegExp(
|
|
169
|
+
const PATH_LOC_SPLIT_RE = new RegExp(
|
|
170
|
+
`^(.+?):([\\[(]?${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?[\\])]?)$`,
|
|
171
|
+
);
|
|
133
172
|
|
|
134
173
|
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
135
174
|
let next: Record<string, unknown> | undefined;
|
|
@@ -142,7 +181,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
|
142
181
|
return (next ?? fields) as AtomToolEdit;
|
|
143
182
|
}
|
|
144
183
|
|
|
145
|
-
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
|
|
184
|
+
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor; bracket: BracketShape } | { kind: "file" };
|
|
146
185
|
|
|
147
186
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
148
187
|
// Resolution
|
|
@@ -221,28 +260,52 @@ export function resolveAtomEntryPaths(
|
|
|
221
260
|
}
|
|
222
261
|
|
|
223
262
|
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
224
|
-
|
|
263
|
+
const trimmed = raw.trim();
|
|
264
|
+
if (trimmed === "$") return { kind: "file" };
|
|
265
|
+
|
|
266
|
+
const leading = trimmed[0];
|
|
267
|
+
const trailing = trimmed[trimmed.length - 1];
|
|
268
|
+
const hasLeading = leading === "[" || leading === "(";
|
|
269
|
+
const hasTrailing = trailing === "]" || trailing === ")";
|
|
270
|
+
if ((leading === "(" && trailing === "]") || (leading === "[" && trailing === ")")) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Edit ${editIndex}: mixed bracket inclusivity in loc is ambiguous; use [anchor, (anchor, anchor], anchor), [anchor], or a bare anchor.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let inner = trimmed;
|
|
277
|
+
if (hasLeading) inner = inner.slice(1);
|
|
278
|
+
if (hasTrailing) inner = inner.slice(0, -1);
|
|
279
|
+
|
|
225
280
|
// Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
|
|
226
281
|
// loc (e.g. line content like `i--`) should not trigger the range error.
|
|
227
|
-
const dash =
|
|
282
|
+
const dash = inner.indexOf("-");
|
|
228
283
|
if (dash > 0) {
|
|
229
|
-
const left =
|
|
230
|
-
const right =
|
|
284
|
+
const left = inner.slice(0, dash);
|
|
285
|
+
const right = inner.slice(dash + 1);
|
|
231
286
|
if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
|
|
232
287
|
throw new Error(
|
|
233
288
|
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
|
|
234
289
|
);
|
|
235
290
|
}
|
|
236
291
|
}
|
|
237
|
-
const pos = parseAnchor(
|
|
292
|
+
const pos = parseAnchor(inner, "loc");
|
|
238
293
|
// Capture an optional content suffix after the anchor: `82zu| for (...)`.
|
|
239
294
|
// The suffix acts as a hint for anchor disambiguation when the model's hash
|
|
240
295
|
// is wrong but the content reveals the intended line.
|
|
241
|
-
const hint = extractAnchorContentHint(
|
|
296
|
+
const hint = extractAnchorContentHint(inner);
|
|
242
297
|
if (hint !== undefined) {
|
|
243
298
|
pos.contentHint = hint;
|
|
244
299
|
}
|
|
245
|
-
|
|
300
|
+
|
|
301
|
+
let bracket: BracketShape = "none";
|
|
302
|
+
if (leading === "[" && trailing === "]") bracket = "node";
|
|
303
|
+
else if (leading === "[") bracket = "left_incl";
|
|
304
|
+
else if (leading === "(" && trailing === ")") bracket = "body";
|
|
305
|
+
else if (leading === "(") bracket = "left_excl";
|
|
306
|
+
else if (trailing === "]") bracket = "right_incl";
|
|
307
|
+
else if (trailing === ")") bracket = "right_excl";
|
|
308
|
+
return { kind: "anchor", pos, bracket };
|
|
246
309
|
}
|
|
247
310
|
|
|
248
311
|
function extractAnchorContentHint(raw: string): string | undefined {
|
|
@@ -258,70 +321,43 @@ function extractAnchorContentHint(raw: string): string | undefined {
|
|
|
258
321
|
return hint;
|
|
259
322
|
}
|
|
260
323
|
|
|
261
|
-
function
|
|
262
|
-
if (typeof
|
|
263
|
-
throw new Error(
|
|
264
|
-
`Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
|
|
265
|
-
);
|
|
324
|
+
function parseSedSpec(input: unknown, editIndex: number): SedSpec {
|
|
325
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
326
|
+
throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?}.`);
|
|
266
327
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
bodyStart = 1;
|
|
328
|
+
const obj = input as Record<string, unknown>;
|
|
329
|
+
const pat = obj.pat;
|
|
330
|
+
const rep = obj.rep;
|
|
331
|
+
if (typeof pat !== "string" || pat.length === 0) {
|
|
332
|
+
throw new Error(`Edit ${editIndex}: sed.pat must be a non-empty string.`);
|
|
273
333
|
}
|
|
274
|
-
|
|
275
|
-
if (/[\sA-Za-z0-9\\]/.test(delim)) {
|
|
334
|
+
if (pat.includes("\n")) {
|
|
276
335
|
throw new Error(
|
|
277
|
-
`Edit ${editIndex}: sed
|
|
336
|
+
`Edit ${editIndex}: sed.pat must be a single line; contains a newline. Use \`splice\` to replace multiple lines, anchoring the first changed line and listing replacement lines in the array.`,
|
|
278
337
|
);
|
|
279
338
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
let i = bodyStart + 1;
|
|
283
|
-
while (i < raw.length) {
|
|
284
|
-
const c = raw[i]!;
|
|
285
|
-
if (c === "\\" && raw[i + 1] === delim) {
|
|
286
|
-
parts[bucket] += delim;
|
|
287
|
-
i += 2;
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (c === delim) {
|
|
291
|
-
if (bucket === 0) {
|
|
292
|
-
bucket = 1;
|
|
293
|
-
i += 1;
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
i += 1;
|
|
297
|
-
break;
|
|
298
|
-
}
|
|
299
|
-
parts[bucket] += c;
|
|
300
|
-
i += 1;
|
|
301
|
-
}
|
|
302
|
-
if (bucket !== 1) {
|
|
303
|
-
throw new Error(
|
|
304
|
-
`Edit ${editIndex}: malformed sed expression ${JSON.stringify(raw)}. Expected three ${JSON.stringify(delim)} separators.`,
|
|
305
|
-
);
|
|
339
|
+
if (typeof rep !== "string") {
|
|
340
|
+
throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
|
|
306
341
|
}
|
|
307
|
-
const
|
|
342
|
+
const rawGlobal = obj.g;
|
|
308
343
|
let global = false;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (f === "g") global = true;
|
|
313
|
-
else if (f === "i") ignoreCase = true;
|
|
314
|
-
else if (f === "F") literal = true;
|
|
315
|
-
else {
|
|
316
|
-
throw new Error(
|
|
317
|
-
`Edit ${editIndex}: unknown sed flag ${JSON.stringify(f)}. Supported flags: g (all), i (case-insensitive), F (literal).`,
|
|
318
|
-
);
|
|
344
|
+
if (rawGlobal !== undefined) {
|
|
345
|
+
if (typeof rawGlobal !== "boolean") {
|
|
346
|
+
throw new Error(`Edit ${editIndex}: sed.g must be a boolean when provided.`);
|
|
319
347
|
}
|
|
348
|
+
global = rawGlobal;
|
|
320
349
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
350
|
+
return { pattern: pat, replacement: rep, global };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatSedExpression(spec: SedSpec): string {
|
|
354
|
+
const obj: { pat: string; rep: string; g?: boolean } = {
|
|
355
|
+
pat: spec.pattern,
|
|
356
|
+
rep: spec.replacement,
|
|
357
|
+
};
|
|
358
|
+
// Only emit non-default flags so error messages stay compact (g defaults false).
|
|
359
|
+
if (spec.global) obj.g = true;
|
|
360
|
+
return JSON.stringify(obj);
|
|
325
361
|
}
|
|
326
362
|
|
|
327
363
|
function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
|
|
@@ -340,12 +376,8 @@ function applySedToLine(
|
|
|
340
376
|
currentLine: string,
|
|
341
377
|
spec: SedSpec,
|
|
342
378
|
): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
|
|
343
|
-
if (spec.literal) {
|
|
344
|
-
return applyLiteralSed(currentLine, spec);
|
|
345
|
-
}
|
|
346
379
|
let flags = "";
|
|
347
380
|
if (spec.global) flags += "g";
|
|
348
|
-
if (spec.ignoreCase) flags += "i";
|
|
349
381
|
let re: RegExp | undefined;
|
|
350
382
|
let compileError: string | undefined;
|
|
351
383
|
try {
|
|
@@ -357,18 +389,13 @@ function applySedToLine(
|
|
|
357
389
|
re.lastIndex = 0;
|
|
358
390
|
const probe = re.exec(currentLine);
|
|
359
391
|
re.lastIndex = 0;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
|
|
367
|
-
matched: false,
|
|
368
|
-
error: `pattern ${JSON.stringify(spec.pattern)} matches an empty string; use \`pre\`/\`post\`/\`splice\` to insert or replace whole lines, or use a non-empty pattern`,
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
392
|
+
// Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace` to
|
|
393
|
+
// insert the replacement at the match position rather than substitute. When that
|
|
394
|
+
// happens, fall through to the literal-substring fallback below — the model almost
|
|
395
|
+
// always meant the pattern literally (`()` is the parens, `^` is a caret, etc.).
|
|
396
|
+
if (!probe || probe[0].length > 0) {
|
|
397
|
+
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
398
|
+
}
|
|
372
399
|
}
|
|
373
400
|
// Fall back to literal substring match. Models frequently send sed patterns
|
|
374
401
|
// containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
|
|
@@ -391,7 +418,7 @@ function classifyAtomEdit(edit: AtomToolEdit): string {
|
|
|
391
418
|
return verbs.length > 0 ? verbs.join("+") : "unknown";
|
|
392
419
|
}
|
|
393
420
|
|
|
394
|
-
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
421
|
+
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0, path?: string): AtomEdit[] {
|
|
395
422
|
const entry = stripNullAtomFields(edit);
|
|
396
423
|
const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
397
424
|
if (verbKeysPresent.length === 0) {
|
|
@@ -417,12 +444,31 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
417
444
|
resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
|
|
418
445
|
}
|
|
419
446
|
if (entry.sed !== undefined) {
|
|
420
|
-
const spec =
|
|
421
|
-
resolved.push({ op: "sed_file", spec, expression:
|
|
447
|
+
const spec = parseSedSpec(entry.sed, editIndex);
|
|
448
|
+
resolved.push({ op: "sed_file", spec, expression: formatSedExpression(spec) });
|
|
422
449
|
}
|
|
423
450
|
return resolved;
|
|
424
451
|
}
|
|
425
452
|
|
|
453
|
+
if (loc.bracket !== "none") {
|
|
454
|
+
// Bracketed locator: only `splice` is meaningful (region replacement).
|
|
455
|
+
const hasInvalidVerb = entry.pre !== undefined || entry.post !== undefined || entry.sed !== undefined;
|
|
456
|
+
if (hasInvalidVerb) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Edit ${editIndex}: bracket forms in loc are splice-only; remove pre/post/sed or use a bare anchor.`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (entry.splice === undefined) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`Edit ${editIndex}: bracket loc requires \`splice\`. Bare anchors are line-only; brackets address a region.`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const kind = resolveBlockDelimiterForPath(path);
|
|
467
|
+
const body = hashlineParseText(entry.splice);
|
|
468
|
+
resolved.push({ op: "splice_block", pos: loc.pos, spec: { body, kind }, bracket: loc.bracket });
|
|
469
|
+
return resolved;
|
|
470
|
+
}
|
|
471
|
+
|
|
426
472
|
if (entry.pre !== undefined) {
|
|
427
473
|
resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
|
|
428
474
|
}
|
|
@@ -448,8 +494,8 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
448
494
|
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
449
495
|
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
450
496
|
if (!spliceIsExplicitReplacement) {
|
|
451
|
-
const spec =
|
|
452
|
-
resolved.push({ op: "sed", pos: loc.pos, spec, expression:
|
|
497
|
+
const spec = parseSedSpec(entry.sed, editIndex);
|
|
498
|
+
resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
|
|
453
499
|
}
|
|
454
500
|
}
|
|
455
501
|
return resolved;
|
|
@@ -466,6 +512,7 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
|
466
512
|
case "post":
|
|
467
513
|
case "del":
|
|
468
514
|
case "sed":
|
|
515
|
+
case "splice_block":
|
|
469
516
|
yield edit.pos;
|
|
470
517
|
return;
|
|
471
518
|
default:
|
|
@@ -537,16 +584,18 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
537
584
|
}
|
|
538
585
|
|
|
539
586
|
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
540
|
-
// For each anchor line, at most one mutating op (splice/del).
|
|
541
|
-
//
|
|
587
|
+
// For each anchor line, at most one mutating op (splice/del). Multiple `sed`
|
|
588
|
+
// ops on the same line are allowed and applied sequentially. `pre`/`post`
|
|
589
|
+
// (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
542
590
|
const mutatingPerLine = new Map<number, string>();
|
|
543
591
|
for (const edit of edits) {
|
|
544
592
|
if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
|
|
545
593
|
const existing = mutatingPerLine.get(edit.pos.line);
|
|
546
594
|
if (existing) {
|
|
595
|
+
if (existing === "sed" && edit.op === "sed") continue;
|
|
547
596
|
throw new Error(
|
|
548
597
|
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
|
549
|
-
`At most one of splice/del
|
|
598
|
+
`At most one of splice/del is allowed per anchor.`,
|
|
550
599
|
);
|
|
551
600
|
}
|
|
552
601
|
mutatingPerLine.set(edit.pos.line, edit.op);
|
|
@@ -564,6 +613,151 @@ export interface AtomNoopEdit {
|
|
|
564
613
|
current: string;
|
|
565
614
|
}
|
|
566
615
|
|
|
616
|
+
interface SpliceBlockApplyResult {
|
|
617
|
+
text: string;
|
|
618
|
+
firstChangedLine: number | undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
type SpliceBlockEdit = Extract<AtomEdit, { op: "splice_block" }>;
|
|
622
|
+
|
|
623
|
+
function lineStartOffset(text: string, line: number): number {
|
|
624
|
+
let currentLine = 1;
|
|
625
|
+
let offset = 0;
|
|
626
|
+
while (offset < text.length && currentLine < line) {
|
|
627
|
+
if (text[offset] === "\n") currentLine++;
|
|
628
|
+
offset++;
|
|
629
|
+
}
|
|
630
|
+
return offset;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function lineEndOffset(text: string, line: number): number {
|
|
634
|
+
let offset = lineStartOffset(text, line);
|
|
635
|
+
while (offset < text.length && text[offset] !== "\n") offset++;
|
|
636
|
+
return offset;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function lineEndIncludingNewlineOffset(text: string, line: number): number {
|
|
640
|
+
const offset = lineEndOffset(text, line);
|
|
641
|
+
return text[offset] === "\n" ? offset + 1 : offset;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function spliceBlockLocatorLabel(bracket: BracketShape): string {
|
|
645
|
+
switch (bracket) {
|
|
646
|
+
case "none":
|
|
647
|
+
case "body":
|
|
648
|
+
return "(anchor)";
|
|
649
|
+
case "node":
|
|
650
|
+
return "[anchor]";
|
|
651
|
+
case "left_incl":
|
|
652
|
+
return "[anchor";
|
|
653
|
+
case "left_excl":
|
|
654
|
+
return "(anchor";
|
|
655
|
+
case "right_incl":
|
|
656
|
+
return "anchor]";
|
|
657
|
+
case "right_excl":
|
|
658
|
+
return "anchor)";
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function applySpliceBlockEdits(
|
|
663
|
+
originalText: string,
|
|
664
|
+
edits: SpliceBlockEdit[],
|
|
665
|
+
warnings: string[],
|
|
666
|
+
): SpliceBlockApplyResult {
|
|
667
|
+
// Sort by anchor line descending so applying earlier ops doesn't shift
|
|
668
|
+
// later anchors. (Multiple splice_block ops within one call are assumed
|
|
669
|
+
// non-overlapping; overlapping ranges are not supported.)
|
|
670
|
+
const sorted = [...edits].sort((a, b) => b.pos.line - a.pos.line);
|
|
671
|
+
const destStyle = detectIndentStyle(originalText);
|
|
672
|
+
let text = originalText;
|
|
673
|
+
let firstChangedLine: number | undefined;
|
|
674
|
+
|
|
675
|
+
for (const edit of sorted) {
|
|
676
|
+
const kind: DelimiterKind = edit.spec.kind;
|
|
677
|
+
const found = findEnclosingBlock(text, edit.pos.line, { kind, depth: 0 });
|
|
678
|
+
if ("message" in found) {
|
|
679
|
+
throw new Error(`splice at anchor ${edit.pos.line}: ${found.message}`);
|
|
680
|
+
}
|
|
681
|
+
const replacedLineCount = found.closeLine - found.openLine + 1;
|
|
682
|
+
warnings.push(
|
|
683
|
+
`splice locator ${spliceBlockLocatorLabel(edit.bracket)} replaced \`${kind}\` block at lines ${found.openLine}-${found.closeLine} ` +
|
|
684
|
+
`(${replacedLineCount} lines, 1 of ${found.enclosingCount} enclosing \`${kind}\` blocks).`,
|
|
685
|
+
);
|
|
686
|
+
const balanceErr = checkBodyBraceBalance(edit.spec.body.join("\n"), kind);
|
|
687
|
+
if (balanceErr) {
|
|
688
|
+
throw new Error(`splice at anchor ${edit.pos.line}: ${balanceErr}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const stripped = stripCommonIndent(edit.spec.body);
|
|
692
|
+
const bodyPrefix = found.bodyLineIndent ?? `${found.openerLineIndent}${defaultIndentUnit(destStyle)}`;
|
|
693
|
+
|
|
694
|
+
let replacementText: string;
|
|
695
|
+
let replaceStart: number;
|
|
696
|
+
let replaceEnd: number;
|
|
697
|
+
|
|
698
|
+
switch (edit.bracket) {
|
|
699
|
+
case "node": {
|
|
700
|
+
const indented = applyIndent(stripped, found.openerLineIndent, destStyle);
|
|
701
|
+
replacementText = indented.join("\n");
|
|
702
|
+
replaceStart = found.openLineStart;
|
|
703
|
+
replaceEnd = found.closeOffsetExclusive;
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case "left_incl":
|
|
707
|
+
case "left_excl": {
|
|
708
|
+
const indented = applyIndent(stripped, bodyPrefix, destStyle);
|
|
709
|
+
replacementText = `${indented.join("\n")}\n${found.openerLineIndent}`;
|
|
710
|
+
replaceStart =
|
|
711
|
+
edit.bracket === "left_incl"
|
|
712
|
+
? lineStartOffset(text, edit.pos.line)
|
|
713
|
+
: lineEndIncludingNewlineOffset(text, edit.pos.line);
|
|
714
|
+
replaceEnd = found.bodyEnd;
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
case "right_incl":
|
|
718
|
+
case "right_excl": {
|
|
719
|
+
const indented = applyIndent(stripped, bodyPrefix, destStyle);
|
|
720
|
+
replacementText = `\n${indented.join("\n")}\n`;
|
|
721
|
+
replaceStart = found.bodyStart;
|
|
722
|
+
replaceEnd =
|
|
723
|
+
edit.bracket === "right_incl"
|
|
724
|
+
? lineEndIncludingNewlineOffset(text, edit.pos.line)
|
|
725
|
+
: lineStartOffset(text, edit.pos.line);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
case "none":
|
|
729
|
+
case "body": {
|
|
730
|
+
const goInline = found.sameLine && stripped.length === 1;
|
|
731
|
+
if (goInline) {
|
|
732
|
+
const single = stripped.length === 0 ? "" : stripped[0]!.trim();
|
|
733
|
+
const pad = kind === "{" ? " " : "";
|
|
734
|
+
replacementText = single.length > 0 ? `${pad}${single}${pad}` : pad;
|
|
735
|
+
} else {
|
|
736
|
+
const indented = applyIndent(stripped, bodyPrefix, destStyle);
|
|
737
|
+
replacementText = `\n${indented.join("\n")}\n${found.openerLineIndent}`;
|
|
738
|
+
}
|
|
739
|
+
replaceStart = found.bodyStart;
|
|
740
|
+
replaceEnd = found.bodyEnd;
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const before = text.slice(0, replaceStart);
|
|
746
|
+
const after = text.slice(replaceEnd);
|
|
747
|
+
const newText = before + replacementText + after;
|
|
748
|
+
|
|
749
|
+
text = newText;
|
|
750
|
+
if (firstChangedLine === undefined || found.openLine < firstChangedLine) {
|
|
751
|
+
firstChangedLine = found.openLine;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return { text, firstChangedLine };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function defaultIndentUnit(style: { kind: "tab" | "space"; width: number }): string {
|
|
758
|
+
return style.kind === "tab" ? "\t" : " ".repeat(Math.max(1, style.width));
|
|
759
|
+
}
|
|
760
|
+
|
|
567
761
|
export function applyAtomEdits(
|
|
568
762
|
text: string,
|
|
569
763
|
edits: AtomEdit[],
|
|
@@ -586,7 +780,39 @@ export function applyAtomEdits(
|
|
|
586
780
|
if (mismatches.length > 0) {
|
|
587
781
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
588
782
|
}
|
|
589
|
-
|
|
783
|
+
// When a `del` and a `sed`/`splice` target the same anchor (across separate edit
|
|
784
|
+
// entries), the `del` is almost always a hallucinated cleanup the model added on top
|
|
785
|
+
// of the real replacement. Drop the `del` silently so the replacement wins, matching
|
|
786
|
+
// the in-entry handling for `splice: []` paired with `sed`.
|
|
787
|
+
const replacedLines = new Set<number>();
|
|
788
|
+
for (const e of edits) {
|
|
789
|
+
if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
|
|
790
|
+
}
|
|
791
|
+
let effective = edits;
|
|
792
|
+
if (replacedLines.size > 0) {
|
|
793
|
+
effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
|
|
794
|
+
}
|
|
795
|
+
validateNoConflictingAnchorOps(effective);
|
|
796
|
+
|
|
797
|
+
// splice_block ops own their entire block range. To keep line numbers sane,
|
|
798
|
+
// they cannot mix with other anchor-scoped ops in the same call. They may
|
|
799
|
+
// coexist with each other (sorted by openLine descending so earlier ops
|
|
800
|
+
// don't shift later anchors).
|
|
801
|
+
const spliceBlockEdits = effective.filter(
|
|
802
|
+
(e): e is Extract<AtomEdit, { op: "splice_block" }> => e.op === "splice_block",
|
|
803
|
+
);
|
|
804
|
+
if (spliceBlockEdits.length > 0) {
|
|
805
|
+
const result = applySpliceBlockEdits(text, spliceBlockEdits, warnings);
|
|
806
|
+
if (result.firstChangedLine !== undefined) {
|
|
807
|
+
if (firstChangedLine === undefined || result.firstChangedLine < firstChangedLine) {
|
|
808
|
+
firstChangedLine = result.firstChangedLine;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// Continue pipeline against the post-splice_block text.
|
|
812
|
+
fileLines.length = 0;
|
|
813
|
+
for (const line of result.text.split("\n")) fileLines.push(line);
|
|
814
|
+
effective = effective.filter(e => e.op !== "splice_block");
|
|
815
|
+
}
|
|
590
816
|
|
|
591
817
|
const trackFirstChanged = (line: number) => {
|
|
592
818
|
if (firstChangedLine === undefined || line < firstChangedLine) {
|
|
@@ -603,7 +829,7 @@ export function applyAtomEdits(
|
|
|
603
829
|
const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
|
|
604
830
|
const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
|
|
605
831
|
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
606
|
-
|
|
832
|
+
effective.forEach((edit, idx) => {
|
|
607
833
|
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
608
834
|
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
609
835
|
else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
|
|
@@ -659,18 +885,19 @@ export function applyAtomEdits(
|
|
|
659
885
|
anchorMutated = true;
|
|
660
886
|
break;
|
|
661
887
|
case "sed": {
|
|
662
|
-
const
|
|
888
|
+
const input = replacementSet ? (replacement[0] ?? "") : currentLine;
|
|
889
|
+
const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
|
|
663
890
|
if (error) {
|
|
664
891
|
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
|
|
665
892
|
}
|
|
666
893
|
if (!matched) {
|
|
667
894
|
throw new Error(
|
|
668
|
-
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(
|
|
895
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(input)}`,
|
|
669
896
|
);
|
|
670
897
|
}
|
|
671
898
|
if (literalFallback) {
|
|
672
899
|
warnings.push(
|
|
673
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead.
|
|
900
|
+
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Escape regex metacharacters in the pattern to match as a regex.`,
|
|
674
901
|
);
|
|
675
902
|
}
|
|
676
903
|
replacement = [result];
|
|
@@ -762,7 +989,7 @@ export function applyAtomEdits(
|
|
|
762
989
|
anyMatched = true;
|
|
763
990
|
if (r.literalFallback && !warnedLiteralFallback) {
|
|
764
991
|
warnings.push(
|
|
765
|
-
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution.
|
|
992
|
+
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex; applied literal substring substitution. Escape regex metacharacters in the pattern to match as a regex.`,
|
|
766
993
|
);
|
|
767
994
|
warnedLiteralFallback = true;
|
|
768
995
|
}
|
|
@@ -806,7 +1033,7 @@ export async function executeAtomSingle(
|
|
|
806
1033
|
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
807
1034
|
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
808
1035
|
|
|
809
|
-
const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
|
|
1036
|
+
const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i, path));
|
|
810
1037
|
|
|
811
1038
|
enforcePlanModeWrite(session, path, { op: "update" });
|
|
812
1039
|
|