@oh-my-pi/hashline 15.5.4 → 15.5.6

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/README.md CHANGED
@@ -25,7 +25,9 @@ await fs.writeText(
25
25
  );
26
26
 
27
27
  const patcher = new Patcher({ fs });
28
- const patch = Patch.parse(`¶hello.ts\n1:\n+const greeting = "hello";`);
28
+ const patch = Patch.parse(String.raw`¶hello.ts
29
+ 1:
30
+ |const greeting = "hello";`);
29
31
  const result = await patcher.apply(patch);
30
32
 
31
33
  console.log(result.sections[0].op); // "update"
@@ -45,13 +47,11 @@ session-aware recovery).
45
47
 
46
48
  Inside a hunk:
47
49
 
48
- |Op|Meaning|
49
- |---|---|
50
- |`LINE↑`|Insert before LINE (or `BOF↑` for the beginning of file)|
51
- |`LINE↓`|Insert after LINE (or `EOF↓` for the end of file)|
52
- |`A-B:`|Replace lines A..B (single-anchor `A:` is sugar for `A-A:`)|
53
- |`A-B!`|Delete lines A..B (single-anchor `A!` is sugar for `A-A!`)|
54
- |`+TEXT`|Payload continuation. The `+` prefix is stripped|
50
+ - `A-B:` — anchor lines A..B (single-anchor `A:` is sugar for `A-A:`).
51
+ - `BOF:` / `EOF:` — virtual anchors at the beginning/end of file.
52
+ - `|TEXT` replace-bucket payload. A non-empty replace bucket replaces A..B.
53
+ - `↑TEXT` insert before A (`BOF:` treats `↑`/`↓` equivalently).
54
+ - `↓TEXT` — insert after B (`EOF:` treats `↑`/`↓` equivalently).
55
55
 
56
56
  ## Abstractions
57
57
 
@@ -4,7 +4,6 @@ import type { ApplyOptions, ApplyResult, Edit } from "./types";
4
4
  *
5
5
  * Returns the post-edit text, the first changed line number (1-indexed), and
6
6
  * any diagnostic warnings produced by the auto-absorb heuristics or by the
7
- * structural-boundary delete check. Throws if an anchor is out of bounds or a
8
- * blank-target replace is detected.
7
+ * structural-boundary delete check. Throws if an anchor is out of bounds.
9
8
  */
10
9
  export declare function applyEdits(text: string, edits: Edit[], options?: ApplyOptions): ApplyResult;
@@ -3,18 +3,16 @@
3
3
  * file-hash computation. These are the single source of truth for the
4
4
  * parser, the tokenizer, the prompt, and the formal grammar.
5
5
  */
6
- /** Op sigil used immediately after a line-number anchor to insert before it. */
7
- export declare const HL_OP_INSERT_BEFORE = "\u2191";
8
- /** Op sigil used immediately after a line-number anchor to insert after it. */
9
- export declare const HL_OP_INSERT_AFTER = "\u2193";
10
- /** Op sigil used after a range (or single anchor) to replace its lines. */
6
+ /** Anchor terminator for every hashline operation block. */
11
7
  export declare const HL_OP_REPLACE = ":";
12
- /** Op sigil used after a range (or single anchor) to delete its lines. */
13
- export declare const HL_OP_DELETE = "!";
14
- /** All hashline edit op sigils, concatenated for fast membership tests. */
15
- export declare const HL_OP_CHARS = "\u2191\u2193:!";
16
- /** Prefix for payload continuation lines. The prefix itself is not written. */
17
- export declare const HL_PAYLOAD_PREFIX = "+";
8
+ /** Payload sigil for lines that replace the anchored range in place. */
9
+ export declare const HL_PAYLOAD_REPLACE = "|";
10
+ /** Payload sigil for lines inserted before the anchored range. */
11
+ export declare const HL_PAYLOAD_ABOVE = "\u2191";
12
+ /** Payload sigil for lines inserted after the anchored range. */
13
+ export declare const HL_PAYLOAD_BELOW = "\u2193";
14
+ /** All hashline payload sigils, concatenated for fast membership tests. */
15
+ export declare const HL_PAYLOAD_CHARS = "|\u2191\u2193";
18
16
  /** Hashline edit file-section header marker. */
