@oh-my-pi/pi-coding-agent 15.2.3 → 15.2.4

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
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.2.4] - 2026-05-22
6
+ ### Breaking Changes
7
+
8
+ - Replaced the legacy `@@` header and `+`/`<`/`=`/`-` hashline syntax with the new `§PATH` header and `«`/`»`/`≔` operation format, so existing hashline scripts and prompts using old symbols must be updated
9
+
10
+ ### Added
11
+
12
+ - Added one-anchor `≔ANCHOR` shorthand equivalent to `≔ANCHOR..ANCHOR` for single-line replace/delete
13
+
14
+ ### Changed
15
+
16
+ - Changed `≔A..B` so an omitted payload now deletes the range, and added an explicit empty payload line to keep a literal blank replacement line
17
+
18
+ ### Removed
19
+
20
+ - Removed the `hsep` prompt helper and `PI_HL_SEP` payload-prefix configuration because hashline payloads are no longer line-prefixed
21
+
22
+ ### Fixed
23
+
24
+ - Fixed hashline payload handling in parser and streaming preview to preserve blank lines as actual payload text until the next op, file header, or envelope marker
25
+
5
26
  ## [15.2.3] - 2026-05-22
6
27
  ### Breaking Changes
7
28
 
@@ -2,8 +2,6 @@
2
2
  export declare const MISMATCH_CONTEXT = 2;
3
3
  /** Filler hash used for the interior of a multi-line range; not validated. */
4
4
  export declare const RANGE_INTERIOR_HASH = "**";
5
- /** Header marker introducing a new file section in multi-section input. */
6
- export declare const FILE_HEADER_PREFIX = "@";
7
5
  /** Optional patch envelope start marker; silently consumed when present. */
8
6
  export declare const BEGIN_PATCH_MARKER = "*** Begin Patch";
9
7
  /** Optional patch envelope end marker; terminates parsing when encountered. */
@@ -64,47 +64,21 @@ export declare function resolveHashlineGrammarPlaceholders(grammar: string): str
64
64
  /** @deprecated Use {@link resolveHashlineGrammarPlaceholders}. */
65
65
  export declare const resolveLarkLidPlaceholders: typeof resolveHashlineGrammarPlaceholders;
66
66
  /**
67
- * Single source of truth for the hashline edit payload separator. This is the
68
- * configured separator that starts inserted/replacement payload lines in
69
- * hashline edit input (`<separator>TEXT`) and separates inline modify ops from
70
- * their appended/prepended text.
67
+ * Hashline edit input markers. File section headers start with {@link HL_FILE_PREFIX};
68
+ * op lines start with a direction/action sigil: {@link HL_OP_INSERT_BEFORE},
69
+ * {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. Payload lines are
70
+ * verbatim file content and have no per-line marker.
71
71
  *
72
- * Override at runtime with the `PI_HL_SEP` env var (e.g.
73
- * `PI_HL_SEP=">"`, `PI_HL_SEP="\\"`). The value is read once at module load;
74
- * the edit grammar, prompt helper, and edit parser derive from it.
75
- *
76
- * Default is `~`, chosen empirically. Benchmark across 8 candidate separators
77
- * x 3 models (glm-4.7:nitro, gpt-5.4-nano, claude-sonnet-4-6), 24-48 runs per
78
- * cell, hashline variant, 12 sampled tasks per run:
79
- *
80
- * sep | task ✓ | edit ✓ | patch fail | tok/run
81
- * ----|--------|--------|-----------------|--------
82
- * + | 70.8% | 78.0% | 27/125 (21.6%) | 32,127
83
- * ÷ | 70.7% | 90.6% | 22/211 (10.4%) | 31,666
84
- * ~ | 69.4% | 94.9% | 6/107 ( 5.6%) | 30,529 <-- default
85
- * > | 69.2% | 91.5% | 21/219 ( 9.6%) | 30,777
86
- * : | 66.7% | 86.4% | 20/126 (15.9%) | 33,900
87
- * | | 65.9% | 86.9% | 20/127 (15.7%) | 34,589
88
- * \ | 65.5% | 89.8% | 16/124 (12.9%) | 36,010
89
- * % | 63.9% | 92.8% | 11/125 ( 8.8%) | 36,530
90
- *
91
- * `~` wins because:
92
- * - highest edit-tool success rate (94.9%) of any tested separator
93
- * - lowest patch-failure rate (5.6%) — model rarely emits a malformed payload
94
- * - cheapest in tokens alongside `>` (no retry overhead from format collisions)
95
- * - no line-leading role in any mainstream language, markdown, diff, regex,
96
- * or shell, so payload lines are unambiguous to both the parser and models
97
- * - task-success is statistically tied with `>` and `÷` (within run-to-run
98
- * noise), so the edit-reliability win is free
99
- *
100
- * `+` and `÷` lead on raw task-success but at the cost of ~2-4x more patch
101
- * failures (the model retries until it lands a valid edit). `:`, `|`, `\`
102
- * collide with line-leading syntax (label/object-key, body separator, escape)
103
- * and degrade both edit reliability and intent-match.
72
+ * These constants are the single source of truth for the edit parser, grammar,
73
+ * renderer, and prompt.
104
74
  */
