@oh-my-pi/pi-coding-agent 14.8.0 → 14.9.0
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 +38 -0
- package/package.json +16 -7
- package/src/config/model-resolver.ts +92 -35
- package/src/config/prompt-templates.ts +1 -1
- package/src/debug/index.ts +21 -0
- package/src/debug/raw-sse-buffer.ts +229 -0
- package/src/debug/raw-sse.ts +213 -0
- package/src/edit/index.ts +9 -10
- package/src/edit/streaming.ts +6 -5
- package/src/eval/js/context-manager.ts +91 -47
- package/src/extensibility/extensions/loader.ts +9 -3
- package/src/extensibility/extensions/types.ts +10 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
- package/src/hashline/anchors.ts +113 -0
- package/src/hashline/apply.ts +732 -0
- package/src/hashline/bigrams.json +649 -0
- package/src/hashline/constants.ts +8 -0
- package/src/hashline/diff-preview.ts +43 -0
- package/src/hashline/diff.ts +56 -0
- package/src/hashline/execute.ts +268 -0
- package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
- package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
- package/src/hashline/index.ts +14 -0
- package/src/hashline/input.ts +110 -0
- package/src/hashline/parser.ts +220 -0
- package/src/hashline/prefixes.ts +101 -0
- package/src/hashline/recovery.ts +72 -0
- package/src/hashline/stream.ts +123 -0
- package/src/hashline/types.ts +69 -0
- package/src/hashline/utils.ts +3 -0
- package/src/index.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/lsp/render.ts +4 -0
- package/src/memories/index.ts +13 -4
- package/src/modes/components/assistant-message.ts +55 -9
- package/src/modes/components/welcome.ts +114 -38
- package/src/modes/controllers/event-controller.ts +3 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/interactive-mode.ts +50 -11
- package/src/modes/prompt-action-autocomplete.ts +3 -0
- package/src/modes/rpc/rpc-client.ts +53 -2
- package/src/modes/rpc/rpc-mode.ts +67 -1
- package/src/modes/rpc/rpc-types.ts +17 -2
- package/src/modes/types.ts +4 -1
- package/src/modes/utils/ui-helpers.ts +3 -1
- package/src/prompts/agents/reviewer.md +14 -0
- package/src/prompts/tools/hashline.md +57 -10
- package/src/sdk.ts +4 -3
- package/src/session/agent-session.ts +195 -30
- package/src/session/compaction/branch-summarization.ts +4 -2
- package/src/session/compaction/compaction.ts +22 -3
- package/src/task/executor.ts +21 -2
- package/src/task/index.ts +4 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/match-line-format.ts +1 -1
- package/src/tools/read.ts +1 -1
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/title-generator.ts +11 -0
- package/src/edit/modes/hashline.ts +0 -2039
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { FILE_HEADER_PREFIX } from "./constants";
|
|
3
|
+
import { HL_EDIT_SEP } from "./hash";
|
|
4
|
+
import type { SplitHashlineOptions } from "./types";
|
|
5
|
+
import { stripTrailingCarriageReturn } from "./utils";
|
|
6
|
+
|
|
7
|
+
export interface HashlineInputSection {
|
|
8
|
+
path: string;
|
|
9
|
+
diff: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function unquoteHashlinePath(pathText: string): string {
|
|
13
|
+
if (pathText.length < 2) return pathText;
|
|
14
|
+
const first = pathText[0];
|
|
15
|
+
const last = pathText[pathText.length - 1];
|
|
16
|
+
if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
|
|
17
|
+
return pathText;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
|
21
|
+
const unquoted = unquoteHashlinePath(rawPath.trim());
|
|
22
|
+
if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
|
|
23
|
+
const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
|
|
24
|
+
const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
25
|
+
return isWithinCwd ? relative || "." : unquoted;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
|
|
29
|
+
const trimmed = line.trimEnd();
|
|
30
|
+
if (trimmed === FILE_HEADER_PREFIX) {
|
|
31
|
+
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
32
|
+
}
|
|
33
|
+
if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
|
|
34
|
+
const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd);
|
|
35
|
+
if (parsedPath.length === 0) {
|
|
36
|
+
throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
|
|
37
|
+
}
|
|
38
|
+
return { path: parsedPath, diff: "" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripLeadingBlankLines(input: string): string {
|
|
42
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
43
|
+
const lines = stripped.split("\n");
|
|
44
|
+
while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift();
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function containsRecognizableHashlineOperations(input: string): boolean {
|
|
49
|
+
for (const rawLine of input.split("\n")) {
|
|
50
|
+
const line = stripTrailingCarriageReturn(rawLine);
|
|
51
|
+
if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
|
|
57
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
58
|
+
const hasExplicitHeader = stripped
|
|
59
|
+
.split("\n")
|
|
60
|
+
.some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null);
|
|
61
|
+
if (hasExplicitHeader) return input;
|
|
62
|
+
|
|
63
|
+
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
|
64
|
+
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
|
65
|
+
if (fallbackPath.length === 0) return input;
|
|
66
|
+
return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
|
|
70
|
+
const [section] = splitHashlineInputs(input, options);
|
|
71
|
+
return section;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
|
|
75
|
+
const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
|
|
76
|
+
const lines = stripped.split("\n");
|
|
77
|
+
const firstLine = stripTrailingCarriageReturn(lines[0] ?? "");
|
|
78
|
+
|
|
79
|
+
if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
|
|
80
|
+
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
81
|
+
throw new Error(
|
|
82
|
+
`input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` +
|
|
83
|
+
`Example: "@src/foo.ts" then edit ops.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sections: HashlineInputSection[] = [];
|
|
88
|
+
let currentPath = "";
|
|
89
|
+
let currentLines: string[] = [];
|
|
90
|
+
|
|
91
|
+
const flush = () => {
|
|
92
|
+
if (currentPath.length === 0) return;
|
|
93
|
+
sections.push({ path: currentPath, diff: currentLines.join("\n") });
|
|
94
|
+
currentLines = [];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
for (const rawLine of lines) {
|
|
98
|
+
const line = stripTrailingCarriageReturn(rawLine);
|
|
99
|
+
const header = parseHashlineHeaderLine(line, options.cwd);
|
|
100
|
+
if (header !== null) {
|
|
101
|
+
flush();
|
|
102
|
+
currentPath = header.path;
|
|
103
|
+
currentLines = [];
|
|
104
|
+
} else {
|
|
105
|
+
currentLines.push(rawLine);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
flush();
|
|
109
|
+
return sections;
|
|
110
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { RANGE_INTERIOR_HASH } from "./constants";
|
|
2
|
+
import { describeAnchorExamples, HL_EDIT_SEP, HL_EDIT_SEP_RE_RAW, HL_HASH_CAPTURE_RE_RAW } from "./hash";
|
|
3
|
+
import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
|
|
4
|
+
import { stripTrailingCarriageReturn } from "./utils";
|
|
5
|
+
|
|
6
|
+
const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW;
|
|
7
|
+
const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
|
|
8
|
+
|
|
9
|
+
function parseLid(raw: string, lineNum: number): Anchor {
|
|
10
|
+
const match = LID_CAPTURE_RE.exec(raw);
|
|
11
|
+
if (!match) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` +
|
|
14
|
+
`got ${JSON.stringify(raw)}.`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return { line: Number.parseInt(match[1], 10), hash: match[2] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ParsedRange {
|
|
21
|
+
start: Anchor;
|
|
22
|
+
end: Anchor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseRange(raw: string, lineNum: number): ParsedRange {
|
|
26
|
+
if (!raw.includes("..")) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`line ${lineNum}: explicit ranges are required for delete/replace. ` +
|
|
29
|
+
`Repeat the same anchor on both sides for a one-line edit (for example, ` +
|
|
30
|
+
`${describeAnchorExamples("119")}..${describeAnchorExamples("119")}); ` +
|
|
31
|
+
`got ${JSON.stringify(raw)}.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const [startRaw, endRaw, extra] = raw.split("..");
|
|
35
|
+
if (extra !== undefined || !startRaw || !endRaw) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`line ${lineNum}: range must include exactly two full anchors separated by "..". ` +
|
|
38
|
+
`For a one-line edit, repeat the same anchor on both sides.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const start = parseLid(startRaw, lineNum);
|
|
42
|
+
const end = parseLid(endRaw, lineNum);
|
|
43
|
+
if (end.line < start.line) {
|
|
44
|
+
throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`);
|
|
45
|
+
}
|
|
46
|
+
if (end.line === start.line && end.hash !== start.hash) {
|
|
47
|
+
throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`);
|
|
48
|
+
}
|
|
49
|
+
return { start, end };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function expandRange(range: ParsedRange): Anchor[] {
|
|
53
|
+
const anchors: Anchor[] = [];
|
|
54
|
+
for (let line = range.start.line; line <= range.end.line; line++) {
|
|
55
|
+
const hash =
|
|
56
|
+
line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH;
|
|
57
|
+
anchors.push({ line, hash });
|
|
58
|
+
}
|
|
59
|
+
return anchors;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor {
|
|
63
|
+
if (raw === "BOF") return { kind: "bof" };
|
|
64
|
+
if (raw === "EOF") return { kind: "eof" };
|
|
65
|
+
const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
|
|
66
|
+
return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
|
|
70
|
+
const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
|
|
71
|
+
const DELETE_OP_RE = /^-\s*(\S+)$/;
|
|
72
|
+
const REPLACE_OP_RE = /^=\s*(\S+)$/;
|
|
73
|
+
const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
|
|
74
|
+
const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
|
|
75
|
+
|
|
76
|
+
export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
|
|
77
|
+
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
|
|
78
|
+
if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
|
|
79
|
+
return cursor;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectPayload(
|
|
83
|
+
lines: string[],
|
|
84
|
+
startIndex: number,
|
|
85
|
+
opLineNum: number,
|
|
86
|
+
requirePayload: boolean,
|
|
87
|
+
): { payload: string[]; nextIndex: number } {
|
|
88
|
+
const payload: string[] = [];
|
|
89
|
+
let index = startIndex;
|
|
90
|
+
while (index < lines.length) {
|
|
91
|
+
const line = stripTrailingCarriageReturn(lines[index]);
|
|
92
|
+
if (!line.startsWith(HL_EDIT_SEP)) break;
|
|
93
|
+
payload.push(line.slice(1));
|
|
94
|
+
index++;
|
|
95
|
+
}
|
|
96
|
+
if (payload.length === 0 && requirePayload) {
|
|
97
|
+
throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
|
|
98
|
+
}
|
|
99
|
+
return { payload, nextIndex: index };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseHashline(diff: string): HashlineEdit[] {
|
|
103
|
+
return parseHashlineWithWarnings(diff).edits;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
|
|
107
|
+
const edits: HashlineEdit[] = [];
|
|
108
|
+
const warnings: string[] = [];
|
|
109
|
+
const lines = diff.split("\n");
|
|
110
|
+
let editIndex = 0;
|
|
111
|
+
|
|
112
|
+
const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
|
|
113
|
+
edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < lines.length; ) {
|
|
117
|
+
const lineNum = i + 1;
|
|
118
|
+
const line = stripTrailingCarriageReturn(lines[i]);
|
|
119
|
+
|
|
120
|
+
if (line.trim().length === 0) {
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (line.startsWith(HL_EDIT_SEP)) {
|
|
125
|
+
throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line);
|
|
129
|
+
if (inlineBeforeMatch) {
|
|
130
|
+
const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum);
|
|
131
|
+
edits.push({
|
|
132
|
+
kind: "modify",
|
|
133
|
+
anchor,
|
|
134
|
+
prefix: inlineBeforeMatch[3],
|
|
135
|
+
suffix: "",
|
|
136
|
+
lineNum,
|
|
137
|
+
index: editIndex++,
|
|
138
|
+
});
|
|
139
|
+
const cursor: HashlineCursor = { kind: "before_anchor", anchor };
|
|
140
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
|
141
|
+
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
142
|
+
i = nextIndex;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line);
|
|
147
|
+
if (inlineAfterMatch) {
|
|
148
|
+
const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum);
|
|
149
|
+
edits.push({
|
|
150
|
+
kind: "modify",
|
|
151
|
+
anchor,
|
|
152
|
+
prefix: "",
|
|
153
|
+
suffix: inlineAfterMatch[3],
|
|
154
|
+
lineNum,
|
|
155
|
+
index: editIndex++,
|
|
156
|
+
});
|
|
157
|
+
const cursor: HashlineCursor = { kind: "after_anchor", anchor };
|
|
158
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
|
159
|
+
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
160
|
+
i = nextIndex;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
|
|
165
|
+
if (insertBeforeMatch) {
|
|
166
|
+
const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
|
|
167
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
|
168
|
+
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
169
|
+
i = nextIndex;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
|
|
174
|
+
if (insertAfterMatch) {
|
|
175
|
+
const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
|
|
176
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
|
177
|
+
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
178
|
+
i = nextIndex;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const deleteMatch = DELETE_OP_RE.exec(line);
|
|
183
|
+
if (deleteMatch) {
|
|
184
|
+
for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
|
|
185
|
+
edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
|
|
186
|
+
}
|
|
187
|
+
i++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const replaceMatch = REPLACE_OP_RE.exec(line);
|
|
192
|
+
if (replaceMatch) {
|
|
193
|
+
const range = parseRange(replaceMatch[1], lineNum);
|
|
194
|
+
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
|
195
|
+
// `= A..B` with no payload blanks the range to a single empty line.
|
|
196
|
+
const replacement = payload.length === 0 ? [""] : payload;
|
|
197
|
+
for (const text of replacement) {
|
|
198
|
+
edits.push({
|
|
199
|
+
kind: "insert",
|
|
200
|
+
cursor: { kind: "before_anchor", anchor: { ...range.start } },
|
|
201
|
+
text,
|
|
202
|
+
lineNum,
|
|
203
|
+
index: editIndex++,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
for (const anchor of expandRange(range)) {
|
|
207
|
+
edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
|
|
208
|
+
}
|
|
209
|
+
i = nextIndex;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
throw new Error(
|
|
214
|
+
`line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
|
|
215
|
+
`Got ${JSON.stringify(line)}.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { edits, warnings };
|
|
220
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { HL_BODY_SEP_RE_RAW } from "./hash";
|
|
2
|
+
|
|
3
|
+
const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
|
|
4
|
+
const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
|
|
5
|
+
const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
|
|
6
|
+
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
7
|
+
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
|
|
8
|
+
|
|
9
|
+
function stripLeadingHashlinePrefixes(line: string): string {
|
|
10
|
+
let result = line;
|
|
11
|
+
let previous: string;
|
|
12
|
+
do {
|
|
13
|
+
previous = result;
|
|
14
|
+
result = result.replace(HL_PREFIX_RE, "");
|
|
15
|
+
} while (result !== previous);
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// 5. Read-output prefix stripping
|
|
21
|
+
//
|
|
22
|
+
// When a model echoes back content from a `read` or `search` response, every
|
|
23
|
+
// line is prefixed with either a hashline tag (`123ab|`) or, for diff-style
|
|
24
|
+
// echoes, a leading `+`. These helpers detect that and recover the raw text.
|
|
25
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
type LinePrefixStats = {
|
|
28
|
+
nonEmpty: number;
|
|
29
|
+
hashPrefixCount: number;
|
|
30
|
+
diffPlusHashPrefixCount: number;
|
|
31
|
+
diffPlusCount: number;
|
|
32
|
+
truncationNoticeCount: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function collectLinePrefixStats(lines: string[]): LinePrefixStats {
|
|
36
|
+
const stats: LinePrefixStats = {
|
|
37
|
+
nonEmpty: 0,
|
|
38
|
+
hashPrefixCount: 0,
|
|
39
|
+
diffPlusHashPrefixCount: 0,
|
|
40
|
+
diffPlusCount: 0,
|
|
41
|
+
truncationNoticeCount: 0,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (line.length === 0) continue;
|
|
46
|
+
if (READ_TRUNCATION_NOTICE_RE.test(line)) {
|
|
47
|
+
stats.truncationNoticeCount++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
stats.nonEmpty++;
|
|
51
|
+
if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
|
|
52
|
+
if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
|
|
53
|
+
if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
|
|
54
|
+
}
|
|
55
|
+
return stats;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
59
|
+
const stats = collectLinePrefixStats(lines);
|
|
60
|
+
if (stats.nonEmpty === 0) return lines;
|
|
61
|
+
|
|
62
|
+
const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty;
|
|
63
|
+
const stripPlus =
|
|
64
|
+
!stripHash &&
|
|
65
|
+
stats.diffPlusHashPrefixCount === 0 &&
|
|
66
|
+
stats.diffPlusCount > 0 &&
|
|
67
|
+
stats.diffPlusCount >= stats.nonEmpty * 0.5;
|
|
68
|
+
|
|
69
|
+
if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
|
|
70
|
+
|
|
71
|
+
return lines
|
|
72
|
+
.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
|
|
73
|
+
.map(line => {
|
|
74
|
+
if (stripHash) return stripLeadingHashlinePrefixes(line);
|
|
75
|
+
if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
|
|
76
|
+
if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
|
|
77
|
+
return line.replace(HL_PREFIX_RE, "");
|
|
78
|
+
}
|
|
79
|
+
return line;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
84
|
+
const stats = collectLinePrefixStats(lines);
|
|
85
|
+
if (stats.nonEmpty === 0) return lines;
|
|
86
|
+
if (stats.hashPrefixCount !== stats.nonEmpty) return lines;
|
|
87
|
+
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalize line payloads by stripping read/search line prefixes. `null` /
|
|
92
|
+
* `undefined` yield `[]`; a single multiline string is split on `\n`.
|
|
93
|
+
*/
|
|
94
|
+
export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
|
|
95
|
+
if (edit == null) return [];
|
|
96
|
+
if (typeof edit === "string") {
|
|
97
|
+
const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
|
|
98
|
+
edit = trimmed.replaceAll("\r", "").split("\n");
|
|
99
|
+
}
|
|
100
|
+
return stripNewLinePrefixes(edit);
|
|
101
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as Diff from "diff";
|
|
2
|
+
import { generateDiffString } from "../edit/diff";
|
|
3
|
+
import type { FileReadCache } from "../edit/file-read-cache";
|
|
4
|
+
import { HashlineMismatchError } from "./anchors";
|
|
5
|
+
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
|
6
|
+
import type { HashlineApplyOptions, HashlineEdit } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface HashlineRecoveryArgs {
|
|
9
|
+
cache: FileReadCache;
|
|
10
|
+
absolutePath: string;
|
|
11
|
+
currentText: string;
|
|
12
|
+
edits: HashlineEdit[];
|
|
13
|
+
options: HashlineApplyOptions;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HashlineRecoveryResult {
|
|
17
|
+
lines: string;
|
|
18
|
+
firstChangedLine: number | undefined;
|
|
19
|
+
warnings: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HASHLINE_RECOVERY_FUZZ_FACTOR = 3;
|
|
23
|
+
|
|
24
|
+
const HASHLINE_RECOVERY_WARNING =
|
|
25
|
+
"Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit).";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attempt to recover from a `HashlineMismatchError` by replaying the edits
|
|
29
|
+
* against a cached pre-edit snapshot of the file and 3-way-merging the result
|
|
30
|
+
* onto the current on-disk content. Returns `null` when no recovery is
|
|
31
|
+
* possible — callers should propagate the original mismatch error in that
|
|
32
|
+
* case.
|
|
33
|
+
*/
|
|
34
|
+
export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null {
|
|
35
|
+
const { cache, absolutePath, currentText, edits, options } = args;
|
|
36
|
+
const snapshot = cache.get(absolutePath);
|
|
37
|
+
if (!snapshot || snapshot.lines.size === 0) return null;
|
|
38
|
+
|
|
39
|
+
const overlaid = currentText.split("\n");
|
|
40
|
+
let maxCachedLine = 0;
|
|
41
|
+
for (const lineNum of snapshot.lines.keys()) {
|
|
42
|
+
if (lineNum > maxCachedLine) maxCachedLine = lineNum;
|
|
43
|
+
}
|
|
44
|
+
while (overlaid.length < maxCachedLine) overlaid.push("");
|
|
45
|
+
for (const [lineNum, content] of snapshot.lines) {
|
|
46
|
+
overlaid[lineNum - 1] = content;
|
|
47
|
+
}
|
|
48
|
+
const previousText = overlaid.join("\n");
|
|
49
|
+
if (previousText === currentText) return null;
|
|
50
|
+
|
|
51
|
+
let applied: HashlineApplyResult;
|
|
52
|
+
try {
|
|
53
|
+
applied = applyHashlineEdits(previousText, edits, options);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof HashlineMismatchError) return null;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
if (applied.lines === previousText) return null;
|
|
59
|
+
|
|
60
|
+
const patch = Diff.structuredPatch("file", "file", previousText, applied.lines, "", "", { context: 3 });
|
|
61
|
+
const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: HASHLINE_RECOVERY_FUZZ_FACTOR });
|
|
62
|
+
if (typeof merged !== "string" || merged === currentText) return null;
|
|
63
|
+
|
|
64
|
+
const mergedDiff = generateDiffString(currentText, merged);
|
|
65
|
+
const recoveryWarnings = [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])];
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
lines: merged,
|
|
69
|
+
firstChangedLine: mergedDiff.firstChangedLine ?? applied.firstChangedLine,
|
|
70
|
+
warnings: recoveryWarnings,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { formatHashLine } from "./hash";
|
|
2
|
+
import type { HashlineStreamOptions } from "./types";
|
|
3
|
+
|
|
4
|
+
interface ResolvedHashlineStreamOptions {
|
|
5
|
+
startLine: number;
|
|
6
|
+
maxChunkLines: number;
|
|
7
|
+
maxChunkBytes: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
|
|
11
|
+
return {
|
|
12
|
+
startLine: options.startLine ?? 1,
|
|
13
|
+
maxChunkLines: options.maxChunkLines ?? 200,
|
|
14
|
+
maxChunkBytes: options.maxChunkBytes ?? 64 * 1024,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface HashlineChunkEmitter {
|
|
19
|
+
pushLine: (line: string) => string[];
|
|
20
|
+
flush: () => string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): HashlineChunkEmitter {
|
|
24
|
+
let lineNumber = options.startLine;
|
|
25
|
+
let outLines: string[] = [];
|
|
26
|
+
let outBytes = 0;
|
|
27
|
+
|
|
28
|
+
const flush = (): string | undefined => {
|
|
29
|
+
if (outLines.length === 0) return undefined;
|
|
30
|
+
const chunk = outLines.join("\n");
|
|
31
|
+
outLines = [];
|
|
32
|
+
outBytes = 0;
|
|
33
|
+
return chunk;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const pushLine = (line: string): string[] => {
|
|
37
|
+
const formatted = formatHashLine(lineNumber, line);
|
|
38
|
+
lineNumber++;
|
|
39
|
+
|
|
40
|
+
const chunks: string[] = [];
|
|
41
|
+
const sepBytes = outLines.length === 0 ? 0 : 1;
|
|
42
|
+
const lineBytes = Buffer.byteLength(formatted, "utf-8");
|
|
43
|
+
const wouldOverflow =
|
|
44
|
+
outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes;
|
|
45
|
+
|
|
46
|
+
if (outLines.length > 0 && wouldOverflow) {
|
|
47
|
+
const flushed = flush();
|
|
48
|
+
if (flushed) chunks.push(flushed);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
outLines.push(formatted);
|
|
52
|
+
outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
|
|
53
|
+
|
|
54
|
+
if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
|
|
55
|
+
const flushed = flush();
|
|
56
|
+
if (flushed) chunks.push(flushed);
|
|
57
|
+
}
|
|
58
|
+
return chunks;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { pushLine, flush };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
|
65
|
+
return (
|
|
66
|
+
typeof value === "object" &&
|
|
67
|
+
value !== null &&
|
|
68
|
+
"getReader" in value &&
|
|
69
|
+
typeof (value as { getReader?: unknown }).getReader === "function"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
74
|
+
const reader = stream.getReader();
|
|
75
|
+
try {
|
|
76
|
+
while (true) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done) return;
|
|
79
|
+
if (value) yield value;
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
reader.releaseLock();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function* streamHashLinesFromUtf8(
|
|
87
|
+
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
88
|
+
options: HashlineStreamOptions = {},
|
|
89
|
+
): AsyncGenerator<string> {
|
|
90
|
+
const resolved = resolveHashlineStreamOptions(options);
|
|
91
|
+
const decoder = new TextDecoder("utf-8");
|
|
92
|
+
const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
|
|
93
|
+
const emitter = createHashlineChunkEmitter(resolved);
|
|
94
|
+
|
|
95
|
+
let pending = "";
|
|
96
|
+
let sawAnyLine = false;
|
|
97
|
+
|
|
98
|
+
for await (const chunk of chunks) {
|
|
99
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
100
|
+
let nl = pending.indexOf("\n");
|
|
101
|
+
while (nl !== -1) {
|
|
102
|
+
const raw = pending.slice(0, nl);
|
|
103
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
104
|
+
sawAnyLine = true;
|
|
105
|
+
for (const out of emitter.pushLine(line)) yield out;
|
|
106
|
+
pending = pending.slice(nl + 1);
|
|
107
|
+
nl = pending.indexOf("\n");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pending += decoder.decode();
|
|
112
|
+
if (pending.length > 0) {
|
|
113
|
+
sawAnyLine = true;
|
|
114
|
+
const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
|
|
115
|
+
for (const out of emitter.pushLine(tail)) yield out;
|
|
116
|
+
}
|
|
117
|
+
if (!sawAnyLine) {
|
|
118
|
+
for (const out of emitter.pushLine("")) yield out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const last = emitter.flush();
|
|
122
|
+
if (last) yield last;
|
|
123
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Static } from "@sinclair/typebox";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { LspBatchRequest } from "../edit/renderer";
|
|
4
|
+
import type { WritethroughCallback, WritethroughDeferredHandle } from "../lsp";
|
|
5
|
+
import type { ToolSession } from "../tools";
|
|
6
|
+
|
|
7
|
+
export interface HashMismatch {
|
|
8
|
+
line: number;
|
|
9
|
+
expected: string;
|
|
10
|
+
actual: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Anchor = {
|
|
14
|
+
line: number;
|
|
15
|
+
hash: string;
|
|
16
|
+
contentHint?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type HashlineCursor =
|
|
20
|
+
| { kind: "bof" }
|
|
21
|
+
| { kind: "eof" }
|
|
22
|
+
| { kind: "before_anchor"; anchor: Anchor }
|
|
23
|
+
| { kind: "after_anchor"; anchor: Anchor };
|
|
24
|
+
|
|
25
|
+
export type HashlineEdit =
|
|
26
|
+
| { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
|
|
27
|
+
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
|
|
28
|
+
| { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number };
|
|
29
|
+
|
|
30
|
+
export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
|
|
31
|
+
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
|
32
|
+
|
|
33
|
+
export interface HashlineStreamOptions {
|
|
34
|
+
/** First line number to use when formatting (1-indexed). */
|
|
35
|
+
startLine?: number;
|
|
36
|
+
/** Maximum formatted lines per yielded chunk (default: 200). */
|
|
37
|
+
maxChunkLines?: number;
|
|
38
|
+
/** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
|
|
39
|
+
maxChunkBytes?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CompactHashlineDiffPreview {
|
|
43
|
+
preview: string;
|
|
44
|
+
addedLines: number;
|
|
45
|
+
removedLines: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CompactHashlineDiffOptions {
|
|
49
|
+
/** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
|
|
50
|
+
maxUnchangedRun?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface HashlineApplyOptions {
|
|
53
|
+
autoDropPureInsertDuplicates?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SplitHashlineOptions {
|
|
57
|
+
cwd?: string;
|
|
58
|
+
path?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ExecuteHashlineSingleOptions {
|
|
62
|
+
session: ToolSession;
|
|
63
|
+
input: string;
|
|
64
|
+
path?: string;
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
batchRequest?: LspBatchRequest;
|
|
67
|
+
writethrough: WritethroughCallback;
|
|
68
|
+
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
69
|
+
}
|