19
17
  export declare const HL_FILE_PREFIX = "\u00B6";
20
18
  /** Separator between a hashline file path and its file hash. */
@@ -51,6 +51,14 @@ export declare class PatchSection {
51
51
  * just want the result.
52
52
  */
53
53
  applyTo(text: string, options?: ApplyOptions): ApplyResult;
54
+ /**
55
+ * Streaming-tolerant counterpart to {@link applyTo}. Uses
56
+ * {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
57
+ * or a per-token parse error mid-stream) does not throw or emit a phantom
58
+ * empty-payload edit. Intended for incremental diff previews; the writer
59
+ * path should always use {@link applyTo}.
60
+ */
61
+ applyPartialTo(text: string, options?: ApplyOptions): ApplyResult;
54
62
  }
55
63
  /**
56
64
  * A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
@@ -20,34 +20,14 @@ export declare const ABORT_MARKER = "*** Abort";
20
20
  /** Warning text appended to the tool result when {@link ABORT_MARKER} terminates parsing. */
21
21
  export declare const ABORT_WARNING = "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
22
22
  /**
23
- * Warning text appended when two consecutive `A-B:` ops on the exact same
24
- * range get coalesced (model painted a before/after pair). The second op wins;
25
- * the first op's payload is silently discarded.
23
+ * Warning text appended when two consecutive blocks target the exact same
24
+ * concrete range. The second block wins; the first block is discarded.
26
25
  */
27
- export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range \u2014 the payload is the final desired content, never both old and new.";
28
- /**
29
- * Warning text appended when un-prefixed continuation lines are accepted as
30
- * implicit payload (lenient legacy behavior). The author wrote a multi-line
31
- * replace without `+` prefixes; the parser accepted it because the lines did
32
- * not classify as ops/headers/payloads, but the canonical syntax requires `+`
33
- * on every continuation line after the op.
34
- */
35
- export declare const IMPLICIT_CONTINUATION_WARNING = "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
36
- /**
37
- * Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
38
- * op arrives while an outer `A-B:` replace is still pending and the inner
39
- * anchor falls inside the outer range. The author used the read-output
40
- * `LINE:TEXT` format as if it were a payload-continuation line; we strip the
41
- * `LINE:` prefix and append the body to the pending payload, but warn so the
42
- * canonical `+`-continuation form remains preferred.
43
- */
44
- export declare const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING = "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` \u2014 never reuse the read-output gutter format.";
45
- /**
46
- * Warning text appended when an op carries an inline payload (`LINE:TEXT`,
47
- * `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
48
- * by `+`-prefixed payload rows on the next line(s).
49
- */
50
- export declare const INLINE_PAYLOAD_ACCEPTED_WARNING = "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE\u2191CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
26
+ export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range \u2014 payload is the final desired content, never both old and new.";
27
+ /** Error text prefix emitted when an anchor line carries inline payload. */
28
+ export declare const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
29
+ /** Error text emitted when `|` replacement payload targets BOF/EOF. */
30
+ export declare const VIRTUAL_REPLACE_REJECTED_MESSAGE = "BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `\u2191` or `\u2193` payload lines.";
51
31
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
52
32
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
53
33
  /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
@@ -4,8 +4,8 @@ import type { Edit } from "./types";
4
4
  * Token-driven state machine that turns a stream of {@link Token}s into a
5
5
  * flat list of {@link Edit}s.
6
6
  *
7
- * `feed()` accepts tokens one at a time; multi-line payloads accumulate
8
- * until the next op or {@link end} flushes them. After `terminated` flips
7
+ * `feed()` accepts tokens one at a time; block payload rows accumulate until
8
+ * the next anchor block or {@link end} flushes them. After `terminated` flips
9
9
  * true (on `envelope-end` or `abort`) subsequent feeds are silently ignored
10
10
  * so callers can keep draining their tokenizer.
11
11
  */
