pi-hashline-edit-pro 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,3 @@
1
- /**
2
- * Application — edit span resolution, conflict detection, and assembly.
3
- *
4
- * This module owns the pipeline that turns resolved edits into character-level
5
- * spans, detects conflicts, and applies the spans back-to-front to produce
6
- * the final file content. It also owns the changed-line-range computation
7
- * and the hashline region formatting used by read and edit responses.
8
- */
9
1
 
10
2
  import { throwIfAborted } from "../runtime";
11
3
  import { computeLineHashes } from "./hash";
@@ -19,7 +11,6 @@ import {
19
11
  type HashlineEdit,
20
12
  } from "./resolve";
21
13
 
22
- // ─── Line index ─────────────────────────────────────────────────────────
23
14
 
24
15
  type LineIndex = {
25
16
  fileLines: string[];
@@ -47,7 +38,6 @@ export function buildLineIndex(content: string): LineIndex {
47
38
  };
48
39
  }
49
40
 
50
- // ─── Edit span resolution ───────────────────────────────────────────────
51
41
 
52
42
  type ResolvedEditSpan = {
53
43
  kind: "replace" | "insert";
@@ -319,13 +309,6 @@ function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
319
309
  }
320
310
  }
321
311
 
322
- /**
323
- * Resolve validated edits into ordered, conflict-free character-level spans.
324
- *
325
- * Each edit is mapped through resolveEditToSpan (which may produce a noop),
326
- * duplicate spans are deduplicated, conflicts are rejected, and the remaining
327
- * spans are sorted back-to-front for safe in-place assembly.
328
- */
329
312
  function resolveEditSpans(
330
313
  edits: ResolvedHashlineEdit[],
331
314
  content: string,
@@ -378,10 +361,6 @@ function resolveEditSpans(
378
361
  });
379
362
  }
380
363
 