105
- export declare const HL_EDIT_SEP: string;
106
- /** Regex-escaped form of {@link HL_EDIT_SEP}, safe for regexes. */
107
- export declare const HL_EDIT_SEP_RE_RAW: string;
75
+ export declare const HL_OP_INSERT_BEFORE = "\u00AB";
76
+ export declare const HL_OP_INSERT_AFTER = "\u00BB";
77
+ export declare const HL_OP_REPLACE = "\u2254";
78
+ /** All hashline edit op sigils, concatenated for fast membership tests. */
79
+ export declare const HL_OP_CHARS = "\u00AB\u00BB\u2254";
80
+ /** Hashline edit file section header marker. */
81
+ export declare const HL_FILE_PREFIX = "\u00A7";
108
82
  /** Stable separator for read/search/hashline display output. Intentionally not configurable. */
109
83
  export declare const HL_BODY_SEP = "|";
110
84
  /** Regex-escaped form of {@link HL_BODY_SEP}, safe for embedding inside a regex. */
@@ -1,11 +1,7 @@
1
1
  import type { HashlineCursor, HashlineEdit } from "./types";
2
2
  export declare function cloneCursor(cursor: HashlineCursor): HashlineCursor;
3
- export declare function parseHashline(diff: string, opts?: ParseHashlineOptions): HashlineEdit[];
4
- export interface ParseHashlineOptions {
5
- /** File path the diff targets. Used to suppress indent-sensitive false-positive warnings. */
6
- path?: string;
7
- }
8
- export declare function parseHashlineWithWarnings(diff: string, opts?: ParseHashlineOptions): {
3
+ export declare function parseHashline(diff: string): HashlineEdit[];
4
+ export declare function parseHashlineWithWarnings(diff: string): {
9
5
  edits: HashlineEdit[];
10
6
  warnings: string[];
11
7
  };
@@ -3,4 +3,13 @@ import type { TabBarTheme } from "@oh-my-pi/pi-tui";
3
3
  export declare function sanitizeStatusText(text: string): string;
4
4
  /** Shared tab bar theme used by model-selector and settings-selector. */
5
5
  export declare function getTabBarTheme(): TabBarTheme;
6
+ /**
7
+ * Suffix appended to the loader's working message to remind users they can
8
+ * abort with Esc. Rendered with the active theme's bracket glyphs so it stays
9
+ * visually consistent with badges and other bracketed UI affordances.
10
+ *
11
+ * The leading space separates the hint from the message body and is consumed
12
+ * by `endsWith`/`slice` matching in the loader renderer.
13
+ */
14
+ export declare function interruptHint(): string;
6
15
  export { parseCommandArgs } from "../utils/command-args";
@@ -1,13 +1,16 @@
1
1
  import type { Theme, ThemeColor } from "./theme";
2
2
  type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
3
+ type ShimmerPaletteTier = ThemeColor | {
4
+ ansi: string;
5
+ };
3
6
  /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
4
7
  export interface ShimmerPalette {
5
8
  /** Color for chars outside / at the edge of the band (intensity < ~0.22). */
6
- low: ThemeColor;
9
+ low: ShimmerPaletteTier;
7
10
  /** Color for chars approaching the crest (~0.22 ≤ intensity < ~0.65). */
8
- mid: ThemeColor;
11
+ mid: ShimmerPaletteTier;
9
12
  /** Color at the band's crest (intensity ≥ ~0.65). */
10
- high: ThemeColor;
13
+ high: ShimmerPaletteTier;
11
14
  /** Whether to bold the crest tier. Default `false`. */
12
15
  bold?: boolean;
13
16
  }
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": "15.2.3",
4
+ "version": "15.2.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.2.3",
51
- "@oh-my-pi/pi-agent-core": "15.2.3",
52
- "@oh-my-pi/pi-ai": "15.2.3",
53
- "@oh-my-pi/pi-natives": "15.2.3",
54
- "@oh-my-pi/pi-tui": "15.2.3",
55
- "@oh-my-pi/pi-utils": "15.2.3",
50
+ "@oh-my-pi/omp-stats": "15.2.4",
51
+ "@oh-my-pi/pi-agent-core": "15.2.4",
52
+ "@oh-my-pi/pi-ai": "15.2.4",
53
+ "@oh-my-pi/pi-natives": "15.2.4",
54
+ "@oh-my-pi/pi-tui": "15.2.4",
55
+ "@oh-my-pi/pi-utils": "15.2.4",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -8,7 +8,7 @@ import {
8
8
  parseFrontmatter,
9
9
  prompt,
10
10
  } from "@oh-my-pi/pi-utils";
11
- import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../hashline/hash";
11
+ import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
12
12
  import { jtdToTypeScript } from "../tools/jtd-to-typescript";
13
13
  import { parseCommandArgs, substituteArgs } from "../utils/command-args";
14
14
 
@@ -154,13 +154,6 @@ prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): str
154
154
  return `${ref}${HL_BODY_SEP}${text}`;
