@oh-my-pi/pi-coding-agent 15.5.0 → 15.5.2

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.
@@ -20,3 +20,32 @@ export const ABORT_MARKER = "*** Abort";
20
20
  /** Warning text appended to the tool result when ABORT_MARKER terminates parsing. */
21
21
  export const ABORT_WARNING =
22
22
  "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
23
+
24
+ /**
25
+ * Warning text appended when two consecutive `A-B:` ops on the exact same
26
+ * range get coalesced (model painted a before/after pair). The second op
27
+ * wins; the first op's payload is silently discarded.
28
+ */
29
+ export const REPLACE_PAIR_COALESCED_WARNING =
30
+ "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.";
31
+
32
+ /**
33
+ * Warning text appended when un-prefixed continuation lines are accepted as
34
+ * implicit payload (lenient legacy behavior). The model authored a multi-line
35
+ * replace without `+` prefixes; the parser accepted it because the lines did
36
+ * not classify as ops/headers/payloads, but the canonical syntax requires `+`
37
+ * on every continuation line after the op.
38
+ */
39
+ export const IMPLICIT_CONTINUATION_WARNING =
40
+ "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.";
41
+
42
+ /**
43
+ * Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
44
+ * op arrives while an outer `A-B:` replace is still pending and the inner
45
+ * anchor falls inside the outer range. The model used the read-output
46
+ * `LINE:TEXT` format as if it were a payload-continuation line; we strip the
47
+ * `LINE:` prefix and append the body to the pending payload, but warn so the
48
+ * canonical `+`-continuation form remains preferred.
49
+ */
50
+ export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
51
+ "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 a multi-line `A-B:` block, payload lines after the first should be prefixed with `+` — never reuse the read-output gutter format.";
@@ -14,7 +14,7 @@ import { HashlineMismatchError } from "./anchors";
14
14
  import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
15
15
  import { buildCompactHashlineDiffPreview } from "./diff-preview";
16
16
  import { parseHashline } from "./executor";
17
- import { computeFileHash } from "./hash";
17
+ import { computeFileHash, formatHashlineHeader } from "./hash";
18
18
  import { splitHashlineInputs } from "./input";
19
19
  import { tryRecoverHashlineWithCache } from "./recovery";
20
20
  import type {
@@ -224,9 +224,10 @@ async function executeHashlineSection(
224
224
  // of the file: the model just received it back as the diff/preview. Cache
225
225
  // it so a follow-up edit anchored against this state can still recover
226
226
  // if the file is touched out-of-band before the next edit lands.
227
+ const newFileHash = computeFileHash(result.lines);
227
228
  getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
228
229
  fullText: result.lines,
229
- fileHash: computeFileHash(result.lines),
230
+ fileHash: newFileHash,
230
231
  });
231
232
 
232
233
  const diffResult = generateDiffString(originalNormalized, result.lines);