381
- /**
382
- * Apply ordered spans to content in reverse (back-to-front) order so earlier
383
- * spans' offsets stay valid.
384
- */
385
364
  function assembleEditResult(
386
365
  content: string,
387
366
  spans: ResolvedEditSpan[],
@@ -406,25 +385,7 @@ function assembleEditResult(
406
385
  return result;
407
386
  }
408
387
 
409
- // ─── Main edit engine ───────────────────────────────────────────────────
410
-
411
- /**
412
- * Apply hashline-anchored edits to file content.
413
- *
414
- * Three-phase pipeline:
415
- * 1. validateAnchorEdits — resolve each hash to a line; mismatches are
416
- * rejected with `[E_STALE_ANCHOR]` and collisions with
417
- * `[E_AMBIGUOUS_ANCHOR]`
418
- * 2. resolveEditSpans — map edits to character spans, dedup, conflict-detect, sort
419
- * 3. assembleEditResult — apply spans back-to-front, compute changed range
420
- *
421
- * `precomputedHashes` is an optional per-line hash array from
422
- * `computeLineHashes(content)`. When provided, the same array is used for
423
- * validation AND for the stale-anchor retry block in mismatch errors, so
424
- * the hashes the model sees on a stale-anchor failure match the hashes the
425
- * runtime actually validated against. When omitted, hashes are computed
426
- * once at the top of this function and threaded through all phases.
427
- */
388
+
428
389
  export function applyHashlineEdits(
429
390
  content: string,
430
391
  edits: import("./resolve").HashlineEdit[],
@@ -446,13 +407,7 @@ export function applyHashlineEdits(
446
407
  lastChangedLine: undefined,
447
408
  };
448
409
 
449
- // Normalize `replace` edits: a single-element `lines: [""]` is equivalent
450
- // to `lines: []` (deletion). The "non-empty lines" span branch preserves
451
- // the trailing newline of the last replaced line, which would leave an
452
- // extra blank line behind when the user meant to delete. Models commonly
453
- // emit `[""]` to mean "delete this", and the deletion branch handles the
454
- // trailing newline correctly. (`append`/`prepend` are unaffected — there
455
- // `[""]` legitimately means "insert a blank line".)
410
+ // Normalize lines: [""] to lines: [] for deletion.
456
411
  edits = edits.map((edit) =>
457
412
  edit.op === "replace" &&
458
413
  edit.lines.length === 1 &&
@@ -466,7 +421,6 @@ export function applyHashlineEdits(
466
421
  const noopEdits: NoopEdit[] = [];
467
422
  const warnings: string[] = [];
468
423
 
469
- // Phase 1: validate anchors (and resolve to line numbers)
470
424
  const { resolved, mismatches } = validateAnchorEdits(
471
425
  edits,
472
426
  lineIndex.fileLines,
@@ -484,7 +438,6 @@ export function applyHashlineEdits(
484
438
  warnings.push(...barePrefixWarnings);
485
439
  maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
486
440
 
487
- // Phase 2: resolve edits to ordered spans
488
441
  const orderedSpans = resolveEditSpans(
489
442
  resolved,
490
443
  content,
@@ -493,7 +446,6 @@ export function applyHashlineEdits(
493
446
  signal,
494
447
  );
495
448
 
496
- // Phase 3: assemble result
497
449
  const result = assembleEditResult(content, orderedSpans, signal);
498
450
  assertDoesNotEmptyFile(content, result);
499
451
  const changedRange = computeChangedLineRange(content, result);
@@ -507,17 +459,10 @@ export function applyHashlineEdits(
507
459
  };
508
460
  }
509
461
 
510
- // ─── Affected-line computation (for returning anchors after edit) ───────
511
462
 
512
463
  const ANCHOR_CONTEXT_LINES = 2;
513
464
  const ANCHOR_MAX_OUTPUT_LINES = 12;
514
465
 
515
- /**
516
- * Compute the post-edit line range covering changed lines plus context.
517
- * Uses `firstChangedLine` and `lastChangedLine` from the edit result for
518
- * precise bounds. Returns null if the range (with context) exceeds the
519
- * output budget, signalling that the LLM should re-read instead.
520
- */
521
466
  export function computeAffectedLineRange(params: {
522
467
  firstChangedLine: number | undefined;
523
468
  lastChangedLine: number | undefined;
@@ -537,7 +482,6 @@ export function computeAffectedLineRange(params: {
537
482
  return null;
538
483
  }
539
484
 
540
- // Empty file after edit: no meaningful anchor block.
541
485
  if (resultLineCount === 0) {
542
486
  return null;
543
487
  }
@@ -545,7 +489,6 @@ export function computeAffectedLineRange(params: {
545
489
  const start = Math.max(1, firstChangedLine - contextLines);
546
490
  const end = Math.min(resultLineCount, lastChangedLine + contextLines);
547
491
 
548
- // Guard against inverted range (can happen when context pushes end below start).
549
492
  if (end < start) {
550
493
  return null;
551
494
  }
@@ -557,15 +500,6 @@ export function computeAffectedLineRange(params: {
557
500
  return { start, end };
558
501
  }
559
502
 
560
- /**
561
- * Format a list of lines as `#HASH:content` rows.
562
- *
563
- * Used by the read tool's preview and the changed-mode anchor block. The
564
- * hashes must be the precomputed per-line hashes for the file — see
565
- * `computeLineHashes`. The line number is no longer part of the wire
566
- * format; callers that need line numbers for pagination or context can
567
- * compute them separately.
568
- */
569
503
  export function formatHashlineRegion(
570
504
  hashes: string[],
571
505
  lines: string[],
@@ -576,17 +510,11 @@ export function formatHashlineRegion(
576
510
  );
577
511
  }
578
512
  return lines
579
- .map((line, index) => `${hashes[index]}:${line}`)
513
+ .map((line, index) => `${hashes[index]}│${line}`)
580
514
  .join("\n");
581
515
  }
582
516
 
583
- // ─── Changed line range computation ─────────────────────────────────
584
517
 
585
- /**
586
- * Compute first/last changed line numbers between two document versions.
587
- * Uses character-level diff to locate the changed span, then maps to line
588
- * numbers in the result document so downstream anchor chaining works.
589
- */
590
518
  export function computeChangedLineRange(
591
519
  original: string,
592
520
  result: string,
@@ -1,130 +1,51 @@
1
- /**
2
- * Hash computation — xxHash32-based line hashing with occurrence-aware
3
- * discriminators.
4
- *
5
- * This module owns the hash constants, the xxHash32 wrapper, and the
6
- * per-line hash computation functions. Every other module that needs
7
- * line hashes goes through `computeLineHashes` (full-file) or
8
- * `computeLineHash` (single-line helper).
9
- */
10
1
 
11
2
  import * as XXH from "xxhashjs";
12
3
 
13
- // ─── Constants ──────────────────────────────────────────────────────────
14
-
15
- /**
16
- * Hash length in characters. The original `pi-hashline-edit` uses 2 chars of
17
- * a 16-char alphabet (8 bits / 256 buckets); this fork uses 4 chars of a
18
- * 64-char alphabet (24 bits / 16 777 216 buckets). With HASH_LENGTH=4, the
19
- * birthday paradox stays out of practical concern for any realistic file
20
- * size. Bumping to 5 is a one-line change here if you want to push the
21
- * threshold further; the cost is one more char per anchor in the `read`
22
- * output.
23
- */
4
+
24
5
  export const HASH_LENGTH = 4;
25
6
 
26
- /** Prefix marker for hash anchors. Every anchor starts with `#` so the hash */
27
- /** format is `#` + HASH_LENGTH base64 chars (e.g. `#aB3x`, `#4yN-`). */
28
- export const HASH_PREFIX = "#";
29
-
30
- /** Total wire-format length of an anchor: prefix + hash body. */
31
- export const ANCHOR_LENGTH = HASH_PREFIX.length + HASH_LENGTH;
32
-
33
- /**
34
- * URL-safe base64 alphabet: A–Z, a–z, 0–9, `-`, `_`. 64 distinct chars
35
- * giving 6 bits per hash character. No exclusions, no human-readability
36
- * heuristics — the consumer is an LLM that tokenizes, not a human that
37
- * squints at pixel glyphs. The `-` and `_` are at the end of the string
38
- * so any character class built from this alphabet (e.g. `[${HASH_ALPHABET}]`)
39
- * treats them as literal rather than as range operators.
40
- */
7
+ export const HASH_PREFIX = "";
8
+
9
+ export const ANCHOR_LENGTH = HASH_LENGTH;
10
+
41
11
  const HASH_ALPHABET =
42
12
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
43
13
  const HASH_ALPHABET_BITS = 6;
44
14
  const HASH_ALPHABET_MASK = (1 << HASH_ALPHABET_BITS) - 1;
45
- // `-` must be escaped when used inside a regex character class — otherwise it
46
- // forms a range with the preceding char (`9-_` spans ASCII 57–95, which
47
- // silently swallows the literal `-`). The `_` is always literal.
48
15
  const HASH_ALPHABET_REGEX_SAFE = HASH_ALPHABET.replace(/-/g, "\\-");
49
16
  const HASH_ALPHABET_RE = new RegExp(`^[${HASH_ALPHABET_REGEX_SAFE}]+$`);
50
17
  export const HASH_CHARS_CLASS = `${HASH_PREFIX}[${HASH_ALPHABET_REGEX_SAFE}]{${HASH_LENGTH}}`;
51
-
52
- /**
53
- * Encode the top `HASH_LENGTH * 6` bits of a 32-bit hash value as a
54
- * `HASH_LENGTH`-char string in the URL-safe base64 alphabet.
55
- *
56
- * The 0.2.0/0.3.0 releases pre-computed this mapping as a `DICT` lookup
57
- * table. At 3 chars that was 262 144 entries × 3 chars = ~1 MB of static
58
- * memory; at 4 chars it would be 16 777 216 entries × 4 chars = ~450 MB
59
- * and a multi-second module load. So we now compute the string inline.
60
- * The per-line cost is one xxHash32 call plus `HASH_LENGTH` small string
61
- * concatenations, which is still nanoseconds — this is called once per
62
- * line in `computeLineHashes`, not on a hot path.
63
- */
64
18
  function hashToString(h: number): string {
65
19
  const totalBits = HASH_LENGTH * HASH_ALPHABET_BITS;
66
20
  const shift = 32 - totalBits;
67
21
  let n = h >>> shift;
68
22
  let out = "";
69
23
  for (let j = 0; j < HASH_LENGTH; j++) {
70
- // Build left-to-right: the first iteration writes the high-order
71
- // 6 bits, the last writes the low-order 6 bits.
72
24
  out +=
73
25
  HASH_ALPHABET[
74
26
  (n >>> ((HASH_LENGTH - 1 - j) * HASH_ALPHABET_BITS)) &
75
27
  HASH_ALPHABET_MASK
76
28
  ]!;
77
29
  }
78
- return HASH_PREFIX + out;
30
+ return out;
79
31
  }
80
32
 
81
- /**
82
- * Patterns used to detect (and reject) hashline display prefixes inside edit
83
- * payloads. The runtime no longer strips them — the model must send literal
84
- * file content. Matching any of these triggers `[E_INVALID_PATCH]`.
85
- */
86
33
  export const HASHLINE_PREFIX_RE = new RegExp(
87
- `^\\s*(?:>>>|>>)?\\s*${HASH_CHARS_CLASS}:`,
34
+ `^\\s*(?:>>>|>>)?\\s*${HASH_CHARS_CLASS}│`,
88
35
  );
89
36
  export const HASHLINE_PREFIX_PLUS_RE = new RegExp(
90
- `^\\+\\s*${HASH_CHARS_CLASS}:`,
37
+ `^\\+\\s*${HASH_CHARS_CLASS}│`,
91
38
  );
92
39
  export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
93
40
 
94
- /**
95
- * Bare hashline prefix: a `#` + HASH_LENGTH-char hash followed by ":" with
96
- * no "LINE#" part (e.g. "#KKZ:### heading", "#TPN:text", "#TJZ:"). Capture
97
- * group 1 is the full anchor (including `#` prefix).
98
- *
99
- * This is the partial-hash failure mode from issue #24: the model copies a
100
- * hash it saw in `read` output into the line content but drops the rest
101
- * of the rendered `#HASH:content` form. The anchor (prefix + HASH_LENGTH chars
102
- * + ":") is matched by this regex, then `assertNoBareHashPrefixLines` rejects
103
- * the edit with `[E_BARE_HASH_PREFIX]` so the model gets actionable feedback
104
- * instead of a silent correctness bug.
105
- */
106
- export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS}):`);
107
-
108
- /** Lines containing no alphanumeric characters (only punctuation/symbols/whitespace). */
41
+ export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS})│`);
42
+
109
43
  const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
110
44
 
111
45
  function xxh32(input: string, seed = 0): number {
112
46
  return XXH.h32(seed).update(input).digest().toNumber() >>> 0;
113
47
  }
114
48
 
115
- /**
116
- * Discriminator prefixes for the occurrence-aware hash space.
117
- *
118
- * `S${lineNumber}` puts symbol-only lines (lone `}`, etc.) into a namespace
119
- * keyed by line number, so the same `}` on different lines never collides.
120
- *
121
- * `C${occurrence}` puts content lines into a namespace keyed by the running
122
- * occurrence count of that canonical content, so the same `import {...}` on
123
- * different lines never collides either. This is the key behavioural change
124
- * from the upstream 2-char hash: identical content now hashes to different
125
- * values at different positions, so the model can target a specific
126
- * occurrence without resorting to `offset` + a small `limit` window.
127
- */
128
49
  const SYMBOL_DISCRIMINATOR = (lineNumber: number): string => `S${lineNumber}`;
129
50
  const CONTENT_DISCRIMINATOR = (occurrence: number): string => `C${occurrence}`;
130
51
 
@@ -136,19 +57,6 @@ function isSymbolOnly(canonical: string): boolean {
136
57
  return !RE_SIGNIFICANT.test(canonical);
137
58
  }
138
59
 
139
- /**
140
- * Compute hashes for every line of the file.
141
- *
142
- * Returns an array of length `lines.length`, where index `i` is the hash of
143
- * line `i + 1` (1-indexed). Two lines with the same canonical content get
144
- * different hashes based on which occurrence they are.
145
- *
146
- * The runtime always works from a precomputed array so that all validation,
147
- * formatting, and error-message code paths see the same hash for a given line.
148
- * The standalone `computeLineHash(idx, line)` helper below is kept for
149
- * single-line use (e.g. diff-preview formatting) where occurrence context
150
- * is not available; it treats the input as a 1st-occurrence content line.
151
- */
152
60
  export function computeLineHashes(content: string): string[] {
153
61
  const lines = content.split("\n");
154
62
  const hashes = new Array<string>(lines.length);
@@ -169,16 +77,6 @@ export function computeLineHashes(content: string): string[] {
169
77
  return hashes;
170
78
  }
171
79
 
172
- /**
173
- * Single-line hash for callers that don't have the full file context.
174
- *
175
- * This treats the input as a 1st-occurrence content line (or, for symbol-only
176
- * lines, as the line at index `idx`). It is the right answer for diff-preview
177
- * formatting and for tests that build anchors one line at a time, but it is
178
- * NOT the same as the hash that `computeLineHashes` would produce for the
179
- * same line in a file with duplicate content. Production validation always
180
- * uses `computeLineHashes` + per-line lookup.
181
- */
182
80
  export function computeLineHash(idx: number, line: string): string {
183
81
  const canonical = canonicalizeLine(line);
184
82
  const discriminator = isSymbolOnly(canonical)
@@ -187,7 +85,6 @@ export function computeLineHash(idx: number, line: string): string {
187
85
  return hashToString(xxh32(`${discriminator}:${canonical}`));
188
86
  }
189
87
 
190
- /** Exported for tests and for downstream tools that want to mirror the format. */
191
88
  export const HASH_FORMAT = {
192
89
  prefix: HASH_PREFIX,
193
90
  length: HASH_LENGTH,
@@ -197,5 +94,4 @@ export const HASH_FORMAT = {
197
94
  };
198
95
 
199
96
 
200
- /** Re-export HASH_ALPHABET_RE for parse module */
201
97
  export { HASH_ALPHABET_RE };
@@ -1,35 +1,6 @@
1
- /**
2
- * Hashline engine — hash-anchored line editing.
3
- *
4
- * Forked from pi-hashline-edit (MIT, github.com/RimuruW/pi-hashline-edit),
5
- * which was vendored & adapted from oh-my-pi (MIT, github.com/can1357/oh-my-pi).
6
- *
7
- * This fork preserves the strict semantics of the original (no silent
8
- * relocation, no autocorrection heuristics, no fuzzy fallback) and uses a
9
- * `#`-prefixed hash over a 64-character URL-safe base64 alphabet, giving
10
- * 24 bits of entropy (16 777 216 buckets) per anchor. Birthday-paradox
11
- * collisions become effectively zero for any realistic file size. The
12
- * alphabet is sized for an LLM consumer, not a human reader — the model
13
- * tokenizes, it does not squint at pixel glyphs.
14
- *
15
- * Anchor format: `#` prefix + 4 base64 chars (e.g. `#aB3x`). The line number
16
- * is no longer part of the wire format, and no content may follow the anchor
17
- * either. The model never has to type a line number; the runtime resolves each
18
- * anchor to a line via the file's precomputed hash array.
19
- * a line via the file's precomputed hash array.
20
- *
21
- * On a hash collision (two different lines happen to have the same hash
22
- * — extremely rare at 24 bits) the anchor is rejected with
23
- * `[E_AMBIGUOUS_ANCHOR]`. The model is expected to disambiguate by calling
24
- * `read` again to get fresh hashes.
25
- */
26
1
 
27
- // Re-export everything from sub-modules to preserve the public API surface.
28
- // Consumers should import from "./hashline" (this index) and get the same
29
- // symbols as before the split.
30
2
 
31
3
  export {
32
- // Hash computation
33
4
  HASH_LENGTH,
34
5
  HASH_PREFIX,
35
6
  ANCHOR_LENGTH,
@@ -44,14 +15,12 @@ export {
44
15
  } from "./hash";
45
16
 
46
17
  export {
47
- // Parsing
48
18
  parseHashRef,
49
19
  hashlineParseText,
50
20
  type Anchor,
51
21
  } from "./parse";
52
22
 
53
23
  export {
54
- // Resolution and validation
55
24
  type ResolvedAnchor,
56
25
  type HashlineEdit,
57
26
  type ResolvedHashlineEdit,
@@ -64,7 +33,6 @@ export {
64
33
  } from "./resolve";
65
34
 
66
35
  export {
67
- // Application
68
36
  buildLineIndex,
69
37
  applyHashlineEdits,
70
38
  computeAffectedLineRange,
@@ -1,60 +1,36 @@
1
- /**
2
- * Parsing — anchor parsing and edit content preprocessing.
3
- *
4
- * This module owns the wire-format parsing for hash anchors and the
5
- * content preprocessing that rejects display prefixes in edit payloads.
6
- */
7
1
 
8
2
  import {
9
3
  ANCHOR_LENGTH,
10
- HASH_PREFIX,
11
4
  HASH_ALPHABET_RE,
12
5
  HASH_CHARS_CLASS,
13
6
  HASHLINE_PREFIX_PLUS_RE,
14
7
  DIFF_MINUS_RE,
15
8
  } from "./hash";
16
9
 
17
- // ─── Types ──────────────────────────────────────────────────────────────
18
10
 
19
- /**
20
- * An anchor is just a hash. The hash is the entire wire format for `pos`
21
- * and `end` — the runtime looks it up in the file's precomputed hash array
22
- * to find the line. No content may follow the hash.
23
- */
24
11
  export type Anchor = { hash: string };
25
12
 
26
- // ─── Parsing ────────────────────────────────────────────────────────────
27
13
 
28
14
  function diagnoseHashRef(ref: string): string {
29
15
  const trimmed = ref.trim();
30
16
 
31
17
  if (!trimmed.length) {
32
- return `[E_BAD_REF] Invalid anchor. Expected a hash anchor like "#aB3x" (prefix "#" + 4 base64 chars).`;
18
+ return `[E_BAD_REF] Invalid anchor. Expected a 4-character base64 anchor (e.g. \"aB3x\").`;
33
19
  }
34
20
 
35
- // Detect the legacy "LINE#HASH" form (5#aB3x, 12#MQ, etc.) so we can
36
- // give a clear error pointing at the new format.
37
- if (/^\d+\s*#/.test(trimmed)) {
38
- return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "#aB3x") — no line numbers or trailing content.`;
21
+ if (/^\d+/.test(trimmed)) {
22
+ return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. \"aB3x\") — no line numbers or trailing content.`;
39
23
  }
40
24
 
41
- return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a hash anchor like "#aB3x".`;
25
+ return `[E_BAD_REF] Invalid anchor \"${trimmed}\". Expected a 4-character base64 anchor (e.g. \"aB3x\").`;
42
26
  }
43
27
 
44
28
  function parseAnchorRef(ref: string): Anchor {
45
29
  const trimmed = ref.trim();
46
30
 
47
- // Strict: the wire format is `#` + 4-character hash from the URL-safe base64
48
- // alphabet (A-Za-z0-9-_), copied verbatim from `read` output. The first
49
- // character of the hash body can be `-` (a valid alphabet char), so an anchor
50
- // like `#-qkl` is taken literally. No other form is tolerated: `+`/`-`/`>>>`
51
- // markers from diff contexts or stale-anchor retry blocks are rejected. The
52
- // model must copy just the anchor (prefix + 4 chars) with no surrounding
53
- // characters.
54
31
  if (
55
32
  trimmed.length === ANCHOR_LENGTH &&
56
- trimmed.startsWith(HASH_PREFIX) &&
57
- HASH_ALPHABET_RE.test(trimmed.slice(HASH_PREFIX.length))
33
+ HASH_ALPHABET_RE.test(trimmed)
58
34
  ) {
59
35
  return { hash: trimmed };
60
36
  }
@@ -62,28 +38,9 @@ function parseAnchorRef(ref: string): Anchor {
62
38
  throw new Error(diagnoseHashRef(ref));
63
39
  }
64
40
 
65
- /**
66
- * Parse a hash anchor. Accepts `#HASH` (e.g. `"#aB3x"`) only. The
67
- * `#HASH:content` disambiguator from earlier versions is gone — the anchor
68
- * is the entire wire format for `pos` and `end`, and no content may
69
- * follow it.
70
- *
71
- * Throws `[E_BAD_REF]` for malformed input.
72
- */
73
41
  export const parseHashRef = parseAnchorRef;
74
42
 
75
- // ─── Content preprocessing ──────────────────────────────────────────────
76
43
 
77
- /**
78
- * Reject hashline display prefixes in edit payloads. Strict semantics: the
79
- * model must send literal file content for `lines`, not the rendered read /
80
- * diff form. Silent stripping is no longer performed — see AGENTS.md.
81
- *
82
- * This covers the unambiguous `+HASH:` / diff `+/-` forms, rejectable on
83
- * shape alone. The bare `HHHH:` variant (issue #24) is context-dependent and
84
- * lives in `assertNoBareHashPrefixLines`.
85
- *
86
- */
87
44
  function assertNoDisplayPrefixes(lines: string[]): void {
88
45
  for (const line of lines) {
89
46
  if (!line.length) continue;
@@ -92,20 +49,12 @@ function assertNoDisplayPrefixes(lines: string[]): void {
92
49
  DIFF_MINUS_RE.test(line)
93
50
  ) {
94
51
  throw new Error(
95
- `[E_INVALID_PATCH] "lines" must contain literal file content, not HASH: or diff prefixes. Offending line: ${JSON.stringify(line)}`
52
+ `[E_INVALID_PATCH] \"lines\" must contain literal file content, not HASH| or diff prefixes. Offending line: ${JSON.stringify(line)}`
96
53
  );
97
54
  }
98
55
  }
99
56
  }
100
57
 
101
- /**
102
- * Parse replacement text into lines.
103
- *
104
- * String input is normalized to LF and drops exactly one trailing newline,
105
- * matching read-preview style content. Array input is preserved verbatim so
106
- * explicitly provided blank lines remain intact. Display prefixes are
107
- * rejected by `assertNoDisplayPrefixes`, never silently stripped.
108
- */
109
58
  export function hashlineParseText(edit: string[] | string | null): string[] {
110
59
  if (edit === null) return [];
111
60
  const lines =