155
155
  });
156
156
 
157
- /**
158
- * {{hsep}} — emit the configured hashline payload separator character.
159
- * Stays in sync with {@link HL_EDIT_SEP} so edit prompt templates
160
- * never have to hardcode the payload separator.
161
- */
162
- prompt.registerHelper("hsep", (): string => HL_EDIT_SEP);
163
-
164
157
  const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
165
158
  const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
166
159
 
package/src/edit/index.ts CHANGED
@@ -35,7 +35,7 @@ export * from "./apply-patch";
35
35
  export * from "./diff";
36
36
  export * from "./file-read-cache";
37
37
 
38
- // Resolve the `$HFMT$` and `$HSEP$` placeholders in the hashline Lark grammar.
38
+ // Resolve the `$HFMT$`, `$HOP_*$`, `$HOP_CHARS$`, and `$HFILE$` placeholders in the hashline Lark grammar.
39
39
  const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
40
40
 
41
41
  export * from "../hashline";
@@ -6,6 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
7
7
  import { sanitizeText } from "@oh-my-pi/pi-utils";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
+ import { HL_FILE_PREFIX } from "../hashline/hash";
9
10
  import type { FileDiagnosticsResult } from "../lsp";
10
11
  import { renderDiff as renderDiffColored } from "../modes/components/diff";
11
12
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -328,7 +329,6 @@ function getCallPreview(
328
329
  }
329
330
 
330
331
  const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
331
- const HL_INPUT_HEADER_PREFIX = "@";
332
332
 
333
333
  function normalizeHashlineInputPreviewPath(rawPath: string): string {
334
334
  const trimmed = rawPath.trim();
@@ -342,13 +342,11 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
342
342
  }
343
343
 
