@oh-my-pi/hashline 15.5.6 → 15.5.7
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/dist/types/messages.d.ts +9 -1
- package/dist/types/recovery.d.ts +6 -2
- package/package.json +1 -1
- package/src/apply.ts +12 -3
- package/src/messages.ts +9 -1
- package/src/prompt.md +14 -8
- package/src/recovery.ts +70 -10
package/dist/types/messages.d.ts
CHANGED
|
@@ -32,5 +32,13 @@ export declare const VIRTUAL_REPLACE_REJECTED_MESSAGE = "BOF:/EOF: anchors are v
|
|
|
32
32
|
export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
|
|
33
33
|
/** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
|
|
34
34
|
export declare const RECOVERY_SESSION_CHAIN_WARNING = "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Warning text emitted by `Recovery` when the session-chain replay
|
|
37
|
+
* fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
|
|
38
|
+
* because replay is the less-certain mode: the structured-patch 3-way
|
|
39
|
+
* merge refused, the anchor-content gate passed, but a coincidental
|
|
40
|
+
* insert+delete pair earlier in the chain could still leave an anchor's
|
|
41
|
+
* line number pointing at a duplicated row. Surface the hedge so the
|
|
42
|
+
* model verifies before continuing.
|
|
43
|
+
*/
|
|
36
44
|
export declare const RECOVERY_SESSION_REPLAY_WARNING = "Recovered by replaying your edits onto the current file content \u2014 your previous edit in this session changed line(s) you re-targeted with a stale hash. Verify the diff matches your intent before continuing.";
|
package/dist/types/recovery.d.ts
CHANGED
|
@@ -22,8 +22,12 @@ export interface RecoveryResult {
|
|
|
22
22
|
*
|
|
23
23
|
* 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
|
|
24
24
|
* 2. (Session chain) If the snapshot wasn't the head, retry on current text
|
|
25
|
-
* when line counts match
|
|
26
|
-
*
|
|
25
|
+
* when line counts match AND every edit's anchor line content is unchanged
|
|
26
|
+
* between snapshot and current — the previous in-session edit advanced
|
|
27
|
+
* the hash and the model's anchors still name the same logical rows. Emits
|
|
28
|
+
* a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
|
|
29
|
+
* both guards a coincidental insert+delete pair on duplicate rows can
|
|
30
|
+
* still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
|
|
27
31
|
* 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
|
|
28
32
|
* text hashes to the expected value, then 3-way-merge.
|
|
29
33
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.5.
|
|
4
|
+
"version": "15.5.7",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
package/src/apply.ts
CHANGED
|
@@ -39,9 +39,18 @@ function isReplacementInsert(edit: Edit): edit is Extract<Edit, { kind: "insert"
|
|
|
39
39
|
|
|
40
40
|
function getEditAnchors(edit: Edit): Anchor[] {
|
|
41
41
|
if (edit.kind === "delete") return [edit.anchor];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
switch (edit.cursor.kind) {
|
|
43
|
+
case "before_anchor":
|
|
44
|
+
case "after_anchor":
|
|
45
|
+
return [edit.cursor.anchor];
|
|
46
|
+
case "bof":
|
|
47
|
+
case "eof":
|
|
48
|
+
return [];
|
|
49
|
+
default: {
|
|
50
|
+
const _exhaustive: never = edit.cursor;
|
|
51
|
+
return _exhaustive;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/**
|
package/src/messages.ts
CHANGED
|
@@ -48,6 +48,14 @@ export const RECOVERY_EXTERNAL_WARNING =
|
|
|
48
48
|
export const RECOVERY_SESSION_CHAIN_WARNING =
|
|
49
49
|
"Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Warning text emitted by `Recovery` when the session-chain replay
|
|
53
|
+
* fast-path was taken. Distinct from {@link RECOVERY_SESSION_CHAIN_WARNING}
|
|
54
|
+
* because replay is the less-certain mode: the structured-patch 3-way
|
|
55
|
+
* merge refused, the anchor-content gate passed, but a coincidental
|
|
56
|
+
* insert+delete pair earlier in the chain could still leave an anchor's
|
|
57
|
+
* line number pointing at a duplicated row. Surface the hedge so the
|
|
58
|
+
* model verifies before continuing.
|
|
59
|
+
*/
|
|
52
60
|
export const RECOVERY_SESSION_REPLAY_WARNING =
|
|
53
61
|
"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.";
|
package/src/prompt.md
CHANGED
|
@@ -25,22 +25,18 @@ A-B:
|
|
|
25
25
|
`EOF:` — virtual position after the last line.
|
|
26
26
|
</anchors>
|
|
27
27
|
|
|
28
|
-
<
|
|
28
|
+
<sigils>
|
|
29
29
|
`|content` — replace A..B with `content`.
|
|
30
30
|
`↑content` — insert `content` before A.
|
|
31
31
|
`↓content` — insert `content` after B.
|
|
32
|
-
</
|
|
32
|
+
</sigils>
|
|
33
33
|
|
|
34
34
|
<semantics>
|
|
35
|
-
- **No payload
|
|
36
|
-
- **Any `|` row → replace.** Delete A..B; insert all `|` rows there.
|
|
37
|
-
- **Only `↑`/`↓` rows → preserve.** Anchor lines stay unchanged.
|
|
35
|
+
- **No payload → delete.** `5:` deletes line 5.
|
|
38
36
|
- **Buckets combine.** `↑` before A, `|` in place, `↓` after B.
|
|
39
37
|
- **Bucket order ignores interleaving.** Output order = all `↑`, then `|`/original, then all `↓`.
|
|
40
38
|
- **Order within a bucket is preserved.** Two `↑` rows stack top-down.
|
|
41
|
-
- **Blank payload
|
|
42
|
-
- **BOF/EOF only insert.** `↑` and `↓` are equivalent there; `|` is invalid.
|
|
43
|
-
- **Escape leading payload sigils by doubling.** `||x` writes `|x`; `↑↑x` writes `↑x`; `↓↓x` writes `↓x`.
|
|
39
|
+
- **Blank payload = explicit.** Bare `|`, `↑`, or `↓` writes one blank line.
|
|
44
40
|
- **Line numbers are frozen.** Later anchors still reference pre-edit lines.
|
|
45
41
|
</semantics>
|
|
46
42
|
|
|
@@ -78,6 +74,7 @@ A-B:
|
|
|
78
74
|
<common-failures>
|
|
79
75
|
- **NEVER use inline payload.** `5:content` is invalid; write `5:` then `|content`.
|
|
80
76
|
- **Do not repeat preserved lines.** If line 5 should survive, omit `|`.
|
|
77
|
+
- **`↑`/`↓` payloads are new bytes only.** Never echo the anchor or a neighbor line — that line already exists; copying it into a `↓` row appends a duplicate.
|
|
81
78
|
- **Do not echo read gutters.** `84:content` is not payload.
|
|
82
79
|
- **Do not replay past B.** Stop before B+1; widen the anchor if B+1 changes.
|
|
83
80
|
- **NEVER fabricate file hashes.** Missing? Re-`read`.
|
|
@@ -98,6 +95,15 @@ A-B:
|
|
|
98
95
|
5:
|
|
99
96
|
↑const Y = X;
|
|
100
97
|
|
|
98
|
+
# WRONG — echoing the anchor into a ↓ payload duplicates it.
|
|
99
|
+
# Line 5 already contains `const X = 1;`.
|
|
100
|
+
5:
|
|
101
|
+
↓const X = 1;
|
|
102
|
+
↓const Y = 2;
|
|
103
|
+
# RIGHT — payload is only the new line; the anchor survives automatically.
|
|
104
|
+
5:
|
|
105
|
+
↓const Y = 2;
|
|
106
|
+
|
|
101
107
|
# WRONG — read-output gutters inside payload.
|
|
102
108
|
5-6:
|
|
103
109
|
5:const X = "b";
|
package/src/recovery.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { applyEdits } from "./apply";
|
|
|
13
13
|
import { computeFileHash } from "./format";
|
|
14
14
|
import { RECOVERY_EXTERNAL_WARNING, RECOVERY_SESSION_CHAIN_WARNING, RECOVERY_SESSION_REPLAY_WARNING } from "./messages";
|
|
15
15
|
import type { Snapshot, SnapshotStore } from "./snapshots";
|
|
16
|
-
import type { ApplyOptions, ApplyResult, Edit } from "./types";
|
|
16
|
+
import type { Anchor, ApplyOptions, ApplyResult, Edit } from "./types";
|
|
17
17
|
|
|
18
18
|
// Section hashes are line-precise; never let Diff.applyPatch slide a hunk
|
|
19
19
|
// onto a duplicate closer 100+ lines away. If snapshot replay does not
|
|
@@ -63,16 +63,72 @@ function applyEditsToSnapshot(
|
|
|
63
63
|
return { text: merged, firstChangedLine, warnings };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function collectAnchorLines(edits: readonly Edit[]): number[] {
|
|
67
|
+
const lines: number[] = [];
|
|
68
|
+
for (const edit of edits) {
|
|
69
|
+
for (const anchor of getEditAnchors(edit)) lines.push(anchor.line);
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getEditAnchors(edit: Edit): Anchor[] {
|
|
75
|
+
if (edit.kind === "delete") return [edit.anchor];
|
|
76
|
+
switch (edit.cursor.kind) {
|
|
77
|
+
case "before_anchor":
|
|
78
|
+
case "after_anchor":
|
|
79
|
+
return [edit.cursor.anchor];
|
|
80
|
+
case "bof":
|
|
81
|
+
case "eof":
|
|
82
|
+
return [];
|
|
83
|
+
default: {
|
|
84
|
+
const _exhaustive: never = edit.cursor;
|
|
85
|
+
return _exhaustive;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns true when every anchor line in `edits` has identical content in
|
|
92
|
+
* `previousText` and `currentText`. The session-chain replay fast-path
|
|
93
|
+
* requires this: if the prior in-session edit rewrote the line the model is
|
|
94
|
+
* now re-targeting with a stale hash, replaying onto current would silently
|
|
95
|
+
* overwrite the new content with whatever the model authored against the
|
|
96
|
+
* old content — a corruption window, not a recovery.
|
|
97
|
+
*/
|
|
98
|
+
function verifyAnchorContent(previousText: string, currentText: string, edits: readonly Edit[]): boolean {
|
|
99
|
+
const lines = collectAnchorLines(edits);
|
|
100
|
+
if (lines.length === 0) return true;
|
|
101
|
+
const prev = previousText.split("\n");
|
|
102
|
+
const curr = currentText.split("\n");
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const idx = line - 1;
|
|
105
|
+
if (idx < 0 || idx >= prev.length || idx >= curr.length) return false;
|
|
106
|
+
if (prev[idx] !== curr[idx]) return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
function replaySessionChainOnCurrent(
|
|
67
112
|
previousText: string,
|
|
68
113
|
currentText: string,
|
|
69
114
|
edits: readonly Edit[],
|
|
70
115
|
options: ApplyOptions,
|
|
71
116
|
): RecoveryResult | null {
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
117
|
+
// Two guards narrow the corruption window. Neither alone is sufficient,
|
|
118
|
+
// and even together they don't fully prove correctness — replay is the
|
|
119
|
+
// less-certain recovery mode and emits RECOVERY_SESSION_REPLAY_WARNING
|
|
120
|
+
// so the caller can verify the diff.
|
|
121
|
+
// - Equal line counts: every line number in `edits` still resolves to
|
|
122
|
+
// SOME logical row (no net shift across the prior chain). A
|
|
123
|
+
// coincidental insert+delete pair can still leave indices pointing
|
|
124
|
+
// at different logical rows than the model anchored against.
|
|
125
|
+
// - Anchor-content alignment: the row at each anchor's line index has
|
|
126
|
+
// identical content in previous and current. Catches the common
|
|
127
|
+
// case of a prior edit rewriting the targeted line; can still be
|
|
128
|
+
// coincidentally satisfied by a duplicated row at the shifted
|
|
129
|
+
// index.
|
|
75
130
|
if (previousText.split("\n").length !== currentText.split("\n").length) return null;
|
|
131
|
+
if (!verifyAnchorContent(previousText, currentText, edits)) return null;
|
|
76
132
|
let applied: ApplyResult;
|
|
77
133
|
try {
|
|
78
134
|
applied = applyEdits(currentText, [...edits], options);
|
|
@@ -123,8 +179,12 @@ function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
|
|
|
123
179
|
*
|
|
124
180
|
* 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
|
|
125
181
|
* 2. (Session chain) If the snapshot wasn't the head, retry on current text
|
|
126
|
-
* when line counts match
|
|
127
|
-
*
|
|
182
|
+
* when line counts match AND every edit's anchor line content is unchanged
|
|
183
|
+
* between snapshot and current — the previous in-session edit advanced
|
|
184
|
+
* the hash and the model's anchors still name the same logical rows. Emits
|
|
185
|
+
* a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
|
|
186
|
+
* both guards a coincidental insert+delete pair on duplicate rows can
|
|
187
|
+
* still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
|
|
128
188
|
* 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
|
|
129
189
|
* text hashes to the expected value, then 3-way-merge.
|
|
130
190
|
*/
|
|
@@ -148,10 +208,10 @@ export class Recovery {
|
|
|
148
208
|
if (snapshot.fullText !== undefined) {
|
|
149
209
|
const merged = applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
|
|
150
210
|
if (merged !== null) return merged;
|
|
151
|
-
// Session-chain
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
211
|
+
// Session-chain fallback: the 3-way merge on the snapshot refused.
|
|
212
|
+
// Replay onto current is gated by line-count equality AND
|
|
213
|
+
// anchor-content alignment — see `replaySessionChainOnCurrent`
|
|
214
|
+
// for why both guards together still don't fully prove correctness.
|
|
155
215
|
if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits, options);
|
|
156
216
|
return null;
|
|
157
217
|
}
|