@@ -15,25 +15,33 @@ export declare class Executor {
15
15
  get terminated(): boolean;
16
16
  /**
17
17
  * Consume one token. After `terminated` flips true subsequent feeds are
18
- * silently ignored so callers can keep draining their tokenizer without
18
+ * silently ignored so callers can keep draining the tokenizer without
19
19
  * explicit early-exit guards.
20
20
  */
21
21
  feed(token: Token): void;
22
22
  /**
23
- * Flush any open pending op (with its full accumulated payload, including
24
- * explicit `+` blank lines) and return the accumulated edits and
25
- * warnings. The executor is single-use; {@link reset} is required for
26
- * reuse.
23
+ * Flush any open pending block and return the accumulated edits and
24
+ * warnings. The executor is single-use; {@link reset} is required for reuse.
27
25
  *
28
- * Throws if two replace/delete ops target the same line with non-identical
29
- * shapes (different ranges, replace+delete, delete+delete). Identical-range
30
- * `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
31
- * warning, so they never reach the validator.
26
+ * Throws if two replacement/delete blocks target the same line with
27
+ * non-identical ranges. Identical-range blocks in the same hunk are
28
+ * coalesced last-wins by `feed()` with a warning, so they never reach the
29
+ * validator.
32
30
  */
33
31
  end(): {
34
32
  edits: Edit[];
35
33
  warnings: string[];
36
34
  };
35
+ /**
36
+ * Streaming-tolerant variant of {@link end}. Identical, except a pending
37
+ * block whose payload has not yet accumulated any rows is treated as still
38
+ * in flight and dropped instead of flushed (which would otherwise preview a
39
+ * destructive bare delete while the model may still be typing payload).
40
+ */
41
+ endStreaming(): {
42
+ edits: Edit[];
43
+ warnings: string[];
44
+ };
37
45
  /** Reset to a fresh state so the same instance can drive another parse. */
38
46
  reset(): void;
39
47
  }
@@ -48,3 +56,21 @@ export declare function parsePatch(diff: string): {
48
56
  edits: Edit[];
49
57
  warnings: string[];
50
58
  };
59
+ /**
60
+ * Streaming-tolerant variant of {@link parsePatch}. Returns whatever edits
61
+ * parsed successfully when the diff is still being typed:
62
+ *
63
+ * - per-token feed errors stop the drain but preserve the edits already
64
+ * collected (the trailing block is malformed mid-stream — wait for the next
65
+ * chunk),
66
+ * - the trailing pending block is dropped if it has no payload yet (avoids a
67
+ * destructive bare-delete preview while payload may still be coming).
68
+ *
69
+ * Throws only on the cross-block overlap validator, which catches conflicting
70
+ * shapes (two replacements/deletes hitting the same anchor). Streaming preview
71
+ * callers should treat any throw here as "no preview this tick".
72
+ */
73
+ export declare function parsePatchStreaming(diff: string): {
74
+ edits: Edit[];
75
+ warnings: string[];
76
+ };
@@ -10,7 +10,7 @@
10
10
  * The tokenizer is intentionally permissive about decorations and prefixes
11
11
  * the model may echo back from `read`/`search` output — leading `*`/`>`/`-`
12
12
  * markers, CR-terminated lines, leading whitespace before line numbers, and
13
- * so on are all stripped before classification.
13
+ * so on are all stripped before anchor classification.
14
14
  */
15
15
  import type { Anchor, Cursor, ParsedRange } from "./types";
16
16
  /**
@@ -24,14 +24,17 @@ import type { Anchor, Cursor, ParsedRange } from "./types";
24
24
  */
25
25
  export declare function splitHashlineLines(text: string): string[];
26
26
  export declare function cloneCursor(cursor: Cursor): Cursor;
27
- /** Parse a bare line-number anchor (used by insert ops). Throws on malformed input. */
27
+ /** Parse a bare line-number anchor. Throws on malformed input. */
28
28
  export declare function parseLid(raw: string, lineNum: number): Anchor;
