@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.1

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. package/src/edit/modes/hashline.ts +0 -2039
@@ -0,0 +1,732 @@
1
+ import { HashlineMismatchError } from "./anchors";
2
+ import { RANGE_INTERIOR_HASH } from "./constants";
3
+ import { computeLineHash, HL_EDIT_SEP } from "./hash";
4
+ import { cloneCursor } from "./parser";
5
+ import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types";
6
+
7
+ export interface HashlineApplyResult {
8
+ lines: string;
9
+ firstChangedLine?: number;
10
+ warnings?: string[];
11
+ noopEdits?: HashlineNoopEdit[];
12
+ }
13
+
14
+ export interface HashlineNoopEdit {
15
+ editIndex: number;
16
+ loc: string;
17
+ reason: string;
18
+ current: string;
19
+ }
20
+
21
+ type HashlineLineOrigin = "original" | "insert" | "replacement";
22
+
23
+ interface IndexedEdit {
24
+ edit: HashlineEdit;
25
+ idx: number;
26
+ }
27
+
28
+ type HashlineDeleteEdit = Extract<HashlineEdit, { kind: "delete" }>;
29
+
30
+ interface HashlineReplacementGroup {
31
+ startIndex: number;
32
+ endIndex: number;
33
+ sourceLineNum: number;
34
+ replacement: string[];
35
+ deletes: HashlineDeleteEdit[];
36
+ }
37
+
38
+ function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
39
+ if (edit.kind === "delete") return [edit.anchor];
40
+ if (edit.kind === "modify") return [edit.anchor];
41
+ if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
42
+ if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
43
+ return [];
44
+ }
45
+
46
+ /**
47
+ * Verify every anchor's hash. Any mismatch is reported as a `HashMismatch`;
48
+ * there is no auto-rebase. Callers are expected to surface mismatches as
49
+ * `HashlineMismatchError` so the model re-reads and re-anchors.
50
+ */
51
+ function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
52
+ const mismatches: HashMismatch[] = [];
53
+ for (const edit of edits) {
54
+ for (const anchor of getHashlineEditAnchors(edit)) {
55
+ if (anchor.line < 1 || anchor.line > fileLines.length) {
56
+ throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
57
+ }
58
+ if (anchor.hash === RANGE_INTERIOR_HASH) continue;
59
+
60
+ const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
61
+ if (actualHash === anchor.hash) continue;
62
+
63
+ mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
64
+ }
65
+ }
66
+ return mismatches;
67
+ }
68
+
69
+ function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
70
+ if (lines.length === 0) return;
71
+ const origins = lines.map((): HashlineLineOrigin => "insert");
72
+ if (fileLines.length === 1 && fileLines[0] === "") {
73
+ fileLines.splice(0, 1, ...lines);
74
+ lineOrigins.splice(0, 1, ...origins);
75
+ return;
76
+ }
77
+ fileLines.splice(0, 0, ...lines);
78
+ lineOrigins.splice(0, 0, ...origins);
79
+ }
80
+
81
+ function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined {
82
+ if (lines.length === 0) return undefined;
83
+ const origins = lines.map((): HashlineLineOrigin => "insert");
84
+ if (fileLines.length === 1 && fileLines[0] === "") {
85
+ fileLines.splice(0, 1, ...lines);
86
+ lineOrigins.splice(0, 1, ...origins);
87
+ return 1;
88
+ }
89
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
90
+ const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
91
+ fileLines.splice(insertIndex, 0, ...lines);
92
+ lineOrigins.splice(insertIndex, 0, ...origins);
93
+ return insertIndex + 1;
94
+ }
95
+
96
+ /** Bucket edits by the line they target so we can apply each line's group in one splice. */
97
+
98
+ function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
99
+ if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line;
100
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
101
+ return undefined;
102
+ }
103
+
104
+ function collectAnchorTargetLines(edits: HashlineEdit[]): Set<number> {
105
+ const lines = new Set<number>();
106
+ for (const edit of edits) {
107
+ const line = getAnchorTargetLine(edit);
108
+ if (line !== undefined) lines.add(line);
109
+ }
110
+ return lines;
111
+ }
112
+
113
+ function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined {
114
+ const first = edits[startIndex];
115
+ if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
116
+
117
+ const sourceLineNum = first.lineNum;
118
+ const replacement: string[] = [];
119
+ let index = startIndex;
120
+ while (index < edits.length) {
121
+ const edit = edits[index];
122
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
123
+ replacement.push(edit.text);
124
+ index++;
125
+ }
126
+
127
+ const deletes: HashlineDeleteEdit[] = [];
128
+ while (index < edits.length) {
129
+ const edit = edits[index];
130
+ if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
131
+ deletes.push(edit);
132
+ index++;
133
+ }
134
+ if (deletes.length === 0) return undefined;
135
+
136
+ const startLine = deletes[0].anchor.line;
137
+ for (let offset = 0; offset < deletes.length; offset++) {
138
+ if (deletes[offset].anchor.line !== startLine + offset) return undefined;
139
+ }
140
+ const cursorLine = first.cursor.anchor.line;
141
+ if (cursorLine !== startLine) return undefined;
142
+
143
+ return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
144
+ }
145
+
146
+ function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
147
+ const max = Math.min(replacement.length, startLine - 1);
148
+ for (let count = max; count >= 2; count--) {
149
+ let matches = true;
150
+ for (let offset = 0; offset < count; offset++) {
151
+ if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
152
+ matches = false;
153
+ break;
154
+ }
155
+ }
156
+ if (matches) return count;
157
+ }
158
+ return 0;
159
+ }
160
+
161
+ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
162
+ const max = Math.min(replacement.length, fileLines.length - endLine);
163
+ for (let count = max; count >= 2; count--) {
164
+ let matches = true;
165
+ for (let offset = 0; offset < count; offset++) {
166
+ if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
167
+ matches = false;
168
+ break;
169
+ }
170
+ }
171
+ if (matches) return count;
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ // Single-line duplicate absorption is limited to structural closing delimiters.
177
+ // General one-line context is too easy to delete incorrectly, but duplicated
178
+ // `};` / `)` / `]` boundaries usually indicate a replacement range stopped one
179
+ // line early and would otherwise produce a syntax error.
180
+ const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
181
+
182
+ function isStructuralClosingBoundaryLine(line: string): boolean {
183
+ return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
184
+ }
185
+
186
+ interface DelimiterBalance {
187
+ paren: number;
188
+ bracket: number;
189
+ brace: number;
190
+ }
191
+
192
+ const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
193
+
194
+ /**
195
+ * Naive bracket counter — does NOT skip string/template/comment contents. The
196
+ * single-line structural absorb relies on this being safe-by-asymmetry: the
197
+ * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
198
+ * to be pure delimiters, so noise in deleted lines or non-boundary kept payload
199
+ * tends to push `expected !== kept` and biases the heuristic toward NOT
200
+ * absorbing (the safe direction). If we ever extend this to opening boundaries
201
+ * or non-structural single lines, swap this for a real tokenizer.
202
+ */
203
+ function computeDelimiterBalance(lines: string[]): DelimiterBalance {
204
+ const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
205
+ for (const line of lines) {
206
+ for (const char of line) {
207
+ switch (char) {
208
+ case "(":
209
+ balance.paren++;
210
+ break;
211
+ case ")":
212
+ balance.paren--;
213
+ break;
214
+ case "[":
215
+ balance.bracket++;
216
+ break;
217
+ case "]":
218
+ balance.bracket--;
219
+ break;
220
+ case "{":
221
+ balance.brace++;
222
+ break;
223
+ case "}":
224
+ balance.brace--;
225
+ break;
226
+ }
227
+ }
228
+ }
229
+ return balance;
230
+ }
231
+
232
+ function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
233
+ return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
234
+ }
235
+
236
+ /**
237
+ * Decides whether the structural-boundary candidate should be dropped: the
238
+ * `keptPayload` (full payload with the boundary line removed) must restore the
239
+ * caller's `expectedBalance`, while the `fullPayload` (boundary line still
240
+ * present) must NOT. For replacements `expectedBalance` is the deleted
241
+ * region's net delimiter balance; for pure inserts it is zero.
242
+ */
243
+ function shouldDropSingleStructuralBoundary(
244
+ fullPayload: string[],
245
+ keptPayload: string[],
246
+ expectedBalance: DelimiterBalance,
247
+ ): boolean {
248
+ return (
249
+ delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
250
+ !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
251
+ );
252
+ }
253
+
254
+ function countMatchingSingleStructuralPrefixBoundary(
255
+ fileLines: string[],
256
+ startLine: number,
257
+ replacement: string[],
258
+ expectedBalance: DelimiterBalance,
259
+ ): number {
260
+ if (replacement.length === 0 || startLine <= 1) return 0;
261
+ const line = replacement[0];
262
+ if (!isStructuralClosingBoundaryLine(line)) return 0;
263
+ if (fileLines[startLine - 2] !== line) return 0;
264
+ return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
265
+ }
266
+
267
+ function countMatchingSingleStructuralSuffixBoundary(
268
+ fileLines: string[],
269
+ endLine: number,
270
+ replacement: string[],
271
+ expectedBalance: DelimiterBalance,
272
+ ): number {
273
+ if (replacement.length === 0 || endLine >= fileLines.length) return 0;
274
+ const line = replacement[replacement.length - 1];
275
+ if (!isStructuralClosingBoundaryLine(line)) return 0;
276
+ if (fileLines[endLine] !== line) return 0;
277
+ return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
278
+ }
279
+
280
+ function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
281
+ for (const line of lines) {
282
+ if (externalTargetLines.has(line)) return true;
283
+ }
284
+ return false;
285
+ }
286
+
287
+ function contiguousRange(start: number, count: number): number[] {
288
+ return Array.from({ length: count }, (_, offset) => start + offset);
289
+ }
290
+
291
+ function deleteEditForAutoAbsorbedLine(
292
+ line: number,
293
+ sourceLineNum: number,
294
+ index: number,
295
+ fileLines: string[],
296
+ ): HashlineEdit {
297
+ return {
298
+ kind: "delete",
299
+ anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
300
+ lineNum: sourceLineNum,
301
+ index,
302
+ };
303
+ }
304
+
305
+ interface HashlinePureInsertGroup {
306
+ startIndex: number;
307
+ endIndex: number;
308
+ sourceLineNum: number;
309
+ cursor: HashlineCursor;
310
+ payload: string[];
311
+ }
312
+
313
+ function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
314
+ if (a.kind !== b.kind) return false;
315
+ if (a.kind === "bof" || a.kind === "eof") return true;
316
+ const aAnchor = (a as { anchor: Anchor }).anchor;
317
+ const bAnchor = (b as { anchor: Anchor }).anchor;
318
+ return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
319
+ }
320
+
321
+ /**
322
+ * Collects a run of consecutive `insert` edits that all share the same
323
+ * `lineNum` and `cursor`, IFF that run is not immediately followed by a
324
+ * `delete` at the same `lineNum` (which would make it a replacement group
325
+ * instead). Returns the contiguous payload so we can check it for boundary
326
+ * duplicates against the file.
327
+ */
328
+ function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
329
+ const first = edits[startIndex];
330
+ if (first?.kind !== "insert") return undefined;
331
+
332
+ const sourceLineNum = first.lineNum;
333
+ const cursor = first.cursor;
334
+ const payload: string[] = [];
335
+ let index = startIndex;
336
+ while (index < edits.length) {
337
+ const edit = edits[index];
338
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
339
+ if (!cursorMatches(edit.cursor, cursor)) break;
340
+ payload.push(edit.text);
341
+ index++;
342
+ }
343
+
344
+ // If the run is followed by a delete at the same source lineNum, this is a
345
+ // replacement group (handled by absorbReplacement…). Decline.
346
+ if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
347
+ return undefined;
348
+ }
349
+
350
+ return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
351
+ }
352
+
353
+ /**
354
+ * For a pure-insert group, locate the file region adjacent to the insertion
355
+ * point. Returns 0-indexed bounds:
356
+ * - `aboveEndIdx`: index of the last file line strictly above the insertion
357
+ * point (-1 if none).
358
+ * - `belowStartIdx`: index of the first file line strictly below the
359
+ * insertion point (`fileLines.length` if none).
360
+ */
361
+ function pureInsertNeighborhood(
362
+ cursor: HashlineCursor,
363
+ fileLines: string[],
364
+ ): { aboveEndIdx: number; belowStartIdx: number } {
365
+ if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
366
+ if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
367
+ if (cursor.kind === "before_anchor") {
368
+ return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
369
+ }
370
+ // after_anchor
371
+ return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
372
+ }
373
+
374
+ interface PureInsertAbsorbResult {
375
+ keptPayload: string[];
376
+ absorbedLeading: number;
377
+ absorbedTrailing: number;
378
+ leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
379
+ trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
380
+ }
381
+
382
+ /**
383
+ * Mirror of replacement-absorb's prefix/suffix block check, but for pure
384
+ * inserts: drop payload lines that exactly duplicate the file lines
385
+ * immediately above (leading) or immediately below (trailing) the insertion
386
+ * point. Generic context echo absorption requires a minimum run of 2, but a
387
+ * single structural closing delimiter is absorbed because duplicated `}` /
388
+ * `});`-style boundaries almost always mean the insert included adjacent
389
+ * context.
390
+ */
391
+ function tryAbsorbPureInsertGroup(
392
+ group: HashlinePureInsertGroup,
393
+ fileLines: string[],
394
+ allowGenericBoundaryAbsorb: boolean,
395
+ ): PureInsertAbsorbResult {
396
+ const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
397
+ if (group.payload.length === 0) return empty;
398
+
399
+ const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
400
+
401
+ // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
402
+ let absorbedLeading = 0;
403
+ if (allowGenericBoundaryAbsorb) {
404
+ const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
405
+ for (let count = maxLead; count >= 2; count--) {
406
+ let ok = true;
407
+ for (let offset = 0; offset < count; offset++) {
408
+ if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
409
+ ok = false;
410
+ break;
411
+ }
412
+ }
413
+ if (ok) {
414
+ absorbedLeading = count;
415
+ break;
416
+ }
417
+ }
418
+ }
419
+ if (
420
+ absorbedLeading === 0 &&
421
+ group.payload.length > 0 &&
422
+ aboveEndIdx >= 0 &&
423
+ isStructuralClosingBoundaryLine(group.payload[0]) &&
424
+ group.payload[0] === fileLines[aboveEndIdx] &&
425
+ shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE)
426
+ ) {
427
+ absorbedLeading = 1;
428
+ }
429
+
430
+ // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
431
+ // Don't double-count payload lines already absorbed as leading.
432
+ let absorbedTrailing = 0;
433
+ const remainingPayload = group.payload.slice(absorbedLeading);
434
+ const remaining = remainingPayload.length;
435
+ if (allowGenericBoundaryAbsorb) {
436
+ const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
437
+ for (let count = maxTrail; count >= 2; count--) {
438
+ let ok = true;
439
+ for (let offset = 0; offset < count; offset++) {
440
+ if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
441
+ ok = false;
442
+ break;
443
+ }
444
+ }
445
+ if (ok) {
446
+ absorbedTrailing = count;
447
+ break;
448
+ }
449
+ }
450
+ }
451
+ if (
452
+ absorbedTrailing === 0 &&
453
+ remaining > 0 &&
454
+ belowStartIdx < fileLines.length &&
455
+ isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
456
+ remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] &&
457
+ shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE)
458
+ ) {
459
+ absorbedTrailing = 1;
460
+ }
461
+
462
+ if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
463
+
464
+ return {
465
+ keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
466
+ absorbedLeading,
467
+ absorbedTrailing,
468
+ leadingFileRange:
469
+ absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
470
+ trailingFileRange:
471
+ absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
472
+ };
473
+ }
474
+
475
+ function absorbReplacementBoundaryDuplicates(
476
+ edits: HashlineEdit[],
477
+ fileLines: string[],
478
+ warnings: string[],
479
+ options: HashlineApplyOptions,
480
+ ): HashlineEdit[] {
481
+ let nextSyntheticIndex = edits.length;
482
+ const absorbed: HashlineEdit[] = [];
483
+
484
+ // Anchor targets are stable across the loop because we only ever append
485
+ // synthetic deletes (never mutate originals). A line in this set that
486
+ // falls outside the current group's range is necessarily owned by another
487
+ // op, so absorbing it would silently steal its target.
488
+ const allTargetLines = collectAnchorTargetLines(edits);
489
+ const emittedAbsorbKeys = new Set<string>();
490
+
491
+ for (let index = 0; index < edits.length; index++) {
492
+ const group = findReplacementGroup(edits, index);
493
+ if (!group) {
494
+ const pureInsert = findPureInsertGroup(edits, index);
495
+ if (pureInsert) {
496
+ const result = tryAbsorbPureInsertGroup(
497
+ pureInsert,
498
+ fileLines,
499
+ options.autoDropPureInsertDuplicates === true,
500
+ );
501
+ if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
502
+ if (result.leadingFileRange) {
503
+ const { start, end } = result.leadingFileRange;
504
+ const key = `pure-insert-leading:${start}..${end}`;
505
+ if (!emittedAbsorbKeys.has(key)) {
506
+ emittedAbsorbKeys.add(key);
507
+ warnings.push(
508
+ `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
509
+ `(file lines ${start}..${end} already match the payload's leading lines).`,
510
+ );
511
+ }
512
+ }
513
+ if (result.trailingFileRange) {
514
+ const { start, end } = result.trailingFileRange;
515
+ const key = `pure-insert-trailing:${start}..${end}`;
516
+ if (!emittedAbsorbKeys.has(key)) {
517
+ emittedAbsorbKeys.add(key);
518
+ warnings.push(
519
+ `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
520
+ `(file lines ${start}..${end} already match the payload's trailing lines).`,
521
+ );
522
+ }
523
+ }
524
+ for (const text of result.keptPayload) {
525
+ absorbed.push({
526
+ kind: "insert",
527
+ cursor: cloneCursor(pureInsert.cursor),
528
+ text,
529
+ lineNum: pureInsert.sourceLineNum,
530
+ index: nextSyntheticIndex++,
531
+ });
532
+ }
533
+ index = pureInsert.endIndex;
534
+ continue;
535
+ }
536
+ for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
537
+ absorbed.push(edits[groupIndex]);
538
+ }
539
+ index = pureInsert.endIndex;
540
+ continue;
541
+ }
542
+ absorbed.push(edits[index]);
543
+ continue;
544
+ }
545
+
546
+ const startLine = group.deletes[0].anchor.line;
547
+ const endLine = group.deletes[group.deletes.length - 1].anchor.line;
548
+
549
+ const deletedBalance = computeDelimiterBalance(
550
+ group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
551
+ );
552
+ const prefixCount =
553
+ countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
554
+ countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance);
555
+ const suffixCount =
556
+ countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
557
+ countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance);
558
+ const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
559
+ const suffixLines = contiguousRange(endLine + 1, suffixCount);
560
+ const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
561
+ const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
562
+
563
+ if (safePrefixCount > 0) {
564
+ const absorbStart = startLine - safePrefixCount;
565
+ const key = `prefix:${absorbStart}..${startLine - 1}`;
566
+ if (!emittedAbsorbKeys.has(key)) {
567
+ emittedAbsorbKeys.add(key);
568
+ warnings.push(
569
+ `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
570
+ `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
571
+ `widened the deletion to absorb them).`,
572
+ );
573
+ }
574
+ }
575
+ if (safeSuffixCount > 0) {
576
+ const absorbEnd = endLine + safeSuffixCount;
577
+ const key = `suffix:${endLine + 1}..${absorbEnd}`;
578
+ if (!emittedAbsorbKeys.has(key)) {
579
+ emittedAbsorbKeys.add(key);
580
+ warnings.push(
581
+ `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
582
+ `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
583
+ `widened the deletion to absorb them).`,
584
+ );
585
+ }
586
+ }
587
+
588
+ for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
589
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
590
+ }
591
+ for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
592
+ absorbed.push(edits[groupIndex]);
593
+ }
594
+ for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
595
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
596
+ }
597
+
598
+ index = group.endIndex;
599
+ }
600
+
601
+ return absorbed;
602
+ }
603
+
604
+ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
605
+ const byLine = new Map<number, IndexedEdit[]>();
606
+ for (const entry of edits) {
607
+ const line =
608
+ entry.edit.kind === "delete"
609
+ ? entry.edit.anchor.line
610
+ : entry.edit.kind === "modify"
611
+ ? entry.edit.anchor.line
612
+ : entry.edit.cursor.kind === "before_anchor"
613
+ ? entry.edit.cursor.anchor.line
614
+ : 0;
615
+ const bucket = byLine.get(line);
616
+ if (bucket) bucket.push(entry);
617
+ else byLine.set(line, [entry]);
618
+ }
619
+ return byLine;
620
+ }
621
+
622
+ export function applyHashlineEdits(
623
+ text: string,
624
+ edits: HashlineEdit[],
625
+ options: HashlineApplyOptions = {},
626
+ ): HashlineApplyResult {
627
+ if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
628
+
629
+ const fileLines = text.split("\n");
630
+ const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original");
631
+ const warnings: string[] = [];
632
+
633
+ let firstChangedLine: number | undefined;
634
+ const trackFirstChanged = (line: number) => {
635
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
636
+ };
637
+
638
+ const mismatches = validateHashlineAnchors(edits, fileLines);
639
+ if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
640
+
641
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
642
+
643
+ // Normalize after_anchor inserts to before_anchor of the next line, or EOF
644
+ // when the anchor is the final line. This keeps the bucketing logic below
645
+ // (which only knows about before_anchor / bof / eof) untouched.
646
+ for (const edit of normalizedEdits) {
647
+ if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
648
+ const anchorLine = edit.cursor.anchor.line;
649
+ if (anchorLine >= fileLines.length) {
650
+ edit.cursor = { kind: "eof" };
651
+ continue;
652
+ }
653
+ const nextLineNum = anchorLine + 1;
654
+ const nextContent = fileLines[nextLineNum - 1] ?? "";
655
+ edit.cursor = {
656
+ kind: "before_anchor",
657
+ anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
658
+ };
659
+ }
660
+
661
+ // Partition edits into BOF, EOF, and anchor-targeted buckets.
662
+ const bofLines: string[] = [];
663
+ const eofLines: string[] = [];
664
+ const anchorEdits: IndexedEdit[] = [];
665
+ normalizedEdits.forEach((edit, idx) => {
666
+ if (edit.kind === "insert" && edit.cursor.kind === "bof") {
667
+ bofLines.push(edit.text);
668
+ } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
669
+ eofLines.push(edit.text);
670
+ } else {
671
+ anchorEdits.push({ edit, idx });
672
+ }
673
+ });
674
+
675
+ // Apply per-line buckets bottom-up so earlier indices stay valid.
676
+ const byLine = bucketAnchorEditsByLine(anchorEdits);
677
+ for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
678
+ const bucket = byLine.get(line);
679
+ if (!bucket) continue;
680
+ bucket.sort((a, b) => a.idx - b.idx);
681
+
682
+ const idx = line - 1;
683
+ const currentLine = fileLines[idx] ?? "";
684
+ const beforeLines: string[] = [];
685
+ let deleteLine = false;
686
+ let prefix = "";
687
+ let suffix = "";
688
+ let modified = false;
689
+
690
+ for (const { edit } of bucket) {
691
+ if (edit.kind === "insert") {
692
+ beforeLines.push(edit.text);
693
+ } else if (edit.kind === "delete") {
694
+ deleteLine = true;
695
+ } else if (edit.kind === "modify") {
696
+ prefix = edit.prefix + prefix;
697
+ suffix = suffix + edit.suffix;
698
+ modified = true;
699
+ }
700
+ }
701
+ if (beforeLines.length === 0 && !deleteLine && !modified) continue;
702
+ if (deleteLine && modified) {
703
+ throw new Error(
704
+ `line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`,
705
+ );
706
+ }
707
+
708
+ const effectiveLine = modified ? prefix + currentLine + suffix : currentLine;
709
+ const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine];
710
+ const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
711
+ if (!deleteLine) {
712
+ origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original");
713
+ }
714
+
715
+ fileLines.splice(idx, 1, ...replacement);
716
+ lineOrigins.splice(idx, 1, ...origins);
717
+ trackFirstChanged(line);
718
+ }
719
+
720
+ if (bofLines.length > 0) {
721
+ insertAtStart(fileLines, lineOrigins, bofLines);
722
+ trackFirstChanged(1);
723
+ }
724
+ const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
725
+ if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
726
+
727
+ return {
728
+ lines: fileLines.join("\n"),
729
+ firstChangedLine,
730
+ ...(warnings.length > 0 ? { warnings } : {}),
731
+ };
732
+ }