@oh-my-pi/pi-coding-agent 15.5.1 → 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.
@@ -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++) {
@@ -83,11 +103,13 @@ export class HashlineExecutor {
83
103
  this.#flushPending();
84
104
  return;
85
105
  case "blank":
86
- if (this.#pending) this.#pending.payload.push("");
87
106
  return;
88
107
  case "payload":
89
108
  this.#handlePayload(token.text, token.lineNum);
90
109
  return;
110
+ case "raw":
111
+ this.#handleRaw(token.text, token.lineNum);
112
+ return;
91
113
  case "op-delete":
92
114
  this.#flushPending();
93
115
  if (token.trailingPayload) {
@@ -104,27 +126,55 @@ export class HashlineExecutor {
104
126
  this.#flushPending();
105
127
  this.#pending = {
106
128
  op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
107
- payload: [token.inlineBody ?? ""],
129
+ payload: token.inlineBody === undefined ? [] : [token.inlineBody],
108
130
  };
109
131
  return;
110
132
  case "op-replace":
111
- this.#flushPending();
112
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();
113
162
  this.#pending = {
114
163
  op: { kind: "replace", range: token.range, lineNum: token.lineNum },
115
- payload: [token.inlineBody ?? ""],
164
+ payload: token.inlineBody === undefined ? [] : [token.inlineBody],
116
165
  };
117
166
  return;
118
167
  }
119
168
  }
120
169
 
121
170
  /**
122
- * Flush any open pending op (with its full accumulated payload, blanks
123
- * included) and return the accumulated edits and warnings. The executor
124
- * is single-use; reset() is required for reuse.
125
- * Throws if two replace/delete ops target the same line that pattern
126
- * means the diff is painting a before/after picture instead of stating
127
- * the final state, and applying both would silently duplicate content.
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
180
  this.#flushPending();
@@ -145,10 +195,11 @@ export class HashlineExecutor {
145
195
  * Each `:` / `!` op contributes a delete edit per line in its range; if
146
196
  * any line ends up targeted by deletes originating from two different
147
197
  * source ops (distinguished by their `lineNum`), the patch is internally
148
- * inconsistent. Common shape: a "before" `A-B:` followed by an "after"
149
- * `A-B:` over the same range, or an `A-B:` that overlaps a later `N!` /
150
- * `N:`. The applier would run both literally and the file would end up
151
- * with two copies of the line, not a chosen winner.
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.
152
203
  */
153
204
  #validateNoOverlappingDeletes(): void {
154
205
  const sourceLinesByAnchor = new Map<number, number[]>();
@@ -177,10 +228,32 @@ export class HashlineExecutor {
177
228
  return;
178
229
  }
179
230
 
180
- // Whitespace-only payload outside any pending op is silently dropped;
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;
181
254
  // fully empty lines arrive as `blank` tokens.
182
255
  if (text.trim().length === 0) return;
183
- // Orphan payload outside any pending op: pick the most specific
256
+ // Orphan raw text outside any pending op: pick the most specific
184
257
  // diagnostic so the model sees the actionable hint.
185
258
  if (isDeleteOpWithPayload(text)) {
186
259
  throw new Error(
@@ -208,7 +281,7 @@ export class HashlineExecutor {
208
281
  if (!pending) return;
209
282
 
210
283
  const { op, payload } = pending;
211
- const linesToInsert = payload;
284
+ const linesToInsert = payload.length === 0 ? [""] : payload;
212
285
 
213
286
  if (op.kind === "insert") {
214
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
  /**