@oh-my-pi/pi-coding-agent 14.6.2 → 14.6.4

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 (62) hide show
  1. package/CHANGELOG.md +95 -2
  2. package/README.md +21 -0
  3. package/package.json +23 -7
  4. package/src/cli/grievances-cli.ts +89 -4
  5. package/src/commands/grievances.ts +33 -7
  6. package/src/config/prompt-templates.ts +14 -7
  7. package/src/config/settings-schema.ts +610 -100
  8. package/src/config/settings.ts +42 -0
  9. package/src/discovery/helpers.ts +13 -6
  10. package/src/edit/index.ts +3 -3
  11. package/src/edit/line-hash.ts +73 -25
  12. package/src/edit/modes/hashline.lark +10 -3
  13. package/src/edit/modes/hashline.ts +295 -40
  14. package/src/edit/renderer.ts +3 -3
  15. package/src/hindsight/backend.ts +205 -0
  16. package/src/hindsight/bank.ts +131 -0
  17. package/src/hindsight/client.ts +598 -0
  18. package/src/hindsight/config.ts +175 -0
  19. package/src/hindsight/content.ts +210 -0
  20. package/src/hindsight/index.ts +8 -0
  21. package/src/hindsight/mental-models.ts +382 -0
  22. package/src/hindsight/seeds.json +32 -0
  23. package/src/hindsight/state.ts +469 -0
  24. package/src/hindsight/transcript.ts +71 -0
  25. package/src/main.ts +7 -10
  26. package/src/memories/index.ts +1 -1
  27. package/src/memory-backend/index.ts +4 -0
  28. package/src/memory-backend/local-backend.ts +30 -0
  29. package/src/memory-backend/off-backend.ts +16 -0
  30. package/src/memory-backend/resolve.ts +24 -0
  31. package/src/memory-backend/types.ts +79 -0
  32. package/src/modes/components/settings-defs.ts +50 -451
  33. package/src/modes/components/settings-selector.ts +2 -2
  34. package/src/modes/components/status-line/presets.ts +1 -1
  35. package/src/modes/controllers/command-controller.ts +266 -6
  36. package/src/modes/controllers/event-controller.ts +12 -0
  37. package/src/modes/controllers/selector-controller.ts +3 -12
  38. package/src/modes/theme/theme.ts +4 -0
  39. package/src/prompts/tools/github.md +3 -0
  40. package/src/prompts/tools/hashline.md +21 -16
  41. package/src/prompts/tools/read.md +10 -6
  42. package/src/prompts/tools/recall.md +5 -0
  43. package/src/prompts/tools/reflect.md +5 -0
  44. package/src/prompts/tools/retain.md +5 -0
  45. package/src/prompts/tools/search.md +1 -1
  46. package/src/sdk.ts +21 -9
  47. package/src/session/agent-session.ts +118 -3
  48. package/src/slash-commands/builtin-registry.ts +12 -12
  49. package/src/task/executor.ts +3 -0
  50. package/src/task/index.ts +2 -0
  51. package/src/tools/ast-edit.ts +14 -5
  52. package/src/tools/ast-grep.ts +12 -3
  53. package/src/tools/find.ts +47 -7
  54. package/src/tools/gh-renderer.ts +10 -1
  55. package/src/tools/gh.ts +233 -5
  56. package/src/tools/hindsight-recall.ts +68 -0
  57. package/src/tools/hindsight-reflect.ts +55 -0
  58. package/src/tools/hindsight-retain.ts +60 -0
  59. package/src/tools/index.ts +20 -0
  60. package/src/tools/path-utils.ts +55 -0
  61. package/src/tools/read.ts +1 -1
  62. package/src/tools/search.ts +45 -8
@@ -44,9 +44,12 @@ import {
44
44
  computeLineHash,
45
45
  describeAnchorExamples,
46
46
  formatHashLine,
47
- HASHLINE_ANCHOR_RE_SRC,
48
- HASHLINE_CONTENT_SEPARATOR,
49
- HASHLINE_LID_CAPTURE_RE_SRC,
47
+ HL_ANCHOR_RE_RAW,
48
+ HL_BODY_SEP,
49
+ HL_BODY_SEP_RE_RAW,
50
+ HL_EDIT_SEP,
51
+ HL_EDIT_SEP_RE_RAW,
52
+ HL_HASH_CAPTURE_RE_RAW,
50
53
  } from "../line-hash";
