@oh-my-pi/pi-coding-agent 15.5.1 → 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 +17 -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 -6
- 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/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 +92 -19
- 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 +9 -5
- package/src/task/executor.ts +2 -2
- package/src/tools/approval.ts +6 -2
- package/src/tools/bash.ts +4 -4
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++) {
|
|
@@ -83,11 +103,13 @@ export class HashlineExecutor {
|
|
|
83
103
|
this.#flushPending();
|
|
84
104
|
return;
|
|
85
105
|
case "blank":
|
|
86
|
-
if (this.#pending) this.#pending.payload.push("");
|
|
87
106
|
return;
|
|
88
107
|
case "payload":
|
|
89
108
|
this.#handlePayload(token.text, token.lineNum);
|
|
90
109
|
return;
|
|
110
|
+
case "raw":
|
|
111
|
+
this.#handleRaw(token.text, token.lineNum);
|
|
112
|
+
return;
|
|
91
113
|
case "op-delete":
|
|
92
114
|
this.#flushPending();
|
|
93
115
|
if (token.trailingPayload) {
|
|
@@ -104,27 +126,55 @@ export class HashlineExecutor {
|
|
|
104
126
|
this.#flushPending();
|
|
105
127
|
this.#pending = {
|
|
106
128
|
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
|
|
107
|
-
payload:
|
|
129
|
+
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
|
|
108
130
|
};
|
|
109
131
|
return;
|
|
110
132
|
case "op-replace":
|
|
111
|
-
this.#flushPending();
|
|
112
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();
|
|
113
162
|
this.#pending = {
|
|
114
163
|
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
|
|
115
|
-
payload:
|
|
164
|
+
payload: token.inlineBody === undefined ? [] : [token.inlineBody],
|
|
116
165
|
};
|
|
117
166
|
return;
|
|
118
167
|
}
|
|
119
168
|
}
|
|
120
169
|
|
|
121
170
|
/**
|
|
122
|
-
* Flush any open pending op (with its full accumulated payload,
|
|
123
|
-
*
|
|
124
|
-
* is single-use; reset() is required for reuse.
|
|
125
|
-
* Throws if two replace/delete ops target the same line
|
|
126
|
-
*
|
|
127
|
-
* the
|
|
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
180
|
this.#flushPending();
|
|
@@ -145,10 +195,11 @@ export class HashlineExecutor {
|
|
|
145
195
|
* Each `:` / `!` op contributes a delete edit per line in its range; if
|
|
146
196
|
* any line ends up targeted by deletes originating from two different
|
|
147
197
|
* source ops (distinguished by their `lineNum`), the patch is internally
|
|
148
|
-
* inconsistent.
|
|
149
|
-
* `
|
|
150
|
-
* `N
|
|
151
|
-
*
|
|
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.
|
|
152
203
|
*/
|
|
153
204
|
#validateNoOverlappingDeletes(): void {
|
|
154
205
|
const sourceLinesByAnchor = new Map<number, number[]>();
|
|
@@ -177,10 +228,32 @@ export class HashlineExecutor {
|
|
|
177
228
|
return;
|
|
178
229
|
}
|
|
179
230
|
|
|
180
|
-
|
|
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;
|
|
181
254
|
// fully empty lines arrive as `blank` tokens.
|
|
182
255
|
if (text.trim().length === 0) return;
|
|
183
|
-
// Orphan
|
|
256
|
+
// Orphan raw text outside any pending op: pick the most specific
|
|
184
257
|
// diagnostic so the model sees the actionable hint.
|
|
185
258
|
if (isDeleteOpWithPayload(text)) {
|
|
186
259
|
throw new Error(
|
|
@@ -208,7 +281,7 @@ export class HashlineExecutor {
|
|
|
208
281
|
if (!pending) return;
|
|
209
282
|
|
|
210
283
|
const { op, payload } = pending;
|
|
211
|
-
const linesToInsert = payload;
|
|
284
|
+
const linesToInsert = payload.length === 0 ? [""] : payload;
|
|
212
285
|
|
|
213
286
|
if (op.kind === "insert") {
|
|
214
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
|
/**
|