@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 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.0",
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.0",
50
- "@oh-my-pi/pi-agent-core": "14.4.0",
51
- "@oh-my-pi/pi-ai": "14.4.0",
52
- "@oh-my-pi/pi-natives": "14.4.0",
53
- "@oh-my-pi/pi-tui": "14.4.0",
54
- "@oh-my-pi/pi-utils": "14.4.0",
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:content"` (colon between anchor and content).
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\\tcontent)",
1024
+ description: "Include line hashes in read output for hashline edit mode (LINE+ID|content)",
1025
1025
  },
1026
1026
  },
1027
1027
 
@@ -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:TEXT` where LINENUM is 1-indexed.
763
- * No padding on line numbers; colon separator between anchor and content.
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:function hi() {\n2er: return;\n3in:}"
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
  }
@@ -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:content` for the user. The bogus hash is preserved in the returned
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 colon = loc.lastIndexOf(":");
181
- if (colon > 0) {
182
- const maybeSelector = loc.slice(colon + 1);
183
- if (isLocSelector(maybeSelector)) {
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
- if (raw.includes("-")) {
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}: atom loc does not support line ranges. Use a single anchor like "160sr", "^", or "$".`,
264
+ `Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
211
265
  );
212
266
  }
213
- return { kind: "anchor", pos: parseAnchor(raw, "loc") };
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
- resolved.push({ op: "del", pos: loc.pos });
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