51
54
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
52
55
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
@@ -75,7 +78,8 @@ type HashlineCursor =
75
78
 
76
79
  export type HashlineEdit =
77
80
  | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
78
- | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
81
+ | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
82
+ | { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number };
79
83
 
80
84
  export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
81
85
  export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
@@ -131,17 +135,18 @@ const RANGE_INTERIOR_HASH = "**";
131
135
  /** Header marker introducing a new file section in multi-section input. */
132
136
  const FILE_HEADER_PREFIX = "@";
133
137
 
134
- const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
135
- const HASHLINE_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
136
- const HASHLINE_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
138
+ const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW;
139
+ const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
140
+ const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
141
+ const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
137
142
  const DIFF_PLUS_RE = /^[+](?![+])/;
138
143
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
139
144
 
140
- const HASHLINE_HASH_HINT_RE = /^[a-z]{2}$/i;
141
- const HASHLINE_ANCHOR_EXAMPLES = describeAnchorExamples("160");
145
+ const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
146
+ const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
142
147
 
143
- const PARSE_TAG_RE = new RegExp(`^${HASHLINE_ANCHOR_RE_SRC}`);
144
- const LID_CAPTURE_RE = new RegExp(`^${HASHLINE_LID_CAPTURE_RE_SRC}$`);
148
+ const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
149
+ const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
145
150
 
146
151
  // ───────────────────────────────────────────────────────────────────────────
147
152
  // 4. Small string utilities
@@ -156,7 +161,7 @@ function stripLeadingHashlinePrefixes(line: string): string {
156
161
  let previous: string;
157
162
  do {
158
163
  previous = result;
159
- result = result.replace(HASHLINE_PREFIX_RE, "");
164
+ result = result.replace(HL_PREFIX_RE, "");
160
165
  } while (result !== previous);
161
166
  return result;
162
167
  }
@@ -193,8 +198,8 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
193
198
  continue;
194
199
  }
195
200
  stats.nonEmpty++;
196
- if (HASHLINE_PREFIX_RE.test(line)) stats.hashPrefixCount++;
197
- if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
201
+ if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
202
+ if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
198
203
  if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
199
204
  }
200
205
  return stats;
@@ -218,8 +223,8 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
218
223
  .map(line => {
219
224
  if (stripHash) return stripLeadingHashlinePrefixes(line);
220
225
  if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
221
- if (stats.diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
222
- return line.replace(HASHLINE_PREFIX_RE, "");
226
+ if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
227
+ return line.replace(HL_PREFIX_RE, "");
223
228
  }
224
229
  return line;
225
230
  });
@@ -379,14 +384,14 @@ export async function* streamHashLinesFromUtf8(
379
384
 
380
385
  export function formatFullAnchorRequirement(raw?: string): string {
381
386
  const suffix = typeof raw === "string" ? raw.trim() : "";
382
- const hashOnlyHint = HASHLINE_HASH_HINT_RE.test(suffix)
387
+ const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
383
388
  ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
384
389
  `Copy the full anchor exactly as shown (for example, "160${suffix}").`
385
390
  : "";
386
391
  const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
387
392
  return (
388
393
  `the full anchor exactly as shown by read/search output ` +
389
- `(line number + hash, for example ${HASHLINE_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
394
+ `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
390
395
  );
391
396
  }
392
397
 
@@ -527,7 +532,7 @@ export class HashlineMismatchError extends Error {
527
532
  const text = fileLines[lineNum - 1] ?? "";
528
533
  const hash = computeLineHash(lineNum, text);
529
534
  const marker = mismatchSet.has(lineNum) ? "*" : " ";
530
- lines.push(`${marker}${lineNum}${hash}${HASHLINE_CONTENT_SEPARATOR}${text}`);
535
+ lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
531
536
  }
532
537
  return lines.join("\n");
533
538
  }
