@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.
- package/CHANGELOG.md +95 -2
- package/README.md +21 -0
- package/package.json +23 -7
- package/src/cli/grievances-cli.ts +89 -4
- package/src/commands/grievances.ts +33 -7
- package/src/config/prompt-templates.ts +14 -7
- package/src/config/settings-schema.ts +610 -100
- package/src/config/settings.ts +42 -0
- package/src/discovery/helpers.ts +13 -6
- package/src/edit/index.ts +3 -3
- package/src/edit/line-hash.ts +73 -25
- package/src/edit/modes/hashline.lark +10 -3
- package/src/edit/modes/hashline.ts +295 -40
- package/src/edit/renderer.ts +3 -3
- package/src/hindsight/backend.ts +205 -0
- package/src/hindsight/bank.ts +131 -0
- package/src/hindsight/client.ts +598 -0
- package/src/hindsight/config.ts +175 -0
- package/src/hindsight/content.ts +210 -0
- package/src/hindsight/index.ts +8 -0
- package/src/hindsight/mental-models.ts +382 -0
- package/src/hindsight/seeds.json +32 -0
- package/src/hindsight/state.ts +469 -0
- package/src/hindsight/transcript.ts +71 -0
- package/src/main.ts +7 -10
- package/src/memories/index.ts +1 -1
- package/src/memory-backend/index.ts +4 -0
- package/src/memory-backend/local-backend.ts +30 -0
- package/src/memory-backend/off-backend.ts +16 -0
- package/src/memory-backend/resolve.ts +24 -0
- package/src/memory-backend/types.ts +79 -0
- package/src/modes/components/settings-defs.ts +50 -451
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/controllers/command-controller.ts +266 -6
- package/src/modes/controllers/event-controller.ts +12 -0
- package/src/modes/controllers/selector-controller.ts +3 -12
- package/src/modes/theme/theme.ts +4 -0
- package/src/prompts/tools/github.md +3 -0
- package/src/prompts/tools/hashline.md +21 -16
- package/src/prompts/tools/read.md +10 -6
- package/src/prompts/tools/recall.md +5 -0
- package/src/prompts/tools/reflect.md +5 -0
- package/src/prompts/tools/retain.md +5 -0
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +21 -9
- package/src/session/agent-session.ts +118 -3
- package/src/slash-commands/builtin-registry.ts +12 -12
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +2 -0
- package/src/tools/ast-edit.ts +14 -5
- package/src/tools/ast-grep.ts +12 -3
- package/src/tools/find.ts +47 -7
- package/src/tools/gh-renderer.ts +10 -1
- package/src/tools/gh.ts +233 -5
- package/src/tools/hindsight-recall.ts +68 -0
- package/src/tools/hindsight-reflect.ts +55 -0
- package/src/tools/hindsight-retain.ts +60 -0
- package/src/tools/index.ts +20 -0
- package/src/tools/path-utils.ts +55 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/search.ts +45 -8
|
@@ -44,9 +44,12 @@ import {
|
|
|
44
44
|
computeLineHash,
|
|
45
45
|
describeAnchorExamples,
|
|
46
46
|
formatHashLine,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
135
|
-
const
|
|
136
|
-
const
|
|
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
|
|
141
|
-
const
|
|
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(`^${
|
|
144
|
-
const LID_CAPTURE_RE = new RegExp(`^${
|
|
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(
|
|
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 (
|
|
197
|
-
if (
|
|
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 &&
|
|
222
|
-
return line.replace(
|
|
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 =
|
|
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 ${
|
|
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}${
|
|
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)}${
|
|
593
|
+
return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
|
|
589
594
|
case "-":
|
|
590
595
|
removedLines++;
|
|
591
|
-
return `-${lineNumber}--${
|
|
596
|
+
return `-${lineNumber}--${HL_BODY_SEP}${content}`;
|
|
592
597
|
default: {
|
|
593
598
|
const newLineNumber = lineNumber + addedLines - removedLines;
|
|
594
|
-
return ` ${newLineNumber}${computeLineHash(newLineNumber, 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+ "
|
|
611
|
+
// "+ ANCHOR" followed by 1+ "<sep>TEXT" payload lines — insert
|
|
607
612
|
// "- A..B" no payload — delete range
|
|
608
|
-
// "= A..B" followed by 1+ "
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
854
|
-
? entry.edit.
|
|
855
|
-
:
|
|
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
|
|
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
|
-
|
|
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")
|
|
924
|
-
|
|
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
|
|
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)
|
|
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(
|
|
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
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -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
|
|
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(
|
|
327
|
-
const body = line.slice(
|
|
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
|
}
|