@@ -238,6 +239,7 @@ async function executeHashlineSection(
238
239
  const warnings = [...parseWarnings, ...(result.warnings ?? [])];
239
240
  const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
240
241
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
242
+ const newHashLine = `\n${formatHashlineHeader(sourcePath, newFileHash)}`;
241
243
  const headline = preview.preview
242
244
  ? `${sourcePath}:`
243
245
  : source.exists
@@ -245,7 +247,7 @@ async function executeHashlineSection(
245
247
  : `Created ${sourcePath}`;
246
248
 
247
249
  return {
248
- content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
250
+ content: [{ type: "text", text: `${headline}${newHashLine}${previewBlock}${warningsBlock}` }],
249
251
  details: {
250
252
  diff: diffResult.diff,
251
253
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
@@ -1,5 +1,17 @@
1
- import { ABORT_WARNING } from "./constants";
2
- import { HL_OP_CHARS, HL_OP_DELETE, HL_OP_INSERT_AFTER, HL_OP_INSERT_BEFORE, HL_OP_REPLACE } from "./hash";
1
+ import {
2
+ ABORT_WARNING,
3
+ IMPLICIT_CONTINUATION_WARNING,
4
+ PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
5
+ REPLACE_PAIR_COALESCED_WARNING,
6
+ } from "./constants";
7
+ import {
8
+ HL_OP_CHARS,
9
+ HL_OP_DELETE,
10
+ HL_OP_INSERT_AFTER,
11
+ HL_OP_INSERT_BEFORE,
12
+ HL_OP_REPLACE,
13
+ HL_PAYLOAD_PREFIX,
14
+ } from "./hash";
3
15
  import {
4
16
  cloneCursor,
5
17
  type HashlineToken,
@@ -15,6 +27,14 @@ function validateRangeOrder(range: ParsedRange, lineNum: number): void {
15
27
  }
16
28
  }
17
29
 
30
+ function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
31
+ return a.start.line === b.start.line && a.end.line === b.end.line;
32
+ }
33
+
34
+ function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
35
+ return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
36
+ }
37
+
18
38
  function expandRange(range: ParsedRange): Anchor[] {
19
39
  const anchors: Anchor[] = [];
20
40
  for (let line = range.start.line; line <= range.end.line; line++) {
@@ -30,7 +50,6 @@ type PendingOp =
30
50
  interface Pending {
31
51
  op: PendingOp;
32
52
  payload: string[];
33
- pendingBlanks: number;
34
53
  }
35
54
 
36
55
  /**
@@ -81,16 +100,18 @@ export class HashlineExecutor {
81
100
  this.#terminated = true;
82
101
  return;
83
102
  case "header":
84
- this.#flushPending(false);
103
+ this.#flushPending();
85
104
  return;
86
105
  case "blank":
87
- if (this.#pending) this.#pending.pendingBlanks++;
88
106
  return;
89
107
  case "payload":
90
108
  this.#handlePayload(token.text, token.lineNum);
91
109
  return;
110
+ case "raw":
111
+ this.#handleRaw(token.text, token.lineNum);
112
+ return;
92
113
  case "op-delete":
93
- this.#flushPending(false);
114
+ this.#flushPending();
94
115
  if (token.trailingPayload) {
95
116
  throw new Error(
96
117
  `line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
@@ -102,32 +123,62 @@ export class HashlineExecutor {
102
123
  }
103
124
  return;
104
125
  case "op-insert":
105
- this.#flushPending(false);
126
+ this.#flushPending();
106
127
  this.#pending = {
107
128
  op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
108
- payload: [token.inlineBody ?? ""],
109
- pendingBlanks: 0,
129
+ payload: token.inlineBody === undefined ? [] : [token.inlineBody],
110
130
  };
111
131
  return;
112
132
  case "op-replace":
113
- this.#flushPending(false);
114
133
  validateRangeOrder(token.range, token.lineNum);
134
+ if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
135
+ const outer = this.#pending.op.range;
136
+ const inner = token.range;
137
+ if (rangesEqual(outer, inner)) {
138
+ // Identical-range before/after pair. Drop the "before" payload
139
+ // silently; the second op proceeds as the lone winner. Other
140
+ // overlap shapes (different ranges, replace+delete, delete+delete)
141
+ // still hit the post-hoc validator.
142
+ this.#pending = undefined;
143
+ if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
144
+ this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
145
+ }
146
+ } else if (rangeContains(outer, inner)) {
147
+ // Model wrote a payload line in read-output `LINE:TEXT` format
148
+ // (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
149
+ // The tokenizer can't tell payload from op when the anchor and
150
+ // sigil shape are identical, so demote: append the op's inline
151
+ // body to the pending payload, strip the `LINE:` prefix, and
152
+ // keep accumulating. Without this the inner anchors would each
153
+ // register as their own delete and clash with the outer range.
154
+ this.#pending.payload.push(token.inlineBody ?? "");
155
+ if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
156
+ this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
157
+ }
158
+ return;
159
+ }
160
+ }
161
+ this.#flushPending();
115
162
  this.#pending = {
116
163
  op: { kind: "replace", range: token.range, lineNum: token.lineNum },
117
- payload: [token.inlineBody ?? ""],
118
- pendingBlanks: 0,
164
+ payload: token.inlineBody === undefined ? [] : [token.inlineBody],
119
165
  };
120
166
  return;
121
167
  }
122
168
  }
123
169
 
124
170
  /**
125
- * Flush any open pending op (including its trailing blank lines, which
126
- * are payload-significant) and return the accumulated edits and
127
- * warnings. The executor is single-use; reset() is required for reuse.
171
+ * Flush any open pending op (with its full accumulated payload, including
172
+ * explicit `+` blank lines) and return the accumulated edits and warnings.
173
+ * The executor is single-use; reset() is required for reuse.
174
+ * Throws if two replace/delete ops target the same line with non-identical
175
+ * shapes (different ranges, replace+delete, delete+delete). Identical-range
176
+ * `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
177
+ * warning, so they never reach the validator.
128
178
  */
129
179
  end(): { edits: HashlineEdit[]; warnings: string[] } {
130
- this.#flushPending(true);
180
+ this.#flushPending();
181
+ this.#validateNoOverlappingDeletes();
131
182
  return { edits: this.#edits, warnings: this.#warnings };
132
183
  }
133
184
 
@@ -140,18 +191,69 @@ export class HashlineExecutor {
140
191
  this.#terminated = false;
141
192
  }
142
193
 
194
+ /**
195
+ * Each `:` / `!` op contributes a delete edit per line in its range; if
196
+ * any line ends up targeted by deletes originating from two different
197
+ * source ops (distinguished by their `lineNum`), the patch is internally
198
+ * inconsistent. Identical-range `A-B:` pairs are already collapsed by
199
+ * `feed()`; remaining shapes here are an `A-B:` that overlaps a later
200
+ * `N!`/`N:` with a different range, or two `!` deletes on the same line.
201
+ * The applier would run both literally and the file would end up with two
202
+ * copies of the line, not a chosen winner.
203
+ */
204
+ #validateNoOverlappingDeletes(): void {
205
+ const sourceLinesByAnchor = new Map<number, number[]>();
206
+ for (const edit of this.#edits) {
207
+ if (edit.kind !== "delete") continue;
208
+ let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
209
+ if (sourceLines === undefined) {
210
+ sourceLines = [];
211
+ sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
212
+ }
213
+ if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
214
+ }
215
+ for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
216
+ if (sourceLines.length < 2) continue;
217
+ const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
218
+ throw new Error(
219
+ `line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
220
+ `Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
221
+ );
222
+ }
223
+ }
224
+
143
225
  #handlePayload(text: string, lineNum: number): void {
144
226
  if (this.#pending) {
145
- this.#flushPendingBlanks();
146
227
  this.#pending.payload.push(text);
147
228
  return;
148
229
  }
149
230
 
150
- // Whitespace-only payload outside any pending op is a visual
151
- // separator (matches the legacy outer-loop isBlankLine skip);
152
- // only fully-empty lines arrive as `blank` tokens.
231
+ throw new Error(
232
+ `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
233
+ `Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
234
+ );
235
+ }
236
+
237
+ #handleRaw(text: string, lineNum: number): void {
238
+ if (this.#pending) {
239
+ if (text.trim().length === 0) return;
240
+ // Lenient legacy fallback: the tokenizer routes a line to `raw` only
241
+ // when it does not parse as an op, header, payload, or envelope
242
+ // marker. A `raw` token while a pending op exists is therefore an
243
+ // unambiguous continuation row that the model authored without the
244
+ // `+` prefix. Accept it as payload and warn so the canonical
245
+ // `+`-prefixed form remains preferred.
246
+ this.#pending.payload.push(text);
247
+ if (!this.#warnings.includes(IMPLICIT_CONTINUATION_WARNING)) {
248
+ this.#warnings.push(IMPLICIT_CONTINUATION_WARNING);
249
+ }
250
+ return;
251
+ }
252
+
253
+ // Whitespace-only raw lines outside any pending op are silently dropped;
254
+ // fully empty lines arrive as `blank` tokens.
153
255
  if (text.trim().length === 0) return;
154
- // Orphan payload outside any pending op: pick the most specific
256
+ // Orphan raw text outside any pending op: pick the most specific
155
257
  // diagnostic so the model sees the actionable hint.
156
258
  if (isDeleteOpWithPayload(text)) {
157
259
  throw new Error(
@@ -174,19 +276,12 @@ export class HashlineExecutor {
174
276
  );
175
277
  }
176
278
 
177
- #flushPendingBlanks(): void {
178
- if (!this.#pending) return;
179
- for (let count = 0; count < this.#pending.pendingBlanks; count++) this.#pending.payload.push("");
180
- this.#pending.pendingBlanks = 0;
181
- }
182
-
183
- #flushPending(includeTrailingBlanks: boolean): void {
279
+ #flushPending(): void {
184
280
  const pending = this.#pending;
185
281
  if (!pending) return;
186
- if (includeTrailingBlanks) this.#flushPendingBlanks();
187
282
 
188
283
  const { op, payload } = pending;
189
- const linesToInsert = payload;
284
+ const linesToInsert = payload.length === 0 ? [""] : payload;
190
285
 
191
286
  if (op.kind === "insert") {
192
287
  for (const text of linesToInsert) {
@@ -14,7 +14,7 @@ insert_after: anchor "$HOP_INSERT_AFTER$" inline_body? LF payload*
14
14
  replace: range "$HOP_REPLACE$" inline_body? LF payload*
15
15
  delete: range "$HOP_DELETE$" LF
16
16
  inline_body: /[^\n]+/
17
- payload: /(.*)/ LF
17
+ payload: "+" /[^\n]*/ LF
18
18
 
19
19
  anchor: LID | "EOF" | "BOF"
20
20
  range: LID ("-" LID)?
@@ -7,12 +7,12 @@ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g,
7
7
 
8
8
  /**
9
9
  * Decoration prefix that may precede a line number in tool output:
10
- * `>` (context line in grep), `+` (added line in diff), `-` (removed line),
11
- * `*` (match line). Any combination, in any order, surrounded by optional
10
+ * `>` (context line in grep), `-` (removed line), `*` (match line).
11
+ * Any combination, in any order, surrounded by optional
12
12
  * whitespace. Output formatters emit at most one decoration per line; the
13
13
  * parser stays liberal because it accepts whatever the model echoes back.
14
14
  */
15
- export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>+\\-*]*\\s*`;
15
+ export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
16
16
 
17
17
  /** Capture-group regex source for a decorated bare line-number anchor. */
18
18
  export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
@@ -74,9 +74,9 @@ export function resolveHashlineGrammarPlaceholders(grammar: string): string {
74
74
  /**
75
75
  * op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
76
76
  * {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
77
- * or {@link HL_OP_DELETE}.
78
- * Multi-line payloads follow on subsequent lines as verbatim file content with no
79
- * per-line marker.
77
+ * or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
78
+ * prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
79
+ * payload is written.
80
80
  *
81
81
  * These constants are the single source of truth for the edit parser, grammar,
82
82
  * renderer, and prompt.
@@ -86,6 +86,9 @@ export const HL_OP_INSERT_AFTER = "↓";
86
86
  export const HL_OP_REPLACE = ":";
87
87
  export const HL_OP_DELETE = "!";
88
88
 
89
+ /** Prefix for payload continuation lines. The prefix itself is not written. */
90
+ export const HL_PAYLOAD_PREFIX = "+";
91
+
89
92
  /** All hashline edit op sigils, concatenated for fast membership tests. */
90
93
  export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
91
94
 
@@ -29,6 +29,8 @@ const HASHLINE_RECOVERY_EXTERNAL_WARNING =
29
29
  "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
30
30
  const HASHLINE_RECOVERY_SESSION_CHAIN_WARNING =
31
31
  "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
32
+ const HASHLINE_RECOVERY_SESSION_REPLAY_WARNING =
33
+ "Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
32
34
 
33
35
  function applyEditsToSnapshot(
34
36
  previousText: string,
@@ -62,6 +64,30 @@ function applyEditsToSnapshot(
62
64
  };
63
65
  }
64
66
 
67
+ function replaySessionChainOnCurrent(
68
+ previousText: string,
69
+ currentText: string,
70
+ edits: HashlineEdit[],
71
+ options: HashlineApplyOptions,
72
+ ): HashlineRecoveryResult | null {
73
+ // Only safe when no insert/delete shifted line counts in the prior edit
74
+ // chain: if total line counts match, every line number in `edits` still
75
+ // resolves to the same logical row.
76
+ if (previousText.split("\n").length !== currentText.split("\n").length) return null;
77
+ let applied: HashlineApplyResult;
78
+ try {
79
+ applied = applyHashlineEdits(currentText, edits, options);
80
+ } catch {
81
+ return null;
82
+ }
83
+ if (applied.lines === currentText) return null;
84
+ return {
85
+ lines: applied.lines,
86
+ firstChangedLine: applied.firstChangedLine,
87
+ warnings: [HASHLINE_RECOVERY_SESSION_REPLAY_WARNING, ...(applied.warnings ?? [])],
88
+ };
89
+ }
90
+
65
91
  function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap<number, string>): string {
66
92
  const overlaid = currentText.split("\n");
67
93
  let maxCachedLine = 0;
@@ -95,8 +121,16 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin
95
121
  if (!snapshot || snapshot.lines.size === 0) return null;
96
122
 
97
123
  const recoveryWarning = resolveRecoveryWarning(head, snapshot);
124
+ const isSessionChain = !isHeadSnapshot(head, snapshot);
98
125
  if (snapshot.fullText !== undefined) {
99
- return applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
126
+ const merged = applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
127
+ if (merged !== null) return merged;
128
+ // Session-chain fast-path: prior in-session edit changed the same line(s)
129
+ // the model is now re-targeting with the stale hash. When line counts
130
+ // match, the edits' line numbers still resolve to the right rows — replay
131
+ // onto the current text directly.
132
+ if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits, options);
133
+ return null;
100
134
  }
101
135
 
102
136
  const overlayText = buildSparseOverlayText(currentText, snapshot.lines);
@@ -7,6 +7,7 @@ import {
7
7
  HL_OP_INSERT_AFTER,
8
8
  HL_OP_INSERT_BEFORE,
9
9
  HL_OP_REPLACE,
10
+ HL_PAYLOAD_PREFIX,
10
11
  } from "./hash";
11
12
  import type { Anchor, HashlineCursor } from "./types";
12
13
 
@@ -20,6 +21,7 @@ const CHAR_SPACE = 32;
20
21
  const CHAR_LOWER_A = 97;
21
22
  const CHAR_LOWER_F = 102;
22
23
  const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0);
24
+ const CHAR_PAYLOAD_PREFIX = HL_PAYLOAD_PREFIX.charCodeAt(0);
23
25
  const FILE_HASH_LENGTH = 4;
24
26
 
25
27
  function isDigitCode(code: number): boolean {
@@ -31,7 +33,7 @@ function isNonZeroDigitCode(code: number): boolean {
31
33
  }
32
34
 
33
35
  function isDecorationCode(code: number): boolean {
34
- return code === 42 || code === 43 || code === 45 || code === 62;
36
+ return code === 42 || code === 45 || code === 62;
35
37
  }
36
38
 
37
39
  function isHexDigitCode(code: number): boolean {
@@ -91,7 +93,7 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
91
93
  }
92
94
 
93
95
  // Leniently accept anchors copied from read/search output:
94
- // - optional leading line-marker decoration (`*`, `>`, `+`, `-`)
96
+ // - optional leading line-marker decoration (`*`, `>`, `-`)
95
97
  // - the required bare line number
96
98
  function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
97
99
  let index = skipWhitespace(line, 0, end);
@@ -339,7 +341,8 @@ export type HashlineToken =
339
341
  | (TokenBase & { kind: "op-insert"; cursor: HashlineCursor; inlineBody: string | undefined })
340
342
  | (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
341
343
  | (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean })
342
- | (TokenBase & { kind: "payload"; text: string });
344
+ | (TokenBase & { kind: "payload"; text: string })
345
+ | (TokenBase & { kind: "raw"; text: string });
343
346
 
344
347
  function classifyLine(line: string, lineNum: number): HashlineToken {
345
348
  if (isEmptyLine(line)) return { kind: "blank", lineNum };
@@ -356,6 +359,9 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
356
359
  }
357
360
  }
358
361
 
362
+ if (line.charCodeAt(0) === CHAR_PAYLOAD_PREFIX) {
363
+ return { kind: "payload", lineNum, text: line.slice(HL_PAYLOAD_PREFIX.length) };
364
+ }
359
365
  const op = tryParseOp(line);
360
366
  if (op !== null) {
361
367
  if (op.kind === "insert") {
@@ -367,7 +373,7 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
367
373
  return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload };
368
374
  }
369
375
 
370
- return { kind: "payload", lineNum, text: line };
376
+ return { kind: "raw", lineNum, text: line };
371
377
  }
372
378
 
373
379
  /**