29
- /**
30
- * Returns true when the line scans as `LINE!payload` (delete sigil followed
31
- * by additional content). The parser uses this for the dedicated "deletes
32
- * only" diagnostic, separate from the standard "unrecognized op" path.
33
- */
34
- export declare function isDeleteOpWithPayload(line: string): boolean;
29
+ export type BlockTarget = {
30
+ kind: "range";
31
+ range: ParsedRange;
32
+ } | {
33
+ kind: "bof";
34
+ } | {
35
+ kind: "eof";
36
+ };
37
+ export type PayloadBucket = "above" | "replace" | "below";
35
38
  interface TokenBase {
36
39
  /** 1-indexed line number in the original input stream. */
37
40
  lineNum: number;
@@ -49,19 +52,12 @@ export type Token = (TokenBase & {
49
52
  path: string;
50
53
  fileHash?: string;
51
54
  }) | (TokenBase & {
52
- kind: "op-insert";
53
- cursor: Cursor;
54
- inlineBody: string | undefined;
55
- }) | (TokenBase & {
56
- kind: "op-replace";
57
- range: ParsedRange;
55
+ kind: "op-block";
56
+ target: BlockTarget;
58
57
  inlineBody: string | undefined;
59
- }) | (TokenBase & {
60
- kind: "op-delete";
61
- range: ParsedRange;
62
- trailingPayload: boolean;
63
58
  }) | (TokenBase & {
64
59
  kind: "payload";
60
+ bucket: PayloadBucket;
65
61
  text: string;
66
62
  }) | (TokenBase & {
67
63
  kind: "raw";
@@ -22,7 +22,9 @@ export type Cursor = {
22
22
  /**
23
23
  * A single low-level edit produced by the parser and consumed by the applier.
24
24
  * Multi-line replacements decompose to one `insert` per replacement line plus
25
- * one `delete` per consumed line.
25
+ * one `delete` per consumed line. Replacement inserts are tagged so the applier
26
+ * can distinguish "insert above this deleted line" from "new content for this
27
+ * deleted line" when a source block carries both buckets.
26
28
  */
27
29
  export type Edit = {
28
30
  kind: "insert";
@@ -30,6 +32,7 @@ export type Edit = {
30
32
  text: string;
31
33
  lineNum: number;
32
34
  index: number;
35
+ mode?: "replacement";
33
36
  } | {
34
37
  kind: "delete";
35
38
  anchor: Anchor;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.5.4",
4
+ "version": "15.5.6",
5
5
  "description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
package/src/apply.ts CHANGED
@@ -3,10 +3,8 @@
3
3
  * post-edit lines plus any diagnostic warnings. Pure function: no FS, no
4
4
  * mutation of the input.
5
5
  *
6
- * The applier is conservative about edits that look like authoring mistakes:
6
+ * The applier normalizes common model boundary mistakes:
7
7
  *
8
- * - Replace ops on a blank line with non-empty payload are rejected outright
9
- * (the model almost certainly miscounted; recommend `↑`/`↓` instead).
10
8
  * - Multi-line replacement-boundary duplicates are auto-absorbed (model
11
9
  * echoed surrounding context as if it were payload).
12
10
  * - Single-line structural-boundary duplicates (`}`, `)`, `];`, …) are
@@ -35,6 +33,10 @@ interface ReplacementGroup {
35
33
  deletes: DeleteEdit[];
36
34
  }
37
35
 
36
+ function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert" }> & { mode: "replacement" } {
37
+ return edit.kind === "insert" && edit.mode === "replacement";
38
+ }
39
+
38
40
  function getEditAnchors(edit: Edit): Anchor[] {
39
41
  if (edit.kind === "delete") return [edit.anchor];
40
42
  if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
@@ -56,57 +58,6 @@ function validateLineBounds(edits: Edit[], fileLines: string[]): void {
56
58
  }
57
59
  }
58
60
 
59
- /**
60
- * Refuse a single-line replace whose target line is blank and whose payload is
61
- * non-empty. The author is almost certainly miscounting: `A:CONTENT` overwrites
62
- * the existing line, so applying it to a blank target deletes the blank cadence
63
- * and inserts content in its place. To insert content at a blank line, use
64
- * `A↑` (insert before) or `A↓` (insert after) instead.
65
- *
66
- * Only fires for the simple shape: exactly one `insert(before_anchor A)` + one
67
- * `delete(A)` sharing the same source op line, no other inserts/deletes from
68
- * that op.
69
- */
70
- function detectReplaceOnBlankTarget(edits: Edit[], fileLines: string[]): string | null {
71
- interface Pair {
72
- insert?: Extract<Edit, { kind: "insert" }>;
73
- delete?: Extract<Edit, { kind: "delete" }>;
74
- multi?: boolean;
75
- }
76
- const byOpLine = new Map<number, Pair>();
77
- for (const edit of edits) {
78
- const pair = byOpLine.get(edit.lineNum) ?? {};
79
- if (pair.multi) continue;
80
- if (edit.kind === "insert") {
81
- if (pair.insert) pair.multi = true;
82
- else pair.insert = edit;
83
- } else {
84
- if (pair.delete) pair.multi = true;
85
- else pair.delete = edit;
86
- }
87
- byOpLine.set(edit.lineNum, pair);
88
- }
89
- for (const pair of byOpLine.values()) {
90
- if (pair.multi || !pair.insert || !pair.delete) continue;
91
- const insert = pair.insert;
92
- const del = pair.delete;
93
- if (insert.cursor.kind !== "before_anchor") continue;
94
- if (insert.cursor.anchor.line !== del.anchor.line) continue;
95
- if (insert.text.includes("\n")) continue;
96
- if (insert.text.trim().length === 0) continue;
97
- const targetLine = del.anchor.line;
98
- const oldLine = fileLines[targetLine - 1];
99
- if (oldLine === undefined || oldLine.trim().length !== 0) continue;
100
- return (
101
- `Edit rejected: replace at line ${targetLine} targets a blank line but the payload is non-empty. ` +
102
- `'A:CONTENT' overwrites the line at A; to insert content next to a blank line, use 'A${"\u2191"}' (insert before) ` +
103
- `or 'A${"\u2193"}' (insert after) instead. If you really meant to replace this blank with content, ` +
104
- `widen the range to include surrounding non-blank lines so the intent is explicit.`
105
- );
106
- }
107
- return null;
108
- }
109
-
110
61
  function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
111
62
  if (lines.length === 0) return;
112
63
  const origins = lines.map((): LineOrigin => "insert");
@@ -152,14 +103,14 @@ function collectAnchorTargetLines(edits: Edit[]): Set<number> {
152
103
 
153
104
  function findReplacementGroup(edits: Edit[], startIndex: number): ReplacementGroup | undefined {
154
105
  const first = edits[startIndex];
155
- if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
106
+ if (!isReplacementInsert(first) || first.cursor.kind !== "before_anchor") return undefined;
156
107
 
157
108
  const sourceLineNum = first.lineNum;
158
109
  const replacement: string[] = [];
159
110
  let index = startIndex;
160
111
  while (index < edits.length) {
161
112
  const edit = edits[index];
162
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
113
+ if (!isReplacementInsert(edit) || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
163
114
  replacement.push(edit.text);
164
115
  index++;
165
116
  }
@@ -318,10 +269,9 @@ function countMatchingSingleStructuralSuffixBoundary(
318
269
  /**
319
270
  * Single-line non-structural boundary duplicate detector for replacement
320
271
  * groups. Mirrors the same boundary check the pure-insert absorber uses for
321
- * `ANCHOR↓` (leading) / `ANCHOR↑` (trailing) inserts, but applied to the
322
- * top/bottom edges of an `A-B:payload` range. Catches mistakes like
323
- * `103-138:const X = …` where line 102 already reads `const X = …` and the
324
- * user really meant `103-138!` (delete only).
272
+ * `A:` + `↓` (leading) / `A:` + `↑` (trailing) inserts, but applied to the
273
+ * top/bottom edges of an `A-B:` replacement payload. Catches mistakes like
274
+ * `103-138:` + `|const X = …` where line 102 already reads `const X = …`.
325
275
  *
326
276
  * Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
327
277
  * absorb already runs unconditionally, and the structural single-line
@@ -400,7 +350,7 @@ function cursorMatches(a: Cursor, b: Cursor): boolean {
400
350
  */
401
351
  function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup | undefined {
402
352
  const first = edits[startIndex];
403
- if (first?.kind !== "insert") return undefined;
353
+ if (first?.kind !== "insert" || isReplacementInsert(first)) return undefined;
404
354
 
405
355
  const sourceLineNum = first.lineNum;
406
356
  const cursor = first.cursor;
@@ -408,7 +358,7 @@ function findPureInsertGroup(edits: Edit[], startIndex: number): PureInsertGroup
408
358
  let index = startIndex;
409
359
  while (index < edits.length) {
410
360
  const edit = edits[index];
411
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
361
+ if (edit.kind !== "insert" || isReplacementInsert(edit) || edit.lineNum !== sourceLineNum) break;
412
362
  if (!cursorMatches(edit.cursor, cursor)) break;
413
363
  payload.push(edit.text);
414
364
  index++;
@@ -676,8 +626,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
676
626
  *
677
627
  * Returns the post-edit text, the first changed line number (1-indexed), and
678
628
  * any diagnostic warnings produced by the auto-absorb heuristics or by the
679
- * structural-boundary delete check. Throws if an anchor is out of bounds or a
680
- * blank-target replace is detected.
629
+ * structural-boundary delete check. Throws if an anchor is out of bounds.
681
630
  */
682
631
  export function applyEdits(text: string, edits: Edit[], options: ApplyOptions = {}): ApplyResult {
683
632
  if (edits.length === 0) return { text, firstChangedLine: undefined };
@@ -693,9 +642,6 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
693
642
 
694
643
  validateLineBounds(edits, fileLines);
695
644
 
696
- const blankTargetError = detectReplaceOnBlankTarget(edits, fileLines);
697
- if (blankTargetError !== null) throw new Error(blankTargetError);
698
-
699
645
  const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
700
646
  const targetEdits: Edit[] = [];
701
647
 
@@ -744,20 +690,23 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
744
690
 
745
691
  const idx = line - 1;
746
692
  const currentLine = fileLines[idx] ?? "";
747
- const beforeLines: string[] = [];
693
+ const insertLines: string[] = [];
694
+ const replacementLines: string[] = [];
748
695
  let deleteLine = false;
749
696
 
750
697
  for (const { edit } of bucket) {
751
- if (edit.kind === "insert") {
752
- beforeLines.push(edit.text);
698
+ if (isReplacementInsert(edit)) {
699
+ replacementLines.push(edit.text);
700
+ } else if (edit.kind === "insert") {
701
+ insertLines.push(edit.text);
753
702
  } else if (edit.kind === "delete") {
754
703
  deleteLine = true;
755
704
  }
756
705
  }
757
- if (beforeLines.length === 0 && !deleteLine) continue;
706
+ if (insertLines.length === 0 && replacementLines.length === 0 && !deleteLine) continue;
758
707
 
759
- const replaceMode = beforeLines.length > 0;
760
- if (deleteLine && !replaceMode) {
708
+ const hasReplacementPayload = replacementLines.length > 0;
709
+ if (deleteLine && !hasReplacementPayload) {
761
710
  const balance = computeDelimiterBalance([currentLine]);
762
711
  const trimmedCurrentLine = currentLine.trim();
763
712
  const touchesStructuralBoundary =
@@ -769,15 +718,17 @@ export function applyEdits(text: string, edits: Edit[], options: ApplyOptions =
769
718
  trimmedCurrentLine.endsWith("{");
770
719
  if (balance.paren !== 0 || balance.bracket !== 0 || balance.brace !== 0 || touchesStructuralBoundary) {
771
720
  warnings.push(
772
- `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use 'A:<replacement>' to keep the boundary intact.`,
721
+ `Deleted line ${line} contains a structural bracket/brace boundary (${JSON.stringify(trimmedCurrentLine)}); verify the file is still balanced or use '|replacement' payload to keep the boundary intact.`,
773
722
  );
774
723
  }
775
724
  }
776
- const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
777
- const origins = replacement.map((): LineOrigin => (deleteLine ? "replacement" : "insert"));
778
- if (!deleteLine) {
779
- origins[origins.length - 1] = lineOrigins[idx] ?? "original";
780
- }
725
+ const replacement = deleteLine
726
+ ? [...insertLines, ...replacementLines]
727
+ : [...insertLines, ...replacementLines, currentLine];
728
+ const origins: LineOrigin[] = [];
729
+ for (let i = 0; i < insertLines.length; i++) origins.push("insert");
730
+ for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
731
+ if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
781
732
 
782
733
  fileLines.splice(idx, 1, ...replacement);
783
734
  lineOrigins.splice(idx, 1, ...origins);
package/src/format.ts CHANGED
@@ -4,20 +4,18 @@
4
4
  * parser, the tokenizer, the prompt, and the formal grammar.
5
5
  */
6
6
 
7
- /** Op sigil used immediately after a line-number anchor to insert before it. */
8
- export const HL_OP_INSERT_BEFORE = "↑";
9
- /** Op sigil used immediately after a line-number anchor to insert after it. */
10
- export const HL_OP_INSERT_AFTER = "↓";
11
- /** Op sigil used after a range (or single anchor) to replace its lines. */
7
+ /** Anchor terminator for every hashline operation block. */
12
8
  export const HL_OP_REPLACE = ":";
13
- /** Op sigil used after a range (or single anchor) to delete its lines. */
14
- export const HL_OP_DELETE = "!";
15
9
 
16
- /** All hashline edit op sigils, concatenated for fast membership tests. */
17
- export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
10
+ /** Payload sigil for lines that replace the anchored range in place. */
11
+ export const HL_PAYLOAD_REPLACE = "|";
12
+ /** Payload sigil for lines inserted before the anchored range. */
13
+ export const HL_PAYLOAD_ABOVE = "↑";
14
+ /** Payload sigil for lines inserted after the anchored range. */
15
+ export const HL_PAYLOAD_BELOW = "↓";
18
16
 
19
- /** Prefix for payload continuation lines. The prefix itself is not written. */
20
- export const HL_PAYLOAD_PREFIX = "+";
17
+ /** All hashline payload sigils, concatenated for fast membership tests. */
18
+ export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_ABOVE}${HL_PAYLOAD_BELOW}`;
21
19
 
22
20
  /** Hashline edit file-section header marker. */
23
21
  export const HL_FILE_PREFIX = "¶";
package/src/grammar.lark CHANGED
@@ -3,19 +3,18 @@ begin_patch: "*** Begin Patch" LF
3
3
  end_patch: "*** End Patch" LF?
4
4
 
5
5
  hunk: update_hunk
6
- update_hunk: "¶" filename ("#" file_hash)? LF line_op*
6
+ update_hunk: "¶" filename ("#" file_hash)? LF block*
7
7
 
8
8
  filename: /([^\s#]+)/
9
9
  file_hash: /[0-9a-f]{4}/
10
10
 
11
- line_op: insert_before | insert_after | replace | delete
12
- insert_before: anchor "↑" LF payload*
13
- insert_after: anchor "" LF payload*
14
- replace: range ":" LF payload*
15
- delete: range "!" LF
16
- payload: "+" /[^\n]*/ LF
11
+ block: anchor ":" LF payload*
12
+ payload: above_payload | replace_payload | below_payload
13
+ above_payload: "" /[^\n]*/ LF
14
+ replace_payload: "|" /[^\n]*/ LF
15
+ below_payload: "" /[^\n]*/ LF
17
16
 
18
- anchor: LID | "EOF" | "BOF"
17
+ anchor: range | "BOF" | "EOF"
19
18
  range: LID ("-" LID)?
20
19
  LID: /[1-9]\d*/
21
20
 
package/src/input.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import * as path from "node:path";
11
11
  import { applyEdits } from "./apply";
12
12
  import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
13
- import { parsePatch } from "./parser";
13
+ import { parsePatch, parsePatchStreaming } from "./parser";
14
14
  import { Tokenizer } from "./tokenizer";
15
15
  import type { ApplyOptions, ApplyResult, Edit, SplitOptions } from "./types";
16
16
 
@@ -235,6 +235,22 @@ export class PatchSection {
235
235
  ? { ...result, warnings: merged }
236
236
  : { text: result.text, firstChangedLine: result.firstChangedLine };
237
237
  }
238
+
239
+ /**
240
+ * Streaming-tolerant counterpart to {@link applyTo}. Uses
241
+ * {@link parsePatchStreaming} so a trailing in-flight op (no payload yet,
242
+ * or a per-token parse error mid-stream) does not throw or emit a phantom
243
+ * empty-payload edit. Intended for incremental diff previews; the writer
244
+ * path should always use {@link applyTo}.
245
+ */
246
+ applyPartialTo(text: string, options: ApplyOptions = {}): ApplyResult {
247
+ const { edits, warnings } = parsePatchStreaming(this.diff);
248
+ const result = applyEdits(text, [...edits], options);
249
+ const merged = warnings.length === 0 ? result.warnings : [...warnings, ...(result.warnings ?? [])];
250
+ return merged && merged.length > 0
251
+ ? { ...result, warnings: merged }
252
+ : { text: result.text, firstChangedLine: result.firstChangedLine };
253
+ }
238
254
  }
239
255
 
240
256
  /**
package/src/messages.ts CHANGED
@@ -27,41 +27,18 @@ export const ABORT_WARNING =
27
27
  "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
28
28
 
29
29
  /**
30
- * Warning text appended when two consecutive `A-B:` ops on the exact same
31
- * range get coalesced (model painted a before/after pair). The second op wins;
32
- * the first op's payload is silently discarded.
30
+ * Warning text appended when two consecutive blocks target the exact same
31
+ * concrete range. The second block wins; the first block is discarded.
33
32
  */
34
33
  export const REPLACE_PAIR_COALESCED_WARNING =
35
- "Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.";
34
+ "Detected two identical-range hashline blocks; kept only the second block. Issue ONE block per range — payload is the final desired content, never both old and new.";
36
35
 
37
- /**
38
- * Warning text appended when un-prefixed continuation lines are accepted as
39
- * implicit payload (lenient legacy behavior). The author wrote a multi-line
40
- * replace without `+` prefixes; the parser accepted it because the lines did
41
- * not classify as ops/headers/payloads, but the canonical syntax requires `+`
42
- * on every continuation line after the op.
43
- */
44
- export const IMPLICIT_CONTINUATION_WARNING =
45
- "Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
36
+ /** Error text prefix emitted when an anchor line carries inline payload. */
37
+ export const INLINE_PAYLOAD_REJECTED_PREFIX = "Inline payload on the anchor line is rejected.";
46
38
 
47
- /**
48
- * Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
49
- * op arrives while an outer `A-B:` replace is still pending and the inner
50
- * anchor falls inside the outer range. The author used the read-output
51
- * `LINE:TEXT` format as if it were a payload-continuation line; we strip the
52
- * `LINE:` prefix and append the body to the pending payload, but warn so the
53
- * canonical `+`-continuation form remains preferred.
54
- */
55
- export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
56
- "Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside an `A-B:` block, every payload line must be on its own row prefixed with `+` — never reuse the read-output gutter format.";
57
-
58
- /**
59
- * Warning text appended when an op carries an inline payload (`LINE:TEXT`,
60
- * `LINE↑CONTENT`, `LINE↓CONTENT`). Canonical syntax is the bare op followed
61
- * by `+`-prefixed payload rows on the next line(s).
62
- */
63
- export const INLINE_PAYLOAD_ACCEPTED_WARNING =
64
- "Accepted inline payload on the op line (e.g. `LINE:CONTENT`, `LINE↑CONTENT`). Canonical syntax is the bare op followed by `+`-prefixed payload rows on the next line(s). Prefer the explicit form.";
39
+ /** Error text emitted when `|` replacement payload targets BOF/EOF. */
40
+ export const VIRTUAL_REPLACE_REJECTED_MESSAGE =
41
+ "BOF:/EOF: anchors are virtual positions and cannot use `|` replacement payload. Use `↑` or `↓` payload lines.";
65
42
 
66
43
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
67
44
  export const RECOVERY_EXTERNAL_WARNING =