@oh-my-pi/pi-coding-agent 14.4.0 → 14.4.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 +70 -0
- package/package.json +7 -7
- package/src/cli.ts +0 -1
- package/src/config/prompt-templates.ts +1 -31
- package/src/config/settings-schema.ts +27 -37
- package/src/config/settings.ts +1 -1
- package/src/edit/index.ts +1 -53
- package/src/edit/line-hash.ts +13 -63
- package/src/edit/modes/atom.ts +334 -64
- package/src/edit/modes/hashline.ts +19 -26
- package/src/edit/renderer.ts +6 -8
- package/src/edit/streaming.ts +90 -114
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +10 -15
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/lsp/defaults.json +142 -652
- package/src/modes/components/session-selector.ts +3 -3
- package/src/modes/components/settings-defs.ts +0 -5
- package/src/modes/components/tool-execution.ts +2 -5
- package/src/modes/controllers/btw-controller.ts +17 -105
- package/src/modes/controllers/todo-command-controller.ts +537 -0
- package/src/modes/interactive-mode.ts +35 -9
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +17 -0
- package/src/prompts/system/irc-incoming.md +8 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -0
- package/src/prompts/tools/atom.md +55 -53
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/grep.md +2 -5
- package/src/prompts/tools/irc.md +49 -0
- package/src/prompts/tools/job.md +11 -0
- package/src/prompts/tools/read.md +12 -13
- package/src/prompts/tools/task.md +1 -1
- package/src/prompts/tools/todo-write.md +14 -5
- package/src/registry/agent-registry.ts +139 -0
- package/src/sdk.ts +35 -0
- package/src/session/agent-session.ts +217 -5
- package/src/session/session-manager.ts +4 -1
- package/src/session/streaming-output.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +24 -0
- package/src/task/executor.ts +14 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/fetch.ts +18 -6
- package/src/tools/fs-cache-invalidation.ts +0 -5
- package/src/tools/grep.ts +5 -125
- package/src/tools/index.ts +12 -6
- package/src/tools/irc.ts +258 -0
- package/src/tools/job.ts +489 -0
- package/src/tools/match-line-format.ts +8 -7
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/read.ts +37 -131
- package/src/tools/renderers.ts +2 -0
- package/src/tools/todo-write.ts +243 -12
- package/src/tools/write.ts +2 -2
- package/src/utils/edit-mode.ts +1 -2
- package/src/utils/file-display-mode.ts +0 -3
- package/src/cli/read-cli.ts +0 -67
- package/src/commands/read.ts +0 -33
- package/src/edit/modes/chunk.ts +0 -832
- package/src/prompts/tools/cancel-job.md +0 -5
- package/src/prompts/tools/chunk-edit.md +0 -158
- package/src/prompts/tools/poll.md +0 -5
- package/src/prompts/tools/read-chunk.md +0 -73
- package/src/tools/cancel-job.ts +0 -95
- package/src/tools/poll-tool.ts +0 -173
package/src/edit/modes/atom.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
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`, `
|
|
4
|
+
* one shared `loc` selector plus one or more verbs (`pre`, `splice`, `post`).
|
|
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",
|
|
9
|
+
* { path, loc: "5th", splice: ["..."] }
|
|
10
10
|
* { path, loc: "5th", pre: ["..."] }
|
|
11
11
|
* { path, loc: "5th", post: ["..."] }
|
|
12
|
-
* { path, loc: "5th", pre: [...],
|
|
13
|
-
* { path, loc: "
|
|
14
|
-
* { path, loc: "$", post: [...] } // append to
|
|
12
|
+
* { path, loc: "5th", pre: [...], splice: [...], post: [...] }
|
|
13
|
+
* { path, loc: "$", pre: [...] } // prepend to file
|
|
14
|
+
* { path, loc: "$", post: [...] } // append to file
|
|
15
|
+
* { path, loc: "$", sed: "s/foo/bar/" } // sed on every line
|
|
15
16
|
*
|
|
16
|
-
* `
|
|
17
|
+
* `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
|
|
17
18
|
* a blank line. Line ranges are not supported.
|
|
18
19
|
* in the same entry.
|
|
19
20
|
*
|
|
@@ -29,7 +30,7 @@ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
|
29
30
|
import { outputMeta } from "../../tools/output-meta";
|
|
30
31
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
31
32
|
import { generateDiffString } from "../diff";
|
|
32
|
-
import { computeLineHash } from "../line-hash";
|
|
33
|
+
import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
33
34
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
34
35
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
35
36
|
import {
|
|
@@ -57,14 +58,19 @@ const textSchema = Type.Array(Type.String());
|
|
|
57
58
|
*/
|
|
58
59
|
export const atomEditSchema = Type.Object(
|
|
59
60
|
{
|
|
60
|
-
path: Type.Optional(Type.String({ description: "file path override", examples: ["src/foo.ts"] })),
|
|
61
61
|
loc: Type.String({
|
|
62
|
-
description: 'edit location: "1ab", "
|
|
63
|
-
examples: ["1ab", "
|
|
62
|
+
description: 'edit location: "1ab", "$", or path override like "a.ts:1ab"',
|
|
63
|
+
examples: ["1ab", "$", "src/foo.ts:1ab"],
|
|
64
64
|
}),
|
|
65
|
-
|
|
65
|
+
splice: Type.Optional(textSchema),
|
|
66
66
|
pre: Type.Optional(textSchema),
|
|
67
67
|
post: Type.Optional(textSchema),
|
|
68
|
+
sed: Type.Optional(
|
|
69
|
+
Type.String({
|
|
70
|
+
description: "sed-style substitution applied to the anchored line",
|
|
71
|
+
examples: ["s/foo/bar/", "s|api|API|g", "s/<pat>/<rep>/F"],
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
68
74
|
},
|
|
69
75
|
{ additionalProperties: false },
|
|
70
76
|
);
|
|
@@ -85,20 +91,45 @@ export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
|
85
91
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
86
92
|
|
|
87
93
|
export type AtomEdit =
|
|
88
|
-
| { op: "
|
|
94
|
+
| { op: "splice"; pos: Anchor; lines: string[] }
|
|
89
95
|
| { op: "pre"; pos: Anchor; lines: string[] }
|
|
90
96
|
| { op: "post"; pos: Anchor; lines: string[] }
|
|
91
97
|
| { op: "del"; pos: Anchor }
|
|
92
98
|
| { op: "append_file"; lines: string[] }
|
|
93
|
-
| { op: "prepend_file"; lines: string[] }
|
|
99
|
+
| { op: "prepend_file"; lines: string[] }
|
|
100
|
+
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string }
|
|
101
|
+
| { op: "sed_file"; spec: SedSpec; expression: string };
|
|
102
|
+
|
|
103
|
+
export interface SedSpec {
|
|
104
|
+
pattern: string;
|
|
105
|
+
replacement: string;
|
|
106
|
+
global: boolean;
|
|
107
|
+
ignoreCase: boolean;
|
|
108
|
+
literal: boolean;
|
|
109
|
+
}
|
|
94
110
|
|
|
95
111
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
112
|
// Param guards
|
|
97
113
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
98
114
|
|
|
99
|
-
const ATOM_VERB_KEYS = ["
|
|
100
|
-
type AtomOptionalKey = "
|
|
101
|
-
const ATOM_OPTIONAL_KEYS = ["
|
|
115
|
+
const ATOM_VERB_KEYS = ["splice", "pre", "post", "sed"] as const;
|
|
116
|
+
type AtomOptionalKey = "loc" | (typeof ATOM_VERB_KEYS)[number];
|
|
117
|
+
const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
|
|
118
|
+
|
|
119
|
+
// Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
|
|
120
|
+
// optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
|
|
121
|
+
// captured as a content hint for anchor disambiguation.
|
|
122
|
+
const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
|
|
123
|
+
|
|
124
|
+
// Splits `path:loc` references where the right side starts with a valid anchor
|
|
125
|
+
// (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
|
|
126
|
+
// content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
|
|
127
|
+
// colon whose RHS is a real anchor, so colons inside the loc's content suffix
|
|
128
|
+
// (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
|
|
129
|
+
// `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
|
|
130
|
+
// fails the anchor pattern.
|
|
131
|
+
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
132
|
+
const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
|
|
102
133
|
|
|
103
134
|
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
104
135
|
let next: Record<string, unknown> | undefined;
|
|
@@ -111,7 +142,7 @@ function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
|
111
142
|
return (next ?? fields) as AtomToolEdit;
|
|
112
143
|
}
|
|
113
144
|
|
|
114
|
-
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "
|
|
145
|
+
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "file" };
|
|
115
146
|
|
|
116
147
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
117
148
|
// Resolution
|
|
@@ -122,7 +153,7 @@ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind:
|
|
|
122
153
|
*
|
|
123
154
|
* Tolerant: on a malformed reference we still try to extract a 1-indexed line
|
|
124
155
|
* number from the leading digits so the validator can surface the *correct*
|
|
125
|
-
* `LINEHASH
|
|
156
|
+
* `LINEHASH|content` for the user. The bogus hash is preserved in the returned
|
|
126
157
|
* anchor so the validator emits a content-rich mismatch error.
|
|
127
158
|
*
|
|
128
159
|
* If we cannot recover even a line number, throw a usage-style error with the
|
|
@@ -158,16 +189,6 @@ function tryParseAtomTag(raw: string): Anchor | undefined {
|
|
|
158
189
|
}
|
|
159
190
|
}
|
|
160
191
|
|
|
161
|
-
function isLocSelector(raw: string): boolean {
|
|
162
|
-
if (raw === "^" || raw === "$") return true;
|
|
163
|
-
const dash = raw.indexOf("-");
|
|
164
|
-
if (dash === -1) return tryParseAtomTag(raw) !== undefined;
|
|
165
|
-
const left = raw.slice(0, dash);
|
|
166
|
-
const right = raw.slice(dash + 1);
|
|
167
|
-
if (left.length === 0 || right.length === 0) return false;
|
|
168
|
-
return tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
192
|
function resolveAtomEntryPath(
|
|
172
193
|
edit: AtomToolEdit,
|
|
173
194
|
topLevelPath: string | undefined,
|
|
@@ -177,19 +198,16 @@ function resolveAtomEntryPath(
|
|
|
177
198
|
let loc = entry.loc;
|
|
178
199
|
let pathOverride: string | undefined;
|
|
179
200
|
if (typeof loc === "string") {
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
pathOverride = loc.slice(0, colon);
|
|
185
|
-
loc = maybeSelector;
|
|
186
|
-
}
|
|
201
|
+
const split = loc.match(PATH_LOC_SPLIT_RE);
|
|
202
|
+
if (split) {
|
|
203
|
+
pathOverride = split[1];
|
|
204
|
+
loc = split[2]!;
|
|
187
205
|
}
|
|
188
206
|
}
|
|
189
|
-
const path = pathOverride ||
|
|
207
|
+
const path = pathOverride || topLevelPath;
|
|
190
208
|
if (!path) {
|
|
191
209
|
throw new Error(
|
|
192
|
-
`Edit ${editIndex}: missing path. Provide a top-level path
|
|
210
|
+
`Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`,
|
|
193
211
|
);
|
|
194
212
|
}
|
|
195
213
|
return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
|
|
@@ -203,14 +221,155 @@ export function resolveAtomEntryPaths(
|
|
|
203
221
|
}
|
|
204
222
|
|
|
205
223
|
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
206
|
-
if (raw === "
|
|
207
|
-
|
|
208
|
-
|
|
224
|
+
if (raw === "$") return { kind: "file" };
|
|
225
|
+
// Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
|
|
226
|
+
// loc (e.g. line content like `i--`) should not trigger the range error.
|
|
227
|
+
const dash = raw.indexOf("-");
|
|
228
|
+
if (dash > 0) {
|
|
229
|
+
const left = raw.slice(0, dash);
|
|
230
|
+
const right = raw.slice(dash + 1);
|
|
231
|
+
if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr" or "$".`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const pos = parseAnchor(raw, "loc");
|
|
238
|
+
// Capture an optional content suffix after the anchor: `82zu| for (...)`.
|
|
239
|
+
// The suffix acts as a hint for anchor disambiguation when the model's hash
|
|
240
|
+
// is wrong but the content reveals the intended line.
|
|
241
|
+
const hint = extractAnchorContentHint(raw);
|
|
242
|
+
if (hint !== undefined) {
|
|
243
|
+
pos.contentHint = hint;
|
|
244
|
+
}
|
|
245
|
+
return { kind: "anchor", pos };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function extractAnchorContentHint(raw: string): string | undefined {
|
|
249
|
+
const match = raw.match(ANCHOR_PREFIX_RE);
|
|
250
|
+
if (!match) return undefined;
|
|
251
|
+
const rest = raw.slice(match[0].length);
|
|
252
|
+
// Accept either the canonical `|` (HASHLINE_CONTENT_SEPARATOR) or the legacy
|
|
253
|
+
// `:` separator. Models trained on older docs still emit `82zu: for (...)`.
|
|
254
|
+
const sep = rest[0];
|
|
255
|
+
if (sep !== HASHLINE_CONTENT_SEPARATOR && sep !== ":") return undefined;
|
|
256
|
+
const hint = rest.slice(1);
|
|
257
|
+
if (hint.trim().length === 0) return undefined;
|
|
258
|
+
return hint;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseSedExpression(raw: string, editIndex: number): SedSpec {
|
|
262
|
+
if (typeof raw !== "string" || raw.length < 3) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
// Tolerate a missing leading `s`: models occasionally emit `/foo/bar/` directly.
|
|
268
|
+
// As long as the first character is a valid delimiter, treat the expression as
|
|
269
|
+
// if `s` was prepended.
|
|
270
|
+
let bodyStart = 0;
|
|
271
|
+
if (raw[0] === "s") {
|
|
272
|
+
bodyStart = 1;
|
|
273
|
+
}
|
|
274
|
+
const delim = raw[bodyStart]!;
|
|
275
|
+
if (/[\sA-Za-z0-9\\]/.test(delim)) {
|
|
209
276
|
throw new Error(
|
|
210
|
-
`Edit ${editIndex}:
|
|
277
|
+
`Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
|
|
211
278
|
);
|
|
212
279
|
}
|
|
213
|
-
|
|
280
|
+
const parts: [string, string] = ["", ""];
|
|
281
|
+
let bucket: 0 | 1 = 0;
|
|
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
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const flagsStr = raw.slice(i);
|
|
308
|
+
let global = false;
|
|
309
|
+
let ignoreCase = false;
|
|
310
|
+
let literal = false;
|
|
311
|
+
for (const f of flagsStr) {
|
|
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
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (parts[0] === "") {
|
|
322
|
+
throw new Error(`Edit ${editIndex}: sed expression has empty pattern.`);
|
|
323
|
+
}
|
|
324
|
+
return { pattern: parts[0], replacement: parts[1], global, ignoreCase, literal };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
|
|
328
|
+
const idx = currentLine.indexOf(spec.pattern);
|
|
329
|
+
if (idx === -1) return { result: currentLine, matched: false };
|
|
330
|
+
if (spec.global) {
|
|
331
|
+
return { result: currentLine.split(spec.pattern).join(spec.replacement), matched: true };
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
result: currentLine.slice(0, idx) + spec.replacement + currentLine.slice(idx + spec.pattern.length),
|
|
335
|
+
matched: true,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function applySedToLine(
|
|
340
|
+
currentLine: string,
|
|
341
|
+
spec: SedSpec,
|
|
342
|
+
): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
|
|
343
|
+
if (spec.literal) {
|
|
344
|
+
return applyLiteralSed(currentLine, spec);
|
|
345
|
+
}
|
|
346
|
+
let flags = "";
|
|
347
|
+
if (spec.global) flags += "g";
|
|
348
|
+
if (spec.ignoreCase) flags += "i";
|
|
349
|
+
let re: RegExp | undefined;
|
|
350
|
+
let compileError: string | undefined;
|
|
351
|
+
try {
|
|
352
|
+
re = new RegExp(spec.pattern, flags);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
compileError = (e as Error).message;
|
|
355
|
+
}
|
|
356
|
+
if (re?.test(currentLine)) {
|
|
357
|
+
re.lastIndex = 0;
|
|
358
|
+
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
359
|
+
}
|
|
360
|
+
// Fall back to literal substring match. Models frequently send sed patterns
|
|
361
|
+
// containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
|
|
362
|
+
// intend as literal code. Trying a literal match before reporting failure
|
|
363
|
+
// recovers the obvious intent without changing semantics for patterns that
|
|
364
|
+
// already match as regex.
|
|
365
|
+
const literal = applyLiteralSed(currentLine, spec);
|
|
366
|
+
if (literal.matched) {
|
|
367
|
+
return { ...literal, literalFallback: true };
|
|
368
|
+
}
|
|
369
|
+
if (compileError !== undefined) {
|
|
370
|
+
return { result: currentLine, matched: false, error: compileError };
|
|
371
|
+
}
|
|
372
|
+
return { result: currentLine, matched: false };
|
|
214
373
|
}
|
|
215
374
|
|
|
216
375
|
function classifyAtomEdit(edit: AtomToolEdit): string {
|
|
@@ -228,45 +387,58 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
228
387
|
);
|
|
229
388
|
}
|
|
230
389
|
if (typeof entry.loc !== "string") {
|
|
231
|
-
throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr"
|
|
390
|
+
throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr" or "$".`);
|
|
232
391
|
}
|
|
233
392
|
|
|
234
393
|
const loc = parseLoc(entry.loc, editIndex);
|
|
235
394
|
const resolved: AtomEdit[] = [];
|
|
236
395
|
|
|
237
|
-
if (loc.kind === "
|
|
238
|
-
if (entry.
|
|
239
|
-
throw new Error(`Edit ${editIndex}: loc "
|
|
396
|
+
if (loc.kind === "file") {
|
|
397
|
+
if (entry.splice !== undefined) {
|
|
398
|
+
throw new Error(`Edit ${editIndex}: loc "$" supports pre, post, and sed (not splice).`);
|
|
240
399
|
}
|
|
241
400
|
if (entry.pre !== undefined) {
|
|
242
401
|
resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
|
|
243
402
|
}
|
|
244
|
-
return resolved;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (loc.kind === "eof") {
|
|
248
|
-
if (entry.set !== undefined || entry.pre !== undefined) {
|
|
249
|
-
throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
|
|
250
|
-
}
|
|
251
403
|
if (entry.post !== undefined) {
|
|
252
404
|
resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
|
|
253
405
|
}
|
|
406
|
+
if (entry.sed !== undefined) {
|
|
407
|
+
const spec = parseSedExpression(entry.sed, editIndex);
|
|
408
|
+
resolved.push({ op: "sed_file", spec, expression: entry.sed });
|
|
409
|
+
}
|
|
254
410
|
return resolved;
|
|
255
411
|
}
|
|
256
412
|
|
|
257
413
|
if (entry.pre !== undefined) {
|
|
258
414
|
resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
|
|
259
415
|
}
|
|
260
|
-
if (entry.
|
|
261
|
-
if (Array.isArray(entry.
|
|
262
|
-
|
|
416
|
+
if (entry.splice !== undefined) {
|
|
417
|
+
if (Array.isArray(entry.splice) && entry.splice.length === 0) {
|
|
418
|
+
// Models often default `splice: []` alongside other verbs (notably `sed`).
|
|
419
|
+
// Treating that combination as an explicit `del` produces a confusing
|
|
420
|
+
// `Conflicting ops` error. When another mutating verb is present, drop
|
|
421
|
+
// the empty `splice` instead of treating it as a deletion.
|
|
422
|
+
if (entry.sed === undefined) {
|
|
423
|
+
resolved.push({ op: "del", pos: loc.pos });
|
|
424
|
+
}
|
|
263
425
|
} else {
|
|
264
|
-
resolved.push({ op: "
|
|
426
|
+
resolved.push({ op: "splice", pos: loc.pos, lines: hashlineParseText(entry.splice) });
|
|
265
427
|
}
|
|
266
428
|
}
|
|
267
429
|
if (entry.post !== undefined) {
|
|
268
430
|
resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
|
|
269
431
|
}
|
|
432
|
+
if (entry.sed !== undefined) {
|
|
433
|
+
const spliceIsExplicitReplacement = Array.isArray(entry.splice) && entry.splice.length > 0;
|
|
434
|
+
// Models often duplicate intent by sending both an explicit `splice` and a
|
|
435
|
+
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
436
|
+
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
437
|
+
if (!spliceIsExplicitReplacement) {
|
|
438
|
+
const spec = parseSedExpression(entry.sed, editIndex);
|
|
439
|
+
resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
270
442
|
return resolved;
|
|
271
443
|
}
|
|
272
444
|
|
|
@@ -276,10 +448,11 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
276
448
|
|
|
277
449
|
function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
278
450
|
switch (edit.op) {
|
|
279
|
-
case "
|
|
451
|
+
case "splice":
|
|
280
452
|
case "pre":
|
|
281
453
|
case "post":
|
|
282
454
|
case "del":
|
|
455
|
+
case "sed":
|
|
283
456
|
yield edit.pos;
|
|
284
457
|
return;
|
|
285
458
|
default:
|
|
@@ -287,6 +460,29 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
|
287
460
|
}
|
|
288
461
|
}
|
|
289
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Search for a line near `anchor.line` whose trimmed content equals the
|
|
465
|
+
* anchor's content hint. Returns the closest match (preferring lines below the
|
|
466
|
+
* requested anchor on ties) or `null` when no line matches. Strict equality on
|
|
467
|
+
* trimmed content keeps this conservative \u2014 we only retarget when there is no
|
|
468
|
+
* ambiguity about the model's intent.
|
|
469
|
+
*/
|
|
470
|
+
function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
|
|
471
|
+
const hint = anchor.contentHint?.trim();
|
|
472
|
+
if (!hint) return null;
|
|
473
|
+
const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
|
|
474
|
+
const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
|
|
475
|
+
let best: { line: number; distance: number } | null = null;
|
|
476
|
+
for (let line = lo; line <= hi; line++) {
|
|
477
|
+
if (fileLines[line - 1].trim() !== hint) continue;
|
|
478
|
+
const distance = Math.abs(line - anchor.line);
|
|
479
|
+
if (best === null || distance < best.distance) {
|
|
480
|
+
best = { line, distance };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return best?.line ?? null;
|
|
484
|
+
}
|
|
485
|
+
|
|
290
486
|
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
291
487
|
const mismatches: HashMismatch[] = [];
|
|
292
488
|
for (const edit of edits) {
|
|
@@ -296,6 +492,22 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
296
492
|
}
|
|
297
493
|
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
298
494
|
if (actualHash === anchor.hash) continue;
|
|
495
|
+
// When the model supplied a content hint after the anchor (e.g.
|
|
496
|
+
// `82zu| for (...)`), prefer rebasing to the line that actually matches
|
|
497
|
+
// that content. This avoids false positives from hash-only rebasing where
|
|
498
|
+
// a coincidentally matching hash on a nearby line silently retargets the
|
|
499
|
+
// edit to the wrong line.
|
|
500
|
+
const hinted = findLineByContentHint(anchor, fileLines);
|
|
501
|
+
if (hinted !== null) {
|
|
502
|
+
const original = `${anchor.line}${anchor.hash}`;
|
|
503
|
+
const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
|
|
504
|
+
anchor.line = hinted;
|
|
505
|
+
anchor.hash = hintedHash;
|
|
506
|
+
warnings.push(
|
|
507
|
+
`Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
|
|
508
|
+
);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
299
511
|
const rebased = tryRebaseAnchor(anchor, fileLines);
|
|
300
512
|
if (rebased !== null) {
|
|
301
513
|
const original = `${anchor.line}${anchor.hash}`;
|
|
@@ -312,16 +524,16 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
312
524
|
}
|
|
313
525
|
|
|
314
526
|
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
315
|
-
// For each anchor line, at most one mutating op (
|
|
527
|
+
// For each anchor line, at most one mutating op (splice/del).
|
|
316
528
|
// `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
317
529
|
const mutatingPerLine = new Map<number, string>();
|
|
318
530
|
for (const edit of edits) {
|
|
319
|
-
if (edit.op !== "
|
|
531
|
+
if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
|
|
320
532
|
const existing = mutatingPerLine.get(edit.pos.line);
|
|
321
533
|
if (existing) {
|
|
322
534
|
throw new Error(
|
|
323
535
|
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
|
324
|
-
`At most one of
|
|
536
|
+
`At most one of splice/del/sed is allowed per anchor.`,
|
|
325
537
|
);
|
|
326
538
|
}
|
|
327
539
|
mutatingPerLine.set(edit.pos.line, edit.op);
|
|
@@ -336,7 +548,7 @@ function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: stri
|
|
|
336
548
|
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
|
|
337
549
|
if (!enabled) return;
|
|
338
550
|
for (const edit of edits) {
|
|
339
|
-
if (edit.op !== "
|
|
551
|
+
if (edit.op !== "splice" && edit.op !== "pre" && edit.op !== "post") continue;
|
|
340
552
|
if (edit.lines.length === 0) continue;
|
|
341
553
|
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
342
554
|
if (!hasEscapedTabs) continue;
|
|
@@ -399,13 +611,15 @@ export function applyAtomEdits(
|
|
|
399
611
|
// captured idx so multiple pre/post on the same target are emitted in the order
|
|
400
612
|
// the model produced them.
|
|
401
613
|
type Indexed<T> = { edit: T; idx: number };
|
|
402
|
-
type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
|
|
614
|
+
type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" } | { op: "sed_file" }>;
|
|
403
615
|
const anchorEdits: Indexed<AnchorEdit>[] = [];
|
|
404
616
|
const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
|
|
617
|
+
const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
|
|
405
618
|
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
406
619
|
edits.forEach((edit, idx) => {
|
|
407
620
|
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
408
621
|
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
622
|
+
else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
|
|
409
623
|
else anchorEdits.push({ edit, idx });
|
|
410
624
|
});
|
|
411
625
|
|
|
@@ -452,11 +666,31 @@ export function applyAtomEdits(
|
|
|
452
666
|
replacementSet = true;
|
|
453
667
|
anchorDeleted = true;
|
|
454
668
|
break;
|
|
455
|
-
case "
|
|
669
|
+
case "splice":
|
|
456
670
|
replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
|
|
457
671
|
replacementSet = true;
|
|
458
672
|
anchorMutated = true;
|
|
459
673
|
break;
|
|
674
|
+
case "sed": {
|
|
675
|
+
const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
|
|
676
|
+
if (error) {
|
|
677
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${error}`);
|
|
678
|
+
}
|
|
679
|
+
if (!matched) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (literalFallback) {
|
|
685
|
+
warnings.push(
|
|
686
|
+
`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.`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
replacement = [result];
|
|
690
|
+
replacementSet = true;
|
|
691
|
+
anchorMutated = true;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
460
694
|
}
|
|
461
695
|
}
|
|
462
696
|
|
|
@@ -524,6 +758,42 @@ export function applyAtomEdits(
|
|
|
524
758
|
trackFirstChanged(insertIdx + 1);
|
|
525
759
|
}
|
|
526
760
|
|
|
761
|
+
// Apply sed_file ops last so they observe the post-anchor / post-prepend /
|
|
762
|
+
// post-append state of the file. Each op runs across every content line and
|
|
763
|
+
let warnedLiteralFallback = false;
|
|
764
|
+
sedFileEdits.sort((a, b) => a.idx - b.idx);
|
|
765
|
+
for (const { edit } of sedFileEdits) {
|
|
766
|
+
const hasTrailingNewline = fileLines.length > 1 && fileLines[fileLines.length - 1] === "";
|
|
767
|
+
const upper = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
768
|
+
let anyMatched = false;
|
|
769
|
+
let lastCompileError: string | undefined;
|
|
770
|
+
for (let i = 0; i < upper; i++) {
|
|
771
|
+
const line = fileLines[i] ?? "";
|
|
772
|
+
const r = applySedToLine(line, edit.spec);
|
|
773
|
+
if (r.error) lastCompileError = r.error;
|
|
774
|
+
if (!r.matched) continue;
|
|
775
|
+
anyMatched = true;
|
|
776
|
+
if (r.literalFallback && !warnedLiteralFallback) {
|
|
777
|
+
warnings.push(
|
|
778
|
+
`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.`,
|
|
779
|
+
);
|
|
780
|
+
warnedLiteralFallback = true;
|
|
781
|
+
}
|
|
782
|
+
if (r.result !== line) {
|
|
783
|
+
fileLines[i] = r.result;
|
|
784
|
+
trackFirstChanged(i + 1);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!anyMatched) {
|
|
788
|
+
if (lastCompileError !== undefined) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${lastCompileError}`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
527
797
|
return {
|
|
528
798
|
lines: fileLines.join("\n"),
|
|
529
799
|
firstChangedLine,
|