@@ -585,13 +590,13 @@ export function buildCompactHashlineDiffPreview(
585
590
  switch (kind) {
586
591
  case "+":
587
592
  addedLines++;
588
- return `+${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
593
+ return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
589
594
  case "-":
590
595
  removedLines++;
591
- return `-${lineNumber}--${HASHLINE_CONTENT_SEPARATOR}${content}`;
596
+ return `-${lineNumber}--${HL_BODY_SEP}${content}`;
592
597
  default: {
593
598
  const newLineNumber = lineNumber + addedLines - removedLines;
594
- return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
599
+ return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`;
595
600
  }
596
601
  }
597
602
  });
@@ -603,9 +608,9 @@ export function buildCompactHashlineDiffPreview(
603
608
  // 10. Edit DSL parsing
604
609
  //
605
610
  // Grammar (one op per "block"):
606
- // "+ ANCHOR" followed by 1+ "|TEXT" payload lines — insert
611
+ // "+ ANCHOR" followed by 1+ "<sep>TEXT" payload lines — insert
607
612
  // "- A..B" no payload — delete range
608
- // "= A..B" followed by 1+ "|TEXT" payload lines — replace
613
+ // "= A..B" followed by 1+ "<sep>TEXT" payload lines — replace
609
614
  //
610
615
  // ANCHOR is `LINE<hash>`, e.g. `160ab`. BOF / EOF are also valid insert targets.
611
616
  // ───────────────────────────────────────────────────────────────────────────
@@ -614,6 +619,8 @@ const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
614
619
  const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
615
620
  const DELETE_OP_RE = /^-\s*(\S+)$/;
616
621
  const REPLACE_OP_RE = /^=\s*(\S+)$/;
622
+ const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
623
+ const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
617
624
 
618
625
  function cloneCursor(cursor: HashlineCursor): HashlineCursor {
619
626
  if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
@@ -631,12 +638,12 @@ function collectPayload(
631
638
  let index = startIndex;
632
639
  while (index < lines.length) {
633
640
  const line = stripTrailingCarriageReturn(lines[index]);
634
- if (!line.startsWith("|")) break;
641
+ if (!line.startsWith(HL_EDIT_SEP)) break;
635
642
  payload.push(line.slice(1));
636
643
  index++;
637
644
  }
638
645
  if (payload.length === 0 && requirePayload) {
639
- throw new Error(`line ${opLineNum}: + and < operations require at least one |TEXT payload line.`);
646
+ throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
640
647
  }
641
648
  return { payload, nextIndex: index };
642
649
  }
@@ -647,6 +654,7 @@ export function parseHashline(diff: string): HashlineEdit[] {
647
654
 
648
655
  export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
649
656
  const edits: HashlineEdit[] = [];
657
+ const warnings: string[] = [];
650
658
  const lines = diff.split("\n");
651
659
  let editIndex = 0;
652
660
 
@@ -662,10 +670,46 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
662
670
  i++;
663
671
  continue;
664
672
  }
665
- if (line.startsWith("|")) {
673
+ if (line.startsWith(HL_EDIT_SEP)) {
666
674
  throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
667
675
  }
668
676
 
677
+ const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line);
678
+ if (inlineBeforeMatch) {
679
+ const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum);
680
+ edits.push({
681
+ kind: "modify",
682
+ anchor,
683
+ prefix: inlineBeforeMatch[3],
684
+ suffix: "",
685
+ lineNum,
686
+ index: editIndex++,
687
+ });
688
+ const cursor: HashlineCursor = { kind: "before_anchor", anchor };
689
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
690
+ for (const text of payload) pushInsert(cursor, text, lineNum);
691
+ i = nextIndex;
692
+ continue;
693
+ }
694
+
695
+ const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line);
696
+ if (inlineAfterMatch) {
697
+ const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum);
698
+ edits.push({
699
+ kind: "modify",
700
+ anchor,
701
+ prefix: "",
702
+ suffix: inlineAfterMatch[3],
703
+ lineNum,
704
+ index: editIndex++,
705
+ });
706
+ const cursor: HashlineCursor = { kind: "after_anchor", anchor };
707
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
708
+ for (const text of payload) pushInsert(cursor, text, lineNum);
709
+ i = nextIndex;
710
+ continue;
711
+ }
712
+
669
713
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
670
714
  if (insertBeforeMatch) {
671
715
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
@@ -716,12 +760,12 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
716
760
  }
717
761
 
