@oh-my-pi/pi-coding-agent 15.5.0 → 15.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/types/config/settings-schema.d.ts +3 -3
- package/dist/types/hashline/constants.d.ts +23 -0
- package/dist/types/hashline/executor.d.ts +7 -3
- package/dist/types/hashline/hash.d.ts +9 -7
- package/dist/types/hashline/tokenizer.d.ts +3 -0
- package/dist/types/tools/approval.d.ts +2 -2
- package/dist/types/tools/bash.d.ts +4 -4
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +0 -125
- package/src/config/settings-schema.ts +4 -4
- package/src/edit/streaming.ts +3 -4
- package/src/extensibility/extensions/wrapper.ts +2 -3
- package/src/hashline/anchors.ts +1 -1
- package/src/hashline/apply.ts +66 -56
- package/src/hashline/constants.ts +29 -0
- package/src/hashline/execute.ts +5 -3
- package/src/hashline/executor.ts +125 -30
- package/src/hashline/grammar.lark +1 -1
- package/src/hashline/hash.ts +9 -6
- package/src/hashline/recovery.ts +35 -1
- package/src/hashline/tokenizer.ts +10 -4
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/prompts/tools/hashline.md +47 -94
- package/src/task/executor.ts +2 -2
- package/src/tools/approval.ts +6 -2
- package/src/tools/bash.ts +4 -4
|
@@ -20,3 +20,32 @@ export const ABORT_MARKER = "*** Abort";
|
|
|
20
20
|
/** Warning text appended to the tool result when ABORT_MARKER terminates parsing. */
|
|
21
21
|
export const ABORT_WARNING =
|
|
22
22
|
"Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Warning text appended when two consecutive `A-B:` ops on the exact same
|
|
26
|
+
* range get coalesced (model painted a before/after pair). The second op
|
|
27
|
+
* wins; the first op's payload is silently discarded.
|
|
28
|
+
*/
|
|
29
|
+
export const REPLACE_PAIR_COALESCED_WARNING =
|
|
30
|
+
"Detected an identical-range before/after replace pair; kept only the second block's payload. Issue ONE op per range — the payload is the final desired content, never both old and new.";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Warning text appended when un-prefixed continuation lines are accepted as
|
|
34
|
+
* implicit payload (lenient legacy behavior). The model authored a multi-line
|
|
35
|
+
* replace without `+` prefixes; the parser accepted it because the lines did
|
|
36
|
+
* not classify as ops/headers/payloads, but the canonical syntax requires `+`
|
|
37
|
+
* on every continuation line after the op.
|
|
38
|
+
*/
|
|
39
|
+
export const IMPLICIT_CONTINUATION_WARNING =
|
|
40
|
+
"Accepted continuation line(s) without the `+` prefix as implicit payload. Canonical syntax is `A-B:` followed by `+` on every continuation row; without `+`, lines that look like ops will be parsed as new ops instead of payload. Prefer the explicit form.";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Warning text appended when an inner `LINE:TEXT` (or sub-range `A-B:TEXT`)
|
|
44
|
+
* op arrives while an outer `A-B:` replace is still pending and the inner
|
|
45
|
+
* anchor falls inside the outer range. The model used the read-output
|
|
46
|
+
* `LINE:TEXT` format as if it were a payload-continuation line; we strip the
|
|
47
|
+
* `LINE:` prefix and append the body to the pending payload, but warn so the
|
|
48
|
+
* canonical `+`-continuation form remains preferred.
|
|
49
|
+
*/
|
|
50
|
+
export const PAYLOAD_LINE_PREFIX_DEMOTED_WARNING =
|
|
51
|
+
"Detected one or more `LINE:TEXT` lines whose anchors fell inside a pending replace range; treated them as payload-continuation lines and stripped the `LINE:` prefix. Inside a multi-line `A-B:` block, payload lines after the first should be prefixed with `+` — never reuse the read-output gutter format.";
|
package/src/hashline/execute.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { HashlineMismatchError } from "./anchors";
|
|
|
14
14
|
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
|
15
15
|
import { buildCompactHashlineDiffPreview } from "./diff-preview";
|
|
16
16
|
import { parseHashline } from "./executor";
|
|
17
|
-
import { computeFileHash } from "./hash";
|
|
17
|
+
import { computeFileHash, formatHashlineHeader } from "./hash";
|
|
18
18
|
import { splitHashlineInputs } from "./input";
|
|
19
19
|
import { tryRecoverHashlineWithCache } from "./recovery";
|
|
20
20
|
import type {
|
|
@@ -224,9 +224,10 @@ async function executeHashlineSection(
|
|
|
224
224
|
// of the file: the model just received it back as the diff/preview. Cache
|
|
225
225
|
// it so a follow-up edit anchored against this state can still recover
|
|
226
226
|
// if the file is touched out-of-band before the next edit lands.
|
|
227
|
+
const newFileHash = computeFileHash(result.lines);
|
|
227
228
|
getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"), {
|
|
228
229
|
fullText: result.lines,
|
|
229
|
-
fileHash:
|
|
230
|
+
fileHash: newFileHash,
|
|
230
231
|
});
|
|
231
232
|
|
|
232
233
|
const diffResult = generateDiffString(originalNormalized, result.lines);
|
|
@@ -238,6 +239,7 @@ async function executeHashlineSection(
|
|
|
238
239
|
const warnings = [...parseWarnings, ...(result.warnings ?? [])];
|
|
239
240
|
const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
240
241
|
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
242
|
+
const newHashLine = `\n${formatHashlineHeader(sourcePath, newFileHash)}`;
|
|
241
243
|
const headline = preview.preview
|
|
242
244
|
? `${sourcePath}:`
|
|
243
245
|
: source.exists
|
|
@@ -245,7 +247,7 @@ async function executeHashlineSection(
|
|
|
245
247
|
: `Created ${sourcePath}`;
|
|
246
248
|
|
|
247
249
|
return {
|
|
248
|
-
content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
|
|
250
|
+
content: [{ type: "text", text: `${headline}${newHashLine}${previewBlock}${warningsBlock}` }],
|
|
249
251
|
details: {
|
|
250
252
|
diff: diffResult.diff,
|
|
251
253
|
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
package/src/hashline/executor.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ABORT_WARNING,
|
|
3
|
+
IMPLICIT_CONTINUATION_WARNING,
|
|
4
|
+
PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
|
|
5
|
+
REPLACE_PAIR_COALESCED_WARNING,
|
|
6
|
+
} from "./constants";
|
|
7
|
+
import {
|
|
8
|
+
HL_OP_CHARS,
|
|
9
|
+
HL_OP_DELETE,
|
|
10
|
+
HL_OP_INSERT_AFTER,
|
|
11
|
+
HL_OP_INSERT_BEFORE,
|
|
12
|
+
HL_OP_REPLACE,
|
|
13
|
+
HL_PAYLOAD_PREFIX,
|
|
14
|
+
} from "./hash";
|
|
3
15
|
import {
|
|
4
16
|
cloneCursor,
|
|
5
17
|
type HashlineToken,
|
|
@@ -15,6 +27,14 @@ function validateRangeOrder(range: ParsedRange, lineNum: number): void {
|
|
|
15
27
|
}
|
|
16
28
|
}
|
|
17
29
|
|
|
30
|
+
function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
|
|
31
|
+
return a.start.line === b.start.line && a.end.line === b.end.line;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
|
|
35
|
+
return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
function expandRange(range: ParsedRange): Anchor[] {
|
|
19
39
|
const anchors: Anchor[] = [];
|
|
20
40
|
for (let line = range.start.line; line <= range.end.line; line++) {
|
|
@@ -30,7 +50,6 @@ type PendingOp =
|
|
|
30
50
|
interface Pending {
|
|
31
51
|
op: PendingOp;
|
|
32
52
|
payload: string[];
|
|
33
|
-
pendingBlanks: number;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
/**
|
|
@@ -81,16 +100,18 @@ export class HashlineExecutor {
|
|
|
81
100
|
this.#terminated = true;
|
|
82
101
|
return;
|
|
83
102
|
case "header":
|
|
84
|
-
this.#flushPending(
|
|
103
|
+
this.#flushPending();
|
|
85
104
|
return;
|
|
86
105
|
case "blank":
|
|
87
|
-
if (this.#pending) this.#pending.pendingBlanks++;
|
|
88
106
|
return;
|
|
89
107
|
case "payload":
|
|
90
108
|
this.#handlePayload(token.text, token.lineNum);
|
|
91
109
|
return;
|
|
110
|
+
case "raw":
|
|
111
|
+
this.#handleRaw(token.text, token.lineNum);
|
|
112
|
+
return;
|
|
92
113
|
case "op-delete":
|
|
93
|
-
this.#flushPending(
|
|
114
|
+
this.#flushPending();
|
|
94
115
|
if (token.trailingPayload) {
|
|
95
116
|
throw new Error(
|
|
96
117
|
`line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
|
|
@@ -102,32 +123,62 @@ export class HashlineExecutor {
|
|
|
102
123
|
}
|
|
103
124
|
return;
|
|
104
125
|
case "op-insert":
|
|
105
|
-
this.#flushPending(
|
|
126
|
+
this.#flushPending();
|
|
106
127
|
this.#pending = {
|
|
107
128
|
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
|
|
108
|
-
payload:
|
|
109
|
-
pendingBlanks: 0,
|
|
129
|
+
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
|
|
110
130
|
};
|
|
111
131
|
return;
|
|
112
132
|
case "op-replace":
|
|
113
|
-
this.#flushPending(false);
|
|
114
133
|
validateRangeOrder(token.range, token.lineNum);
|
|
134
|
+
if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
|
|
135
|
+
const outer = this.#pending.op.range;
|
|
136
|
+
const inner = token.range;
|
|
137
|
+
if (rangesEqual(outer, inner)) {
|
|
138
|
+
// Identical-range before/after pair. Drop the "before" payload
|
|
139
|
+
// silently; the second op proceeds as the lone winner. Other
|
|
140
|
+
// overlap shapes (different ranges, replace+delete, delete+delete)
|
|
141
|
+
// still hit the post-hoc validator.
|
|
142
|
+
this.#pending = undefined;
|
|
143
|
+
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
|
|
144
|
+
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
|
|
145
|
+
}
|
|
146
|
+
} else if (rangeContains(outer, inner)) {
|
|
147
|
+
// Model wrote a payload line in read-output `LINE:TEXT` format
|
|
148
|
+
// (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
|
|
149
|
+
// The tokenizer can't tell payload from op when the anchor and
|
|
150
|
+
// sigil shape are identical, so demote: append the op's inline
|
|
151
|
+
// body to the pending payload, strip the `LINE:` prefix, and
|
|
152
|
+
// keep accumulating. Without this the inner anchors would each
|
|
153
|
+
// register as their own delete and clash with the outer range.
|
|
154
|
+
this.#pending.payload.push(token.inlineBody ?? "");
|
|
155
|
+
if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
|
|
156
|
+
this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
this.#flushPending();
|
|
115
162
|
this.#pending = {
|
|
116
163
|
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
|
|
117
|
-
payload:
|
|
118
|
-
pendingBlanks: 0,
|
|
164
|
+
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
|
|
119
165
|
};
|
|
120
166
|
return;
|
|
121
167
|
}
|
|
122
168
|
}
|
|
123
169
|
|
|
124
170
|
/**
|
|
125
|
-
* Flush any open pending op (
|
|
126
|
-
*
|
|
127
|
-
*
|
|
171
|
+
* Flush any open pending op (with its full accumulated payload, including
|
|
172
|
+
* explicit `+` blank lines) and return the accumulated edits and warnings.
|
|
173
|
+
* The executor is single-use; reset() is required for reuse.
|
|
174
|
+
* Throws if two replace/delete ops target the same line with non-identical
|
|
175
|
+
* shapes (different ranges, replace+delete, delete+delete). Identical-range
|
|
176
|
+
* `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
|
|
177
|
+
* warning, so they never reach the validator.
|
|
128
178
|
*/
|
|
129
179
|
end(): { edits: HashlineEdit[]; warnings: string[] } {
|
|
130
|
-
this.#flushPending(
|
|
180
|
+
this.#flushPending();
|
|
181
|
+
this.#validateNoOverlappingDeletes();
|
|
131
182
|
return { edits: this.#edits, warnings: this.#warnings };
|
|
132
183
|
}
|
|
133
184
|
|
|
@@ -140,18 +191,69 @@ export class HashlineExecutor {
|
|
|
140
191
|
this.#terminated = false;
|
|
141
192
|
}
|
|
142
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Each `:` / `!` op contributes a delete edit per line in its range; if
|
|
196
|
+
* any line ends up targeted by deletes originating from two different
|
|
197
|
+
* source ops (distinguished by their `lineNum`), the patch is internally
|
|
198
|
+
* inconsistent. Identical-range `A-B:` pairs are already collapsed by
|
|
199
|
+
* `feed()`; remaining shapes here are an `A-B:` that overlaps a later
|
|
200
|
+
* `N!`/`N:` with a different range, or two `!` deletes on the same line.
|
|
201
|
+
* The applier would run both literally and the file would end up with two
|
|
202
|
+
* copies of the line, not a chosen winner.
|
|
203
|
+
*/
|
|
204
|
+
#validateNoOverlappingDeletes(): void {
|
|
205
|
+
const sourceLinesByAnchor = new Map<number, number[]>();
|
|
206
|
+
for (const edit of this.#edits) {
|
|
207
|
+
if (edit.kind !== "delete") continue;
|
|
208
|
+
let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
|
|
209
|
+
if (sourceLines === undefined) {
|
|
210
|
+
sourceLines = [];
|
|
211
|
+
sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
|
|
212
|
+
}
|
|
213
|
+
if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
|
|
214
|
+
}
|
|
215
|
+
for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
|
|
216
|
+
if (sourceLines.length < 2) continue;
|
|
217
|
+
const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
|
|
218
|
+
throw new Error(
|
|
219
|
+
`line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
|
|
220
|
+
`Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
143
225
|
#handlePayload(text: string, lineNum: number): void {
|
|
144
226
|
if (this.#pending) {
|
|
145
|
-
this.#flushPendingBlanks();
|
|
146
227
|
this.#pending.payload.push(text);
|
|
147
228
|
return;
|
|
148
229
|
}
|
|
149
230
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
231
|
+
throw new Error(
|
|
232
|
+
`line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
|
|
233
|
+
`Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#handleRaw(text: string, lineNum: number): void {
|
|
238
|
+
if (this.#pending) {
|
|
239
|
+
if (text.trim().length === 0) return;
|
|
240
|
+
// Lenient legacy fallback: the tokenizer routes a line to `raw` only
|
|
241
|
+
// when it does not parse as an op, header, payload, or envelope
|
|
242
|
+
// marker. A `raw` token while a pending op exists is therefore an
|
|
243
|
+
// unambiguous continuation row that the model authored without the
|
|
244
|
+
// `+` prefix. Accept it as payload and warn so the canonical
|
|
245
|
+
// `+`-prefixed form remains preferred.
|
|
246
|
+
this.#pending.payload.push(text);
|
|
247
|
+
if (!this.#warnings.includes(IMPLICIT_CONTINUATION_WARNING)) {
|
|
248
|
+
this.#warnings.push(IMPLICIT_CONTINUATION_WARNING);
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Whitespace-only raw lines outside any pending op are silently dropped;
|
|
254
|
+
// fully empty lines arrive as `blank` tokens.
|
|
153
255
|
if (text.trim().length === 0) return;
|
|
154
|
-
// Orphan
|
|
256
|
+
// Orphan raw text outside any pending op: pick the most specific
|
|
155
257
|
// diagnostic so the model sees the actionable hint.
|
|
156
258
|
if (isDeleteOpWithPayload(text)) {
|
|
157
259
|
throw new Error(
|
|
@@ -174,19 +276,12 @@ export class HashlineExecutor {
|
|
|
174
276
|
);
|
|
175
277
|
}
|
|
176
278
|
|
|
177
|
-
#
|
|
178
|
-
if (!this.#pending) return;
|
|
179
|
-
for (let count = 0; count < this.#pending.pendingBlanks; count++) this.#pending.payload.push("");
|
|
180
|
-
this.#pending.pendingBlanks = 0;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
#flushPending(includeTrailingBlanks: boolean): void {
|
|
279
|
+
#flushPending(): void {
|
|
184
280
|
const pending = this.#pending;
|
|
185
281
|
if (!pending) return;
|
|
186
|
-
if (includeTrailingBlanks) this.#flushPendingBlanks();
|
|
187
282
|
|
|
188
283
|
const { op, payload } = pending;
|
|
189
|
-
const linesToInsert = payload;
|
|
284
|
+
const linesToInsert = payload.length === 0 ? [""] : payload;
|
|
190
285
|
|
|
191
286
|
if (op.kind === "insert") {
|
|
192
287
|
for (const text of linesToInsert) {
|
|
@@ -14,7 +14,7 @@ insert_after: anchor "$HOP_INSERT_AFTER$" inline_body? LF payload*
|
|
|
14
14
|
replace: range "$HOP_REPLACE$" inline_body? LF payload*
|
|
15
15
|
delete: range "$HOP_DELETE$" LF
|
|
16
16
|
inline_body: /[^\n]+/
|
|
17
|
-
payload: /
|
|
17
|
+
payload: "+" /[^\n]*/ LF
|
|
18
18
|
|
|
19
19
|
anchor: LID | "EOF" | "BOF"
|
|
20
20
|
range: LID ("-" LID)?
|
package/src/hashline/hash.ts
CHANGED
|
@@ -7,12 +7,12 @@ const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g,
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Decoration prefix that may precede a line number in tool output:
|
|
10
|
-
* `>` (context line in grep),
|
|
11
|
-
*
|
|
10
|
+
* `>` (context line in grep), `-` (removed line), `*` (match line).
|
|
11
|
+
* Any combination, in any order, surrounded by optional
|
|
12
12
|
* whitespace. Output formatters emit at most one decoration per line; the
|
|
13
13
|
* parser stays liberal because it accepts whatever the model echoes back.
|
|
14
14
|
*/
|
|
15
|
-
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[
|
|
15
|
+
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
|
|
16
16
|
|
|
17
17
|
/** Capture-group regex source for a decorated bare line-number anchor. */
|
|
18
18
|
export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
|
|
@@ -74,9 +74,9 @@ export function resolveHashlineGrammarPlaceholders(grammar: string): string {
|
|
|
74
74
|
/**
|
|
75
75
|
* op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
|
|
76
76
|
* {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
|
|
77
|
-
* or {@link HL_OP_DELETE}.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
77
|
+
* or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
|
|
78
|
+
* prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
|
|
79
|
+
* payload is written.
|
|
80
80
|
*
|
|
81
81
|
* These constants are the single source of truth for the edit parser, grammar,
|
|
82
82
|
* renderer, and prompt.
|
|
@@ -86,6 +86,9 @@ export const HL_OP_INSERT_AFTER = "↓";
|
|
|
86
86
|
export const HL_OP_REPLACE = ":";
|
|
87
87
|
export const HL_OP_DELETE = "!";
|
|
88
88
|
|
|
89
|
+
/** Prefix for payload continuation lines. The prefix itself is not written. */
|
|
90
|
+
export const HL_PAYLOAD_PREFIX = "+";
|
|
91
|
+
|
|
89
92
|
/** All hashline edit op sigils, concatenated for fast membership tests. */
|
|
90
93
|
export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
|
|
91
94
|
|
package/src/hashline/recovery.ts
CHANGED
|
@@ -29,6 +29,8 @@ const HASHLINE_RECOVERY_EXTERNAL_WARNING =
|
|
|
29
29
|
"Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
30
30
|
const HASHLINE_RECOVERY_SESSION_CHAIN_WARNING =
|
|
31
31
|
"Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
|
|
32
|
+
const HASHLINE_RECOVERY_SESSION_REPLAY_WARNING =
|
|
33
|
+
"Recovered by replaying your edits onto the current file content — your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
|
32
34
|
|
|
33
35
|
function applyEditsToSnapshot(
|
|
34
36
|
previousText: string,
|
|
@@ -62,6 +64,30 @@ function applyEditsToSnapshot(
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
function replaySessionChainOnCurrent(
|
|
68
|
+
previousText: string,
|
|
69
|
+
currentText: string,
|
|
70
|
+
edits: HashlineEdit[],
|
|
71
|
+
options: HashlineApplyOptions,
|
|
72
|
+
): HashlineRecoveryResult | null {
|
|
73
|
+
// Only safe when no insert/delete shifted line counts in the prior edit
|
|
74
|
+
// chain: if total line counts match, every line number in `edits` still
|
|
75
|
+
// resolves to the same logical row.
|
|
76
|
+
if (previousText.split("\n").length !== currentText.split("\n").length) return null;
|
|
77
|
+
let applied: HashlineApplyResult;
|
|
78
|
+
try {
|
|
79
|
+
applied = applyHashlineEdits(currentText, edits, options);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (applied.lines === currentText) return null;
|
|
84
|
+
return {
|
|
85
|
+
lines: applied.lines,
|
|
86
|
+
firstChangedLine: applied.firstChangedLine,
|
|
87
|
+
warnings: [HASHLINE_RECOVERY_SESSION_REPLAY_WARNING, ...(applied.warnings ?? [])],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap<number, string>): string {
|
|
66
92
|
const overlaid = currentText.split("\n");
|
|
67
93
|
let maxCachedLine = 0;
|
|
@@ -95,8 +121,16 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin
|
|
|
95
121
|
if (!snapshot || snapshot.lines.size === 0) return null;
|
|
96
122
|
|
|
97
123
|
const recoveryWarning = resolveRecoveryWarning(head, snapshot);
|
|
124
|
+
const isSessionChain = !isHeadSnapshot(head, snapshot);
|
|
98
125
|
if (snapshot.fullText !== undefined) {
|
|
99
|
-
|
|
126
|
+
const merged = applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
|
|
127
|
+
if (merged !== null) return merged;
|
|
128
|
+
// Session-chain fast-path: prior in-session edit changed the same line(s)
|
|
129
|
+
// the model is now re-targeting with the stale hash. When line counts
|
|
130
|
+
// match, the edits' line numbers still resolve to the right rows — replay
|
|
131
|
+
// onto the current text directly.
|
|
132
|
+
if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits, options);
|
|
133
|
+
return null;
|
|
100
134
|
}
|
|
101
135
|
|
|
102
136
|
const overlayText = buildSparseOverlayText(currentText, snapshot.lines);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
HL_OP_INSERT_AFTER,
|
|
8
8
|
HL_OP_INSERT_BEFORE,
|
|
9
9
|
HL_OP_REPLACE,
|
|
10
|
+
HL_PAYLOAD_PREFIX,
|
|
10
11
|
} from "./hash";
|
|
11
12
|
import type { Anchor, HashlineCursor } from "./types";
|
|
12
13
|
|
|
@@ -20,6 +21,7 @@ const CHAR_SPACE = 32;
|
|
|
20
21
|
const CHAR_LOWER_A = 97;
|
|
21
22
|
const CHAR_LOWER_F = 102;
|
|
22
23
|
const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0);
|
|
24
|
+
const CHAR_PAYLOAD_PREFIX = HL_PAYLOAD_PREFIX.charCodeAt(0);
|
|
23
25
|
const FILE_HASH_LENGTH = 4;
|
|
24
26
|
|
|
25
27
|
function isDigitCode(code: number): boolean {
|
|
@@ -31,7 +33,7 @@ function isNonZeroDigitCode(code: number): boolean {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
function isDecorationCode(code: number): boolean {
|
|
34
|
-
return code === 42 || code ===
|
|
36
|
+
return code === 42 || code === 45 || code === 62;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function isHexDigitCode(code: number): boolean {
|
|
@@ -91,7 +93,7 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
// Leniently accept anchors copied from read/search output:
|
|
94
|
-
// - optional leading line-marker decoration (`*`, `>`,
|
|
96
|
+
// - optional leading line-marker decoration (`*`, `>`, `-`)
|
|
95
97
|
// - the required bare line number
|
|
96
98
|
function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
|
|
97
99
|
let index = skipWhitespace(line, 0, end);
|
|
@@ -339,7 +341,8 @@ export type HashlineToken =
|
|
|
339
341
|
| (TokenBase & { kind: "op-insert"; cursor: HashlineCursor; inlineBody: string | undefined })
|
|
340
342
|
| (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
|
|
341
343
|
| (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean })
|
|
342
|
-
| (TokenBase & { kind: "payload"; text: string })
|
|
344
|
+
| (TokenBase & { kind: "payload"; text: string })
|
|
345
|
+
| (TokenBase & { kind: "raw"; text: string });
|
|
343
346
|
|
|
344
347
|
function classifyLine(line: string, lineNum: number): HashlineToken {
|
|
345
348
|
if (isEmptyLine(line)) return { kind: "blank", lineNum };
|
|
@@ -356,6 +359,9 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
|
|
|
356
359
|
}
|
|
357
360
|
}
|
|
358
361
|
|
|
362
|
+
if (line.charCodeAt(0) === CHAR_PAYLOAD_PREFIX) {
|
|
363
|
+
return { kind: "payload", lineNum, text: line.slice(HL_PAYLOAD_PREFIX.length) };
|
|
364
|
+
}
|
|
359
365
|
const op = tryParseOp(line);
|
|
360
366
|
if (op !== null) {
|
|
361
367
|
if (op.kind === "insert") {
|
|
@@ -367,7 +373,7 @@ function classifyLine(line: string, lineNum: number): HashlineToken {
|
|
|
367
373
|
return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload };
|
|
368
374
|
}
|
|
369
375
|
|
|
370
|
-
return { kind: "
|
|
376
|
+
return { kind: "raw", lineNum, text: line };
|
|
371
377
|
}
|
|
372
378
|
|
|
373
379
|
/**
|