@oh-my-pi/pi-coding-agent 14.4.0 → 14.4.1
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 +14 -0
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +1 -1
- package/src/config/settings-schema.ts +1 -1
- package/src/edit/line-hash.ts +13 -10
- package/src/edit/modes/atom.ts +264 -29
- package/src/edit/modes/hashline.ts +17 -22
- package/src/lsp/defaults.json +142 -652
- package/src/modes/components/session-selector.ts +3 -3
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -0
- package/src/prompts/tools/atom.md +29 -38
- package/src/prompts/tools/grep.md +2 -2
- package/src/prompts/tools/read.md +1 -1
- package/src/session/session-manager.ts +4 -1
- package/src/tools/grep.ts +2 -2
- package/src/tools/match-line-format.ts +3 -3
- package/src/tools/read.ts +1 -5
- package/src/tools/write.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [14.4.1] - 2026-04-26
|
|
4
6
|
### Breaking Changes
|
|
5
7
|
|
|
6
8
|
- Replaced the legacy `gh_repo_view`, `gh_issue_view`, `gh_pr_view`, `gh_pr_diff`, `gh_pr_checkout`, `gh_pr_push`, `gh_run_watch`, `gh_search_issues`, and `gh_search_prs` tool names with only `github`, which requires updating existing callers that invoked the old `gh_*` tools
|
|
7
9
|
|
|
8
10
|
### Added
|
|
9
11
|
|
|
12
|
+
- Added a `sed` verb to the `atom` edit tool for line-local substitutions using sed-style syntax (`s/pattern/replacement/`) with `g`, `i`, and `F` flags and model-tolerant delimiter choices
|
|
10
13
|
- Added the unified `github` tool with op-based dispatch for repository, issue, pull request, search, checkout, push, and Actions watch workflows
|
|
11
14
|
- Added `op` routing so callers can select `repo_view`, `issue_view`, `pr_view`, `pr_diff`, `pr_checkout`, `pr_push`, `search_issues`, `search_prs`, or `run_watch` through a single tool entry point
|
|
12
15
|
|
|
13
16
|
### Changed
|
|
14
17
|
|
|
18
|
+
- Changed hashline-based read and match output formatting to use `LINE+ID|content` as the anchor/content separator, and updated match/context markers to `>` for matches and `:` for context
|
|
15
19
|
- Updated GitHub CLI render output to show `GitHub <op>` for tool calls dispatched through `github` operations
|
|
16
20
|
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- Removed the built-in `taplo` Language Server entry from default LSP settings, so TOML files no longer have default TOML server startup
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed `atom` `loc` parsing so path-qualified anchors like `path:263ti| ...` and single-anchor locs containing hyphens no longer mis-parse as ranges
|
|
28
|
+
- Fixed hashline anchor handling in `atom` edits so a provided content hint after the anchor (`|` or `:` suffix) can rebond a stale hash to the intended line
|
|
29
|
+
- Fixed `atom` `sed` execution to tolerate common model-emitted forms such as `/pat/rep/`, and to apply safe literal fallbacks for regex failures or metacharacter-heavy patterns while still erroring when no match is possible
|
|
30
|
+
|
|
17
31
|
## [14.4.0] - 2026-04-26
|
|
18
32
|
|
|
19
33
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.4.
|
|
4
|
+
"version": "14.4.1",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.20.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.4.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.4.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.4.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.4.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.4.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.4.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.4.1",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.4.1",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.4.1",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.4.1",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.4.1",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.4.1",
|
|
55
55
|
"@sinclair/typebox": "^0.34.49",
|
|
56
56
|
"@xterm/headless": "^6.0.0",
|
|
57
57
|
"ajv": "^8.20.0",
|
|
@@ -55,7 +55,7 @@ prompt.registerHelper("href", (lineNum: unknown, content: unknown): string => {
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* {{hline lineNum "content"}} — format a full read-style line with prefix.
|
|
58
|
-
* Returns `"lineNumBIGRAM
|
|
58
|
+
* Returns `"lineNumBIGRAM|content"` (pipe between anchor and content).
|
|
59
59
|
*/
|
|
60
60
|
prompt.registerHelper("hline", (lineNum: unknown, content: unknown): string => {
|
|
61
61
|
const { ref, text } = formatHashlineRef(lineNum, content);
|
|
@@ -1021,7 +1021,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1021
1021
|
ui: {
|
|
1022
1022
|
tab: "editing",
|
|
1023
1023
|
label: "Hash Lines",
|
|
1024
|
-
description: "Include line hashes in read output for hashline edit mode (LINE+ID
|
|
1024
|
+
description: "Include line hashes in read output for hashline edit mode (LINE+ID|content)",
|
|
1025
1025
|
},
|
|
1026
1026
|
},
|
|
1027
1027
|
|
package/src/edit/line-hash.ts
CHANGED
|
@@ -726,7 +726,7 @@ export const CHUNK_BIGRAMS_COUNT = CHUNK_BIGRAMS.length;
|
|
|
726
726
|
*/
|
|
727
727
|
export const HASHLINE_BIGRAM_RE_SRC = `(?:${HASHLINE_BIGRAMS.join("|")})`;
|
|
728
728
|
|
|
729
|
-
export const HASHLINE_CONTENT_SEPARATOR = "
|
|
729
|
+
export const HASHLINE_CONTENT_SEPARATOR = "|";
|
|
730
730
|
|
|
731
731
|
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
|
|
732
732
|
|
|
@@ -756,11 +756,19 @@ export function formatLineHash(line: number, lines: string): string {
|
|
|
756
756
|
return `${line}${computeLineHash(line, lines)}`;
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Formats a single line with a hashline anchor.
|
|
761
|
+
* Returns `LINE+ID|TEXT` (e.g., `42nd|function hi() {\n2er| return;\n3in|}`)
|
|
762
|
+
*/
|
|
763
|
+
export function formatHashLine(lineNumber: number, line: string): string {
|
|
764
|
+
return `${lineNumber}${computeLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
|
|
765
|
+
}
|
|
766
|
+
|
|
759
767
|
/**
|
|
760
768
|
* Format file text with hashline prefixes for display.
|
|
761
769
|
*
|
|
762
|
-
* Each line becomes `LINE+ID
|
|
763
|
-
* No padding on line numbers;
|
|
770
|
+
* Each line becomes `LINE+ID|TEXT` where LINENUM is 1-indexed.
|
|
771
|
+
* No padding on line numbers; pipe separator between anchor and content.
|
|
764
772
|
*
|
|
765
773
|
* @param text - Raw file text string
|
|
766
774
|
* @param startLine - First line number (1-indexed, defaults to 1)
|
|
@@ -769,15 +777,10 @@ export function formatLineHash(line: number, lines: string): string {
|
|
|
769
777
|
* @example
|
|
770
778
|
* ```
|
|
771
779
|
* formatHashLines("function hi() {\n return;\n}")
|
|
772
|
-
* // "1th
|
|
780
|
+
* // "1th|function hi() {\n2er| return;\n3in|}"
|
|
773
781
|
* ```
|
|
774
782
|
*/
|
|
775
783
|
export function formatHashLines(text: string, startLine = 1): string {
|
|
776
784
|
const lines = text.split("\n");
|
|
777
|
-
return lines
|
|
778
|
-
.map((line, i) => {
|
|
779
|
-
const num = startLine + i;
|
|
780
|
-
return `${formatLineHash(num, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
|
|
781
|
-
})
|
|
782
|
-
.join("\n");
|
|
785
|
+
return lines.map((line, i) => formatHashLine(startLine + i, line)).join("\n");
|
|
783
786
|
}
|
package/src/edit/modes/atom.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
|
29
29
|
import { outputMeta } from "../../tools/output-meta";
|
|
30
30
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
31
31
|
import { generateDiffString } from "../diff";
|
|
32
|
-
import { computeLineHash } from "../line-hash";
|
|
32
|
+
import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
33
33
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
34
34
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
35
35
|
import {
|
|
@@ -65,6 +65,12 @@ export const atomEditSchema = Type.Object(
|
|
|
65
65
|
set: 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
|
);
|
|
@@ -90,16 +96,40 @@ export type AtomEdit =
|
|
|
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
|
+
|
|
102
|
+
export interface SedSpec {
|
|
103
|
+
pattern: string;
|
|
104
|
+
replacement: string;
|
|
105
|
+
global: boolean;
|
|
106
|
+
ignoreCase: boolean;
|
|
107
|
+
literal: boolean;
|
|
108
|
+
}
|
|
94
109
|
|
|
95
110
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
96
111
|
// Param guards
|
|
97
112
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
98
113
|
|
|
99
|
-
const ATOM_VERB_KEYS = ["set", "pre", "post"] as const;
|
|
114
|
+
const ATOM_VERB_KEYS = ["set", "pre", "post", "sed"] as const;
|
|
100
115
|
type AtomOptionalKey = "path" | "loc" | (typeof ATOM_VERB_KEYS)[number];
|
|
101
116
|
const ATOM_OPTIONAL_KEYS = ["path", "loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
|
|
102
117
|
|
|
118
|
+
// Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
|
|
119
|
+
// optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
|
|
120
|
+
// captured as a content hint for anchor disambiguation.
|
|
121
|
+
const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
|
|
122
|
+
|
|
123
|
+
// Splits `path:loc` references where the right side starts with a valid anchor
|
|
124
|
+
// (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
|
|
125
|
+
// content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
|
|
126
|
+
// colon whose RHS is a real anchor, so colons inside the loc's content suffix
|
|
127
|
+
// (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
|
|
128
|
+
// `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
|
|
129
|
+
// fails the anchor pattern.
|
|
130
|
+
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
131
|
+
const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
|
|
132
|
+
|
|
103
133
|
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
104
134
|
let next: Record<string, unknown> | undefined;
|
|
105
135
|
const fields = edit as Record<string, unknown>;
|
|
@@ -122,7 +152,7 @@ type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind:
|
|
|
122
152
|
*
|
|
123
153
|
* Tolerant: on a malformed reference we still try to extract a 1-indexed line
|
|
124
154
|
* number from the leading digits so the validator can surface the *correct*
|
|
125
|
-
* `LINEHASH
|
|
155
|
+
* `LINEHASH|content` for the user. The bogus hash is preserved in the returned
|
|
126
156
|
* anchor so the validator emits a content-rich mismatch error.
|
|
127
157
|
*
|
|
128
158
|
* If we cannot recover even a line number, throw a usage-style error with the
|
|
@@ -158,16 +188,6 @@ function tryParseAtomTag(raw: string): Anchor | undefined {
|
|
|
158
188
|
}
|
|
159
189
|
}
|
|
160
190
|
|
|
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
191
|
function resolveAtomEntryPath(
|
|
172
192
|
edit: AtomToolEdit,
|
|
173
193
|
topLevelPath: string | undefined,
|
|
@@ -177,13 +197,10 @@ function resolveAtomEntryPath(
|
|
|
177
197
|
let loc = entry.loc;
|
|
178
198
|
let pathOverride: string | undefined;
|
|
179
199
|
if (typeof loc === "string") {
|
|
180
|
-
const
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
pathOverride = loc.slice(0, colon);
|
|
185
|
-
loc = maybeSelector;
|
|
186
|
-
}
|
|
200
|
+
const split = loc.match(PATH_LOC_SPLIT_RE);
|
|
201
|
+
if (split) {
|
|
202
|
+
pathOverride = split[1];
|
|
203
|
+
loc = split[2]!;
|
|
187
204
|
}
|
|
188
205
|
}
|
|
189
206
|
const path = pathOverride || entry.path || topLevelPath;
|
|
@@ -205,12 +222,154 @@ export function resolveAtomEntryPaths(
|
|
|
205
222
|
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
206
223
|
if (raw === "^") return { kind: "bof" };
|
|
207
224
|
if (raw === "$") return { kind: "eof" };
|
|
208
|
-
|
|
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) {
|
|
209
263
|
throw new Error(
|
|
210
|
-
`Edit ${editIndex}:
|
|
264
|
+
`Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
|
|
211
265
|
);
|
|
212
266
|
}
|
|
213
|
-
|
|
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)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
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 {
|
|
@@ -235,7 +394,7 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
235
394
|
const resolved: AtomEdit[] = [];
|
|
236
395
|
|
|
237
396
|
if (loc.kind === "bof") {
|
|
238
|
-
if (entry.set !== undefined || entry.post !== undefined) {
|
|
397
|
+
if (entry.set !== undefined || entry.post !== undefined || entry.sed !== undefined) {
|
|
239
398
|
throw new Error(`Edit ${editIndex}: loc "^" only supports pre.`);
|
|
240
399
|
}
|
|
241
400
|
if (entry.pre !== undefined) {
|
|
@@ -245,7 +404,7 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
245
404
|
}
|
|
246
405
|
|
|
247
406
|
if (loc.kind === "eof") {
|
|
248
|
-
if (entry.set !== undefined || entry.pre !== undefined) {
|
|
407
|
+
if (entry.set !== undefined || entry.pre !== undefined || entry.sed !== undefined) {
|
|
249
408
|
throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
|
|
250
409
|
}
|
|
251
410
|
if (entry.post !== undefined) {
|
|
@@ -259,7 +418,13 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
259
418
|
}
|
|
260
419
|
if (entry.set !== undefined) {
|
|
261
420
|
if (Array.isArray(entry.set) && entry.set.length === 0) {
|
|
262
|
-
|
|
421
|
+
// Models often default `set: []` alongside other verbs (notably `sed`).
|
|
422
|
+
// Treating that combination as an explicit `del` produces a confusing
|
|
423
|
+
// `Conflicting ops` error. When another mutating verb is present, drop
|
|
424
|
+
// the empty `set` instead of treating it as a deletion.
|
|
425
|
+
if (entry.sed === undefined) {
|
|
426
|
+
resolved.push({ op: "del", pos: loc.pos });
|
|
427
|
+
}
|
|
263
428
|
} else {
|
|
264
429
|
resolved.push({ op: "set", pos: loc.pos, lines: hashlineParseText(entry.set) });
|
|
265
430
|
}
|
|
@@ -267,6 +432,16 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
|
267
432
|
if (entry.post !== undefined) {
|
|
268
433
|
resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
|
|
269
434
|
}
|
|
435
|
+
if (entry.sed !== undefined) {
|
|
436
|
+
const setIsExplicitReplacement = Array.isArray(entry.set) && entry.set.length > 0;
|
|
437
|
+
// Models often duplicate intent by sending both an explicit `set` and a
|
|
438
|
+
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
439
|
+
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
440
|
+
if (!setIsExplicitReplacement) {
|
|
441
|
+
const spec = parseSedExpression(entry.sed, editIndex);
|
|
442
|
+
resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
270
445
|
return resolved;
|
|
271
446
|
}
|
|
272
447
|
|
|
@@ -280,6 +455,7 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
|
280
455
|
case "pre":
|
|
281
456
|
case "post":
|
|
282
457
|
case "del":
|
|
458
|
+
case "sed":
|
|
283
459
|
yield edit.pos;
|
|
284
460
|
return;
|
|
285
461
|
default:
|
|
@@ -287,6 +463,29 @@ function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
|
287
463
|
}
|
|
288
464
|
}
|
|
289
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Search for a line near `anchor.line` whose trimmed content equals the
|
|
468
|
+
* anchor's content hint. Returns the closest match (preferring lines below the
|
|
469
|
+
* requested anchor on ties) or `null` when no line matches. Strict equality on
|
|
470
|
+
* trimmed content keeps this conservative \u2014 we only retarget when there is no
|
|
471
|
+
* ambiguity about the model's intent.
|
|
472
|
+
*/
|
|
473
|
+
function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
|
|
474
|
+
const hint = anchor.contentHint?.trim();
|
|
475
|
+
if (!hint) return null;
|
|
476
|
+
const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
|
|
477
|
+
const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
|
|
478
|
+
let best: { line: number; distance: number } | null = null;
|
|
479
|
+
for (let line = lo; line <= hi; line++) {
|
|
480
|
+
if (fileLines[line - 1].trim() !== hint) continue;
|
|
481
|
+
const distance = Math.abs(line - anchor.line);
|
|
482
|
+
if (best === null || distance < best.distance) {
|
|
483
|
+
best = { line, distance };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return best?.line ?? null;
|
|
487
|
+
}
|
|
488
|
+
|
|
290
489
|
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
291
490
|
const mismatches: HashMismatch[] = [];
|
|
292
491
|
for (const edit of edits) {
|
|
@@ -296,6 +495,22 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
|
|
|
296
495
|
}
|
|
297
496
|
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
298
497
|
if (actualHash === anchor.hash) continue;
|
|
498
|
+
// When the model supplied a content hint after the anchor (e.g.
|
|
499
|
+
// `82zu| for (...)`), prefer rebasing to the line that actually matches
|
|
500
|
+
// that content. This avoids false positives from hash-only rebasing where
|
|
501
|
+
// a coincidentally matching hash on a nearby line silently retargets the
|
|
502
|
+
// edit to the wrong line.
|
|
503
|
+
const hinted = findLineByContentHint(anchor, fileLines);
|
|
504
|
+
if (hinted !== null) {
|
|
505
|
+
const original = `${anchor.line}${anchor.hash}`;
|
|
506
|
+
const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
|
|
507
|
+
anchor.line = hinted;
|
|
508
|
+
anchor.hash = hintedHash;
|
|
509
|
+
warnings.push(
|
|
510
|
+
`Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
|
|
511
|
+
);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
299
514
|
const rebased = tryRebaseAnchor(anchor, fileLines);
|
|
300
515
|
if (rebased !== null) {
|
|
301
516
|
const original = `${anchor.line}${anchor.hash}`;
|
|
@@ -316,12 +531,12 @@ function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
|
316
531
|
// `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
317
532
|
const mutatingPerLine = new Map<number, string>();
|
|
318
533
|
for (const edit of edits) {
|
|
319
|
-
if (edit.op !== "set" && edit.op !== "del") continue;
|
|
534
|
+
if (edit.op !== "set" && edit.op !== "del" && edit.op !== "sed") continue;
|
|
320
535
|
const existing = mutatingPerLine.get(edit.pos.line);
|
|
321
536
|
if (existing) {
|
|
322
537
|
throw new Error(
|
|
323
538
|
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
|
324
|
-
`At most one of set/del is allowed per anchor.`,
|
|
539
|
+
`At most one of set/del/sed is allowed per anchor.`,
|
|
325
540
|
);
|
|
326
541
|
}
|
|
327
542
|
mutatingPerLine.set(edit.pos.line, edit.op);
|
|
@@ -457,6 +672,26 @@ export function applyAtomEdits(
|
|
|
457
672
|
replacementSet = true;
|
|
458
673
|
anchorMutated = true;
|
|
459
674
|
break;
|
|
675
|
+
case "sed": {
|
|
676
|
+
const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
|
|
677
|
+
if (error) {
|
|
678
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${error}`);
|
|
679
|
+
}
|
|
680
|
+
if (!matched) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (literalFallback) {
|
|
686
|
+
warnings.push(
|
|
687
|
+
`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.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
replacement = [result];
|
|
691
|
+
replacementSet = true;
|
|
692
|
+
anchorMutated = true;
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
460
695
|
}
|
|
461
696
|
}
|
|
462
697
|
|