718
762
  throw new Error(
719
- `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or |TEXT payload lines. ` +
763
+ `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
720
764
  `Got ${JSON.stringify(line)}.`,
721
765
  );
722
766
  }
723
767
 
724
- return { edits, warnings: [] };
768
+ return { edits, warnings };
725
769
  }
726
770
 
727
771
  // ───────────────────────────────────────────────────────────────────────────
@@ -749,8 +793,19 @@ interface IndexedEdit {
749
793
  idx: number;
750
794
  }
751
795
 
796
+ type HashlineDeleteEdit = Extract<HashlineEdit, { kind: "delete" }>;
797
+
798
+ interface HashlineReplacementGroup {
799
+ startIndex: number;
800
+ endIndex: number;
801
+ sourceLineNum: number;
802
+ replacement: string[];
803
+ deletes: HashlineDeleteEdit[];
804
+ }
805
+
752
806
  function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
753
807
  if (edit.kind === "delete") return [edit.anchor];
808
+ if (edit.kind === "modify") return [edit.anchor];
754
809
  if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
755
810
  if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
756
811
  return [];
@@ -844,15 +899,194 @@ function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lin
844
899
  }
845
900
 
846
901
  /** Bucket edits by the line they target so we can apply each line's group in one splice. */
902
+
903
+ function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
904
+ if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line;
905
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
906
+ return undefined;
907
+ }
908
+
909
+ function collectAnchorTargetLines(edits: HashlineEdit[]): Set<number> {
910
+ const lines = new Set<number>();
911
+ for (const edit of edits) {
912
+ const line = getAnchorTargetLine(edit);
913
+ if (line !== undefined) lines.add(line);
914
+ }
915
+ return lines;
916
+ }
917
+
918
+ function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined {
919
+ const first = edits[startIndex];
920
+ if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
921
+
922
+ const sourceLineNum = first.lineNum;
923
+ const replacement: string[] = [];
924
+ let index = startIndex;
925
+ while (index < edits.length) {
926
+ const edit = edits[index];
927
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
928
+ replacement.push(edit.text);
929
+ index++;
930
+ }
931
+
932
+ const deletes: HashlineDeleteEdit[] = [];
933
+ while (index < edits.length) {
934
+ const edit = edits[index];
935
+ if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
936
+ deletes.push(edit);
937
+ index++;
938
+ }
939
+ if (deletes.length === 0) return undefined;
940
+
941
+ const startLine = deletes[0].anchor.line;
942
+ for (let offset = 0; offset < deletes.length; offset++) {
943
+ if (deletes[offset].anchor.line !== startLine + offset) return undefined;
944
+ }
945
+ const cursorLine = first.cursor.anchor.line;
946
+ if (cursorLine !== startLine) return undefined;
947
+
948
+ return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
949
+ }
950
+
951
+ function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
952
+ const max = Math.min(replacement.length, startLine - 1);
953
+ for (let count = max; count >= 2; count--) {
954
+ let matches = true;
955
+ for (let offset = 0; offset < count; offset++) {
956
+ if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
957
+ matches = false;
958
+ break;
959
+ }
960
+ }
961
+ if (matches) return count;
962
+ }
963
+ return 0;
964
+ }
965
+
966
+ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
967
+ const max = Math.min(replacement.length, fileLines.length - endLine);
968
+ for (let count = max; count >= 2; count--) {
969
+ let matches = true;
970
+ for (let offset = 0; offset < count; offset++) {
971
+ if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
972
+ matches = false;
973
+ break;
974
+ }
975
+ }
976
+ if (matches) return count;
977
+ }
978
+ return 0;
979
+ }
980
+
981
+ function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
982
+ for (const line of lines) {
983
+ if (externalTargetLines.has(line)) return true;
984
+ }
985
+ return false;
986
+ }
987
+
988
+ function contiguousRange(start: number, count: number): number[] {
989
+ return Array.from({ length: count }, (_, offset) => start + offset);
990
+ }
991
+
992
+ function deleteEditForAutoAbsorbedLine(
993
+ line: number,
994
+ sourceLineNum: number,
995
+ index: number,
996
+ fileLines: string[],
997
+ ): HashlineEdit {
998
+ return {
999
+ kind: "delete",
1000
+ anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
1001
+ lineNum: sourceLineNum,
1002
+ index,
1003
+ };
1004
+ }
1005
+
1006
+ function absorbReplacementBoundaryDuplicates(
1007
+ edits: HashlineEdit[],
1008
+ fileLines: string[],
1009
+ warnings: string[],
1010
+ ): HashlineEdit[] {
1011
+ let nextSyntheticIndex = edits.length;
1012
+ const absorbed: HashlineEdit[] = [];
1013
+
1014
+ // Anchor targets are stable across the loop because we only ever append
1015
+ // synthetic deletes (never mutate originals). A line in this set that
1016
+ // falls outside the current group's range is necessarily owned by another
1017
+ // op, so absorbing it would silently steal its target.
1018
+ const allTargetLines = collectAnchorTargetLines(edits);
1019
+ const emittedAbsorbKeys = new Set<string>();
1020
+
1021
+ for (let index = 0; index < edits.length; index++) {
1022
+ const group = findReplacementGroup(edits, index);
1023
+ if (!group) {
1024
+ absorbed.push(edits[index]);
1025
+ continue;
1026
+ }
1027
+
1028
+ const startLine = group.deletes[0].anchor.line;
1029
+ const endLine = group.deletes[group.deletes.length - 1].anchor.line;
1030
+
1031
+ const prefixCount = countMatchingPrefixBlock(fileLines, startLine, group.replacement);
1032
+ const suffixCount = countMatchingSuffixBlock(fileLines, endLine, group.replacement);
1033
+ const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
1034
+ const suffixLines = contiguousRange(endLine + 1, suffixCount);
1035
+ const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
1036
+ const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
1037
+
1038
+ if (safePrefixCount > 0) {
1039
+ const absorbStart = startLine - safePrefixCount;
1040
+ const key = `prefix:${absorbStart}..${startLine - 1}`;
1041
+ if (!emittedAbsorbKeys.has(key)) {
1042
+ emittedAbsorbKeys.add(key);
1043
+ warnings.push(
1044
+ `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
1045
+ `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
1046
+ `widened the deletion to absorb them).`,
1047
+ );
1048
+ }
1049
+ }
1050
+ if (safeSuffixCount > 0) {
1051
+ const absorbEnd = endLine + safeSuffixCount;
1052
+ const key = `suffix:${endLine + 1}..${absorbEnd}`;
1053
+ if (!emittedAbsorbKeys.has(key)) {
1054
+ emittedAbsorbKeys.add(key);
1055
+ warnings.push(
1056
+ `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
1057
+ `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
1058
+ `widened the deletion to absorb them).`,
1059
+ );
1060
+ }
1061
+ }
1062
+
1063
+ for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
1064
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
1065
+ }
1066
+ for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
1067
+ absorbed.push(edits[groupIndex]);
1068
+ }
1069
+ for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
1070
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
1071
+ }
1072
+
1073
+ index = group.endIndex;
1074
+ }
1075
+
1076
+ return absorbed;
1077
+ }
1078
+
847
1079
  function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
848
1080
  const byLine = new Map<number, IndexedEdit[]>();
849
1081
  for (const entry of edits) {
850
1082
  const line =
851
1083
  entry.edit.kind === "delete"
852
1084
  ? entry.edit.anchor.line
853
- : entry.edit.cursor.kind === "before_anchor"
854
- ? entry.edit.cursor.anchor.line
855
- : 0;
1085
+ : entry.edit.kind === "modify"
1086
+ ? entry.edit.anchor.line
1087
+ : entry.edit.cursor.kind === "before_anchor"
1088
+ ? entry.edit.cursor.anchor.line
1089
+ : 0;
856
1090
  const bucket = byLine.get(line);
857
1091
  if (bucket) bucket.push(entry);
858
1092
  else byLine.set(line, [entry]);
@@ -875,10 +1109,12 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
875
1109
  const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
876
1110
  if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
877
1111
 
1112
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings);
1113
+
878
1114
  // Normalize after_anchor inserts to before_anchor of the next line, or EOF
879
1115
  // when the anchor is the final line. This keeps the bucketing logic below
880
1116
  // (which only knows about before_anchor / bof / eof) untouched.
881
- for (const edit of edits) {
1117
+ for (const edit of normalizedEdits) {
882
1118
  if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
883
1119
  const anchorLine = edit.cursor.anchor.line;
884
1120
  if (anchorLine >= fileLines.length) {
@@ -897,7 +1133,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
897
1133
  const bofLines: string[] = [];
898
1134
  const eofLines: string[] = [];
899
1135
  const anchorEdits: IndexedEdit[] = [];
900
- edits.forEach((edit, idx) => {
1136
+ normalizedEdits.forEach((edit, idx) => {
901
1137
  if (edit.kind === "insert" && edit.cursor.kind === "bof") {
902
1138
  bofLines.push(edit.text);
903
1139
  } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
@@ -918,16 +1154,34 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
918
1154
  const currentLine = fileLines[idx] ?? "";
919
1155
  const beforeLines: string[] = [];
920
1156
  let deleteLine = false;
1157
+ let prefix = "";
1158
+ let suffix = "";
1159
+ let modified = false;
921
1160
 
922
1161
  for (const { edit } of bucket) {
923
- if (edit.kind === "insert") beforeLines.push(edit.text);
924
- else deleteLine = true;
1162
+ if (edit.kind === "insert") {
1163
+ beforeLines.push(edit.text);
1164
+ } else if (edit.kind === "delete") {
1165
+ deleteLine = true;
1166
+ } else if (edit.kind === "modify") {
1167
+ prefix = edit.prefix + prefix;
1168
+ suffix = suffix + edit.suffix;
1169
+ modified = true;
1170
+ }
1171
+ }
1172
+ if (beforeLines.length === 0 && !deleteLine && !modified) continue;
1173
+ if (deleteLine && modified) {
1174
+ throw new Error(
1175
+ `line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`,
1176
+ );
925
1177
  }
926
- if (beforeLines.length === 0 && !deleteLine) continue;
927
1178
 
928
- const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
1179
+ const effectiveLine = modified ? prefix + currentLine + suffix : currentLine;
1180
+ const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine];
929
1181
  const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
930
- if (!deleteLine) origins[origins.length - 1] = lineOrigins[idx] ?? "original";
1182
+ if (!deleteLine) {
1183
+ origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original");
1184
+ }
931
1185
 
932
1186
  fileLines.splice(idx, 1, ...replacement);
933
1187
  lineOrigins.splice(idx, 1, ...origins);
@@ -1000,7 +1254,7 @@ function stripLeadingBlankLines(input: string): string {
1000
1254
  function containsRecognizableHashlineOperations(input: string): boolean {
1001
1255
  for (const rawLine of input.split("\n")) {
1002
1256
  const line = stripTrailingCarriageReturn(rawLine);
1003
- if (/^[+<=-]\s+/.test(line) || line.startsWith("|")) return true;
1257
+ if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
1004
1258
  }
1005
1259
  return false;
1006
1260
  }
@@ -1119,6 +1373,7 @@ async function readHashlineFile(absolutePath: string): Promise<ReadHashlineFileR
1119
1373
  function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
1120
1374
  return edits.some(edit => {
1121
1375
  if (edit.kind === "delete") return true;
1376
+ if (edit.kind === "modify") return true;
1122
1377
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
1123
1378
  });
1124
1379
  }
@@ -309,7 +309,7 @@ function getCallPreview(
309
309
  }
310
310
 
311
311
  const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
312
- const HASHLINE_INPUT_HEADER_PREFIX = "@";
312
+ const HL_INPUT_HEADER_PREFIX = "@";
313
313
 
314
314
  function normalizeHashlineInputPreviewPath(rawPath: string): string {
315
315
  const trimmed = rawPath.trim();
@@ -323,8 +323,8 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
323
323
  }
324
324
 
325
325
  function parseHashlineInputPreviewHeader(line: string): string | null {
326
- if (!line.startsWith(HASHLINE_INPUT_HEADER_PREFIX)) return null;
327
- const body = line.slice(HASHLINE_INPUT_HEADER_PREFIX.length).trim();
326
+ if (!line.startsWith(HL_INPUT_HEADER_PREFIX)) return null;
327
+ const body = line.slice(HL_INPUT_HEADER_PREFIX.length).trim();
328
328
  const previewPath = normalizeHashlineInputPreviewPath(body);
329
329
  return previewPath.length > 0 ? previewPath : null;
330
330
  }