344
344
  function parseHashlineInputPreviewHeader(line: string): string | null {
345
- if (!line.startsWith(HL_INPUT_HEADER_PREFIX)) return null;
346
- // The real parser (`parseHashlineHeaderLine` in `hashline/input.ts`) strips
347
- // every leading "@" before resolving the path so canonical "@@ PATH" headers
348
- // (and stray "@ PATH" / "@@@ PATH" runs) all route to the same file. Mirror
349
- // that here so the renderer doesn't surface a literal "@ " in the title.
345
+ if (!line.startsWith(HL_FILE_PREFIX)) return null;
346
+ // Mirror hashline/input.ts: strip every leading file marker so canonical
347
+ // PATH` headers and stray `§§ PATH` / `§§§PATH` runs render clean paths.
350
348
  let prefixEnd = 0;
351
- while (prefixEnd < line.length && line[prefixEnd] === HL_INPUT_HEADER_PREFIX) prefixEnd++;
349
+ while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
352
350
  const body = line.slice(prefixEnd).trim();
353
351
  const previewPath = normalizeHashlineInputPreviewPath(body);
354
352
  return previewPath.length > 0 ? previewPath : null;
@@ -22,6 +22,8 @@ import {
22
22
  containsRecognizableHashlineOperations,
23
23
  END_PATCH_MARKER,
24
24
  type HashlineInputSection,
25
+ HL_FILE_PREFIX,
26
+ HL_OP_CHARS,
25
27
  splitHashlineInputs,
26
28
  } from "../hashline";
27
29
  import type { Theme } from "../modes/theme/theme";
@@ -77,8 +79,19 @@ const STREAMING_FALLBACK_LINES = 12;
77
79
  const STREAMING_FALLBACK_WIDTH = 80;
78
80
 
79
81
  function isHashlineHeaderLine(line: string): boolean {
82
+ return line.trimEnd().startsWith(HL_FILE_PREFIX);
83
+ }
84
+
85
+ function parseHashlineHeaderPath(line: string): string {
80
86
  const trimmed = line.trimEnd();
81
- return trimmed.startsWith("@") && trimmed.length > 1;
87
+ let prefixEnd = 0;
88
+ while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
89
+ return trimmed.slice(prefixEnd).trim();
90
+ }
91
+
92
+ function isHashlineOpLine(line: string): boolean {
93
+ const first = line[0];
94
+ return first !== undefined && HL_OP_CHARS.includes(first);
82
95
  }
83
96
 
84
97
  function isHashlineEnvelopeMarkerLine(line: string): boolean {
@@ -358,11 +371,11 @@ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[
358
371
  }
359
372
 
360
373
  /**
361
- * Hashline equivalent: emit each section's `~payload` lines as `+added`
362
- * lines in the order the model typed them. We deliberately omit op headers
363
- * and removal targets from the streaming preview because their content
364
- * lives in the file and would require a costly re-apply per tick; the
365
- * complete unified diff is shown once streaming finishes.
374
+ * Hashline equivalent: emit each payload line as a `+added` line in the
375
+ * order the model typed it. We deliberately omit op headers and removal
376
+ * targets from the streaming preview because their content lives in the file
377
+ * and would require a costly re-apply per tick; the complete unified diff is
378
+ * shown once streaming finishes.
366
379
  */
367
380
  function buildHashlineNaturalOrderPreviews(
368
381
  input: string,
@@ -382,13 +395,12 @@ function buildHashlineNaturalOrderPreviews(
382
395
  for (const raw of lines) {
383
396
  if (isHashlineEnvelopeMarkerLine(raw)) continue;
384
397
  if (isHashlineHeaderLine(raw)) {
385
- currentPath = raw.trimEnd().slice(1).trim();
398
+ currentPath = parseHashlineHeaderPath(raw);
386
399
  if (currentPath) ensure(currentPath);
387
400
  continue;
388
401
  }
389
- if (raw.startsWith("~")) {
390
- ensure(currentPath).push(`+${raw.slice(1)}`);
391
- }
402
+ if (isHashlineOpLine(raw) || !currentPath) continue;
403
+ ensure(currentPath).push(`+${raw}`);
392
404
  }
393
405
  if (groups.size === 0) return null;
394
406
  const previews: PerFileDiffPreview[] = [];
@@ -409,7 +421,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
409
421
  if (input.length === 0) return null;
410
422
  if (ctx.isStreaming) {
411
423
  // Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
412
- // reordering by showing the model's `~payload` lines in input order.
424
+ // reordering by showing payload lines in input order.
413
425
  return buildHashlineNaturalOrderPreviews(input, args.path);
414
426
  }
415
427
  ctx.signal.throwIfAborted();
@@ -419,7 +431,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
419
431
  sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
420
432
  } catch {
421
433
  // Single-section fallback keeps the original error rendering for the
422
- // "haven't typed `@@ PATH` yet" case.
434
+ // "haven't typed PATH` yet" case.
423
435
  const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
424
436
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
425
437
  });
@@ -4,9 +4,6 @@ export const MISMATCH_CONTEXT = 2;
4
4
  /** Filler hash used for the interior of a multi-line range; not validated. */
5
5
  export const RANGE_INTERIOR_HASH = "**";
6
6
 
7
- /** Header marker introducing a new file section in multi-section input. */
8
- export const FILE_HEADER_PREFIX = "@";
9
-
10
7
  /** Optional patch envelope start marker; silently consumed when present. */
11
8
  export const BEGIN_PATCH_MARKER = "*** Begin Patch";
12
9
 
@@ -30,7 +30,7 @@ export async function computeHashlineSectionDiff(
30
30
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
31
31
  const { text: content } = stripBom(rawContent);
32
32
  const normalized = normalizeToLF(content);
33
- const result = applyHashlineEdits(normalized, parseHashline(section.diff, { path: section.path }), options);
33
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
34
34
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
35
35
  return generateDiffString(normalized, result.lines);
36
36
  } catch (err) {
@@ -106,7 +106,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
106
106
  const { session, path: sectionPath, diff } = options;
107
107
 
108
108
  const absolutePath = resolvePlanPath(session, sectionPath);
109
- const { edits } = parseHashlineWithWarnings(diff, { path: sectionPath });
109
+ const { edits } = parseHashlineWithWarnings(diff);
110
110
  enforcePlanModeWrite(session, sectionPath, { op: "update" });
111
111
 
112
112
  const source = await readHashlineFile(absolutePath, sectionPath);
@@ -139,7 +139,7 @@ async function executeHashlineSection(
139
139
  } = options;
140
140
 
141
141
  const absolutePath = resolvePlanPath(session, sourcePath);
142
- const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff, { path: sourcePath });
142
+ const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
143
143
  enforcePlanModeWrite(session, sourcePath, { op: "update" });
144
144
 
145
145
  const source = await readHashlineFile(absolutePath, sourcePath);
@@ -3,20 +3,19 @@ begin_patch: "*** Begin Patch" LF
3
3
  end_patch: "*** End Patch" LF?
4
4
 
5
5
  hunk: update_hunk
6
- update_hunk: "@@ " filename LF line_op*
6
+ update_hunk: "$HFILE$" filename LF line_op*
7
7
 
8
8
  filename: /(.+)/
9
9
 
10
- line_op: insert_before | insert_after | replace | delete | blank
11
- insert_before: ("<" | "< ") anchor LF payload+
12
- insert_after: ("+" | "+ ") anchor LF payload+
13
- replace: ("=" | "= ") range LF payload*
14
- delete: ("-" | "- ") range LF
15
- payload: $HSEP$ /(.*)/ LF
10
+ line_op: insert_before | insert_after | replace | blank
11
+ insert_before: "$HOP_INSERT_BEFORE$" anchor LF payload+
12
+ insert_after: "$HOP_INSERT_AFTER$" anchor LF payload+
13
+ replace: "$HOP_REPLACE$" range LF payload*
14
+ payload: /[^$HOP_CHARS$$HFILE$\n][^\n]*/ LF | LF
16
15
  blank: LF
17
16
 
18
17
  anchor: LID | "EOF" | "BOF"
19
- range: LID ".." LID
18
+ range: LID (".." LID)?
20
19
  LID: /[1-9]\d*$HFMT$/
21
20
 
22
21
  %import common.LF
@@ -75,7 +75,13 @@ export function describeAnchorExamples(linePrefix = ""): string {
75
75
  * pass through unchanged.
76
76
  */
77
77
  export function resolveHashlineGrammarPlaceholders(grammar: string): string {
78
- return grammar.replaceAll("$HFMT$", "[a-z]{2}").replaceAll("$HSEP$", JSON.stringify(HL_EDIT_SEP));
78
+ return grammar
79
+ .replaceAll("$HFMT$", "[a-z]{2}")
80
+ .replaceAll("$HOP_INSERT_BEFORE$", HL_OP_INSERT_BEFORE)
81
+ .replaceAll("$HOP_INSERT_AFTER$", HL_OP_INSERT_AFTER)
82
+ .replaceAll("$HOP_REPLACE$", HL_OP_REPLACE)
83
+ .replaceAll("$HOP_CHARS$", HL_OP_CHARS)
84
+ .replaceAll("$HFILE$", HL_FILE_PREFIX);
79
85
  }
80
86
 
81
87
  /** @deprecated Use {@link resolveHashlineGrammarPlaceholders}. */
@@ -84,51 +90,23 @@ export const resolveLarkLidPlaceholders = resolveHashlineGrammarPlaceholders;
84
90
  const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
91
 
86
92
  /**
87
- * Single source of truth for the hashline edit payload separator. This is the
88
- * configured separator that starts inserted/replacement payload lines in
89
- * hashline edit input (`<separator>TEXT`) and separates inline modify ops from
90
- * their appended/prepended text.
93
+ * Hashline edit input markers. File section headers start with {@link HL_FILE_PREFIX};
94
+ * op lines start with a direction/action sigil: {@link HL_OP_INSERT_BEFORE},
95
+ * {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. Payload lines are
96
+ * verbatim file content and have no per-line marker.
91
97
  *
92
- * Override at runtime with the `PI_HL_SEP` env var (e.g.
93
- * `PI_HL_SEP=">"`, `PI_HL_SEP="\\"`). The value is read once at module load;
94
- * the edit grammar, prompt helper, and edit parser derive from it.
95
- *
96
- * Default is `~`, chosen empirically. Benchmark across 8 candidate separators
97
- * x 3 models (glm-4.7:nitro, gpt-5.4-nano, claude-sonnet-4-6), 24-48 runs per
98
- * cell, hashline variant, 12 sampled tasks per run:
99
- *
100
- * sep | task ✓ | edit ✓ | patch fail | tok/run
101
- * ----|--------|--------|-----------------|--------
102
- * + | 70.8% | 78.0% | 27/125 (21.6%) | 32,127
103
- * ÷ | 70.7% | 90.6% | 22/211 (10.4%) | 31,666
104
- * ~ | 69.4% | 94.9% | 6/107 ( 5.6%) | 30,529 <-- default
105
- * > | 69.2% | 91.5% | 21/219 ( 9.6%) | 30,777
106
- * : | 66.7% | 86.4% | 20/126 (15.9%) | 33,900
107
- * | | 65.9% | 86.9% | 20/127 (15.7%) | 34,589
108
- * \ | 65.5% | 89.8% | 16/124 (12.9%) | 36,010
109
- * % | 63.9% | 92.8% | 11/125 ( 8.8%) | 36,530
110
- *
111
- * `~` wins because:
112
- * - highest edit-tool success rate (94.9%) of any tested separator
113
- * - lowest patch-failure rate (5.6%) — model rarely emits a malformed payload
114
- * - cheapest in tokens alongside `>` (no retry overhead from format collisions)
115
- * - no line-leading role in any mainstream language, markdown, diff, regex,
116
- * or shell, so payload lines are unambiguous to both the parser and models
117
- * - task-success is statistically tied with `>` and `÷` (within run-to-run
118
- * noise), so the edit-reliability win is free
119
- *
120
- * `+` and `÷` lead on raw task-success but at the cost of ~2-4x more patch
121
- * failures (the model retries until it lands a valid edit). `:`, `|`, `\`
122
- * collide with line-leading syntax (label/object-key, body separator, escape)
123
- * and degrade both edit reliability and intent-match.
98
+ * These constants are the single source of truth for the edit parser, grammar,
99
+ * renderer, and prompt.
124
100
  */
125
- export const HL_EDIT_SEP = (() => {
126
- const sep = process.env.PI_HL_SEP?.trim();
127
- return sep?.length === 1 ? sep : "~";
128
- })();
101
+ export const HL_OP_INSERT_BEFORE = "«";
102
+ export const HL_OP_INSERT_AFTER = "»";
103
+ export const HL_OP_REPLACE = "";
104
+
105
+ /** All hashline edit op sigils, concatenated for fast membership tests. */
106
+ export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}`;
129
107
 
130
- /** Regex-escaped form of {@link HL_EDIT_SEP}, safe for regexes. */
131
- export const HL_EDIT_SEP_RE_RAW = regexEscape(HL_EDIT_SEP);
108
+ /** Hashline edit file section header marker. */
109
+ export const HL_FILE_PREFIX = "§";
132
110
 
133
111
  /** Stable separator for read/search/hashline display output. Intentionally not configurable. */
134
112
  export const HL_BODY_SEP = "|";
@@ -1,8 +1,11 @@
1
1
  import * as path from "node:path";
2
- import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER, FILE_HEADER_PREFIX } from "./constants";
3
- import { HL_EDIT_SEP } from "./hash";
2
+ import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants";
3
+ import { HL_FILE_PREFIX, HL_OP_CHARS } from "./hash";
4
4
  import type { SplitHashlineOptions } from "./types";
5
5
 
6
+ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ const HASHLINE_OP_LINE_RE = new RegExp(`^[${regexEscape(HL_OP_CHARS)}]`);
8
+
6
9
  export interface HashlineInputSection {
7
10
  path: string;
8
11
  diff: string;
@@ -26,19 +29,18 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
26
29
 
27
30
  function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
28
31
  const trimmed = line.trimEnd();
29
- if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
30
- // Some models occasionally emit unified-diff-style "@@ path" (or even longer
31
- // runs of "@"). Strip every leading "@" before resolving the path so those
32
- // stray headers still route to the right file.
32
+ if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
33
+ // Strip a run of leading header markers so canonical `§PATH` and
34
+ // runaway-prefix forms like `§§PATH` / `§§§PATH` route to the same file.
33
35
  let prefixEnd = 0;
34
- while (prefixEnd < trimmed.length && trimmed[prefixEnd] === FILE_HEADER_PREFIX) prefixEnd++;
36
+ while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
35
37
  const rest = trimmed.slice(prefixEnd);
36
38
  if (rest.trim().length === 0) {
37
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
39
+ throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
38
40
  }
39
41
  const parsedPath = normalizeHashlinePath(rest, cwd);
40
42
  if (parsedPath.length === 0) {
41
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
43
+ throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
42
44
  }
43
45
  return { path: parsedPath, diff: "" };
44
46
  }
@@ -64,7 +66,7 @@ function stripLeadingBlankLines(input: string): string {
64
66
 
65
67
  export function containsRecognizableHashlineOperations(input: string): boolean {
66
68
  for (const line of input.split(/\r?\n/)) {
67
- if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
69
+ if (HASHLINE_OP_LINE_RE.test(line)) return true;
68
70
  }
69
71
  return false;
70
72
  }
@@ -79,7 +81,7 @@ function normalizeFallbackInput(input: string, options: SplitHashlineOptions): s
79
81
  if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
80
82
  const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
81
83
  if (fallbackPath.length === 0) return input;
82
- return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
84
+ return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
83
85
  }
84
86
 
85
87
  export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
@@ -95,8 +97,8 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
95
97
  if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
96
98
  const preview = JSON.stringify(firstLine.slice(0, 120));
97
99
  throw new Error(
98
- `input must begin with "@@ PATH" on the first non-blank line; got: ${preview}. ` +
99
- `Example: "@@ src/foo.ts" then edit ops.`,
100
+ `input must begin with "${HL_FILE_PREFIX}PATH" on the first non-blank line; got: ${preview}. ` +
101
+ `Example: "${HL_FILE_PREFIX}src/foo.ts" then edit ops.`,
100
102
  );
101
103
  }
102
104