@oh-my-pi/hashline 15.5.12 → 15.5.13

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/src/prompt.md CHANGED
@@ -1,40 +1,34 @@
1
- Your patch language selects ranges of file lines and rewrites them. Each hunk picks a range and lists its new content; an empty body deletes the range.
1
+ Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
2
2
 
3
- <body-rows>
4
- Every body row is **exactly one** of two kinds:
5
- +TEXT add a new literal line `TEXT` (verbatim, leading whitespace included)
6
- &A..B copy lines A..B from snapshot
7
- </body-rows>
8
-
9
- <anchors>
10
- ```
11
- A B select lines A..B; the body rows below describe their new content
12
- (empty body = delete the range). Always TWO numbers — single
13
- lines are spelled `A A`.
14
- BOF virtual position before line 1; body rows insert there
15
- EOF virtual position after the last line; body rows insert there
16
- ```
3
+ <headers>
4
+ Every file section starts with `¶PATH#TAG`. `TAG` is the 3-char snapshot tag from your latest `read`/`search`. REQUIRED for any hunk that names line numbers. Hashless `¶PATH` is allowed only for new-file creation or a patch that is purely `insert head:` / `insert tail:`.
5
+ </headers>
17
6
 
18
- A hunk header is **just the anchor on its own line** — no `@@`, no brackets, no prefix.
19
- </anchors>
7
+ <ops>
8
+ replace N..M: replace original lines N..M with the body rows below.
9
+ delete N..M delete original lines N..M. No body.
10
+ insert before N: insert the body rows immediately before line N.
11
+ insert after N: insert the body rows immediately after line N.
12
+ insert head: insert the body rows at the very start of the file.
13
+ insert tail: insert the body rows at the very end of the file.
14
+ Single line: `replace N..N:` / `delete N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `replace N..N:`).
15
+ </ops>
20
16
 
21
- <header>
22
- Every file section starts with `¶PATH#HASH`. `HASH` is the snapshot tag from your latest `read`/`search` of that file. It is required whenever a hunk uses a numeric anchor. Hashless `¶PATH` is only valid for new-file creation or BOF/EOF-only patches.
23
- </header>
17
+ <body-rows>
18
+ Body rows appear only under a `:` header. Every body row is:
19
+ +TEXT add a new literal line `TEXT`, verbatim (leading whitespace kept). `+` alone adds a blank line.
20
+ There is NO other body row kind. NEVER write `-old` or a bare/context line. To keep a line, leave it out of every range. To insert a literal line starting with `-` or `+`, prefix it: `+-x`, `++x`.
21
+ </body-rows>
24
22
 
25
23
  <rules>
26
- - Anchors are line **numbers**, never line **content**, and always come in PAIRS. `read` shows each file row as `LINE:TEXT`; for a patch the hunk header is `4 4` (single line) or `4 7` (range), and the body is `+TEXT` (or `&4` to keep it).
27
- - A bare single number (`4`) is REJECTEDalways write two numbers.
28
- - `A B` describes the **original** lines you are replacing. Replacing one line with ten new lines is still `4 4`, NOT `4 13`.
29
- - Each range may appear in only ONE hunk per patch.
30
- - Line numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as your hunks land.
31
- - An empty body **deletes** the selected range entirely. To replace lines A..B with completely new content, list the new content under the hunk header (do not write `&A..B` for the lines you are replacing).
32
- - `@@` is NOT a hashline construct. Do not wrap headers in `@@ ... @@` — write the anchor bare.
24
+ - Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `¶PATH#TAG` header; use the bare LINE numbers.
25
+ - Numbers refer to the ORIGINAL file and stay valid for the whole patch they do not shift as hunks apply.
26
+ - One hunk per range; the body is the final content, never an old/new pair.
27
+ - To change lines 2 and 5 while keeping 3–4, issue two hunks (`replace 2..2:` and `replace 5..5:`). Untouched lines are simply absent from every range.
33
28
  </rules>
34
29
 
35
-
36
30
  <example>
37
- This is the original file (the exact shape `read` returns):
31
+ Original (the exact shape `read` returns):
38
32
  ```
39
33
  ¶greet.py#A1
40
34
  1:def greet(name):
@@ -43,46 +37,51 @@ This is the original file (the exact shape `read` returns):
43
37
  4:greet("world")
44
38
  ```
45
39
 
46
- # To insert a guard as the first line of greet:
40
+ Insert a guard after line 1:
47
41
  ```
48
42
  ¶greet.py#A1
49
- 1 1
50
- &1
43
+ insert after 1:
51
44
  + if not name: name = "stranger"
52
45
  ```
53
46
 
54
- # Replace line 2 with two new lines.
47
+ Replace line 2 with two lines:
55
48
  ```
56
- 2 2
49
+ ¶greet.py#A1
50
+ replace 2..2:
57
51
  + greeting = "Hi"
58
52
  + msg = f"{greeting}, {name}"
59
53
  ```
60
54
 
61
- # Delete line 4.
55
+ Delete line 3:
62
56
  ```
63
57
  ¶greet.py#A1
64
- 4 4
58
+ delete 3
65
59
  ```
66
60
 
67
- # Add header & trailer.
61
+ Add a header and trailer:
68
62
  ```
69
63
  ¶greet.py#A1
70
- BOF
64
+ insert head:
71
65
  +# generated header
72
- EOF
66
+ insert tail:
73
67
  +greet("everyone")
74
68
  ```
75
69
  </example>
76
70
 
77
71
  <anti-patterns>
78
- # WRONG — range set based on what it will be (RIGHT: 1 1, inserted line count doesn't matter)
79
- 1 2
72
+ # WRONG — empty `replace` to delete. RIGHT: delete 4
73
+ replace 4..4:
74
+
75
+ # WRONG — range describes post-edit size. RIGHT: replace 1..1: (body length is irrelevant)
76
+ replace 1..2:
80
77
  +def greet(name):
81
- + """Greet a user by name."""
82
78
 
83
- # WRONG — do not include context lines, nor delete old lines, the selector `2 2` itself deletes the entire range
84
- 3 3
79
+ # WRONG — `-` rows / bare context lines do not exist. The range deletes; the body is only the new content.
80
+ replace 3..3:
85
81
  msg = "Hello, " + name
86
82
  - print(msg)
87
83
  + return msg
84
+ # RIGHT
85
+ replace 3..3:
86
+ + return msg
88
87
  </anti-patterns>
package/src/recovery.ts CHANGED
@@ -70,14 +70,7 @@ function collectAnchorLines(edits: readonly Edit[]): number[] {
70
70
 
71
71
  function getEditAnchors(edit: Edit): Anchor[] {
72
72
  if (edit.kind === "delete") return [edit.anchor];
73
- const cursorAnchors = edit.cursor.kind === "before_anchor" ? [edit.cursor.anchor] : [];
74
- if (edit.kind === "insert") return cursorAnchors;
75
-
76
- const repeatAnchors: Anchor[] = [];
77
- for (let line = edit.range.start.line; line <= edit.range.end.line; line++) {
78
- repeatAnchors.push({ line });
79
- }
80
- return cursorAnchors.concat(repeatAnchors);
73
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
81
74
  }
82
75
 
83
76
  /**
@@ -135,35 +128,6 @@ function replaySessionChainOnCurrent(
135
128
  };
136
129
  }
137
130
 
138
- function snapshotHasEntries(snapshot: Snapshot): boolean {
139
- for (const _entry of snapshot.entries()) return true;
140
- return false;
141
- }
142
-
143
- function buildSparseOverlayText(currentText: string, snapshot: Snapshot): string {
144
- const overlaid = currentText.split("\n");
145
- let maxCachedLine = 0;
146
- for (const [lineNum] of snapshot.entries()) {
147
- if (lineNum > maxCachedLine) maxCachedLine = lineNum;
148
- }
149
- while (overlaid.length < maxCachedLine) overlaid.push("");
150
- for (const [lineNum, content] of snapshot.entries()) {
151
- overlaid[lineNum - 1] = content;
152
- }
153
- return overlaid.join("\n");
154
- }
155
-
156
- function sparseSnapshotCoversAnchors(snapshot: Snapshot, edits: readonly Edit[]): boolean {
157
- for (const lineNumber of collectAnchorLines(edits)) {
158
- if (snapshot.get(lineNumber) === undefined) return false;
159
- }
160
- return true;
161
- }
162
-
163
- function sparseSnapshotMatchesCurrent(currentText: string, snapshot: Snapshot): boolean {
164
- return snapshot.matchesLiveFile(currentText.split("\n"));
165
- }
166
-
167
131
  /** First 1-indexed line at which `a` and `b` diverge, or `undefined` if equal. */
168
132
  function findFirstChangedLine(a: string, b: string): number | undefined {
169
133
  if (a === b) return undefined;
@@ -182,52 +146,38 @@ function isHeadSnapshot(head: Snapshot | null, snapshot: Snapshot): boolean {
182
146
 
183
147
  /**
184
148
  * Stateless recovery driver over a {@link SnapshotStore}. Construct once and
185
- * call {@link Recovery.tryRecover} per stale-hash incident. The default
186
- * implementation tries three strategies in order:
149
+ * call {@link Recovery.tryRecover} per stale-tag incident. The default
150
+ * implementation tries two strategies in order:
187
151
  *
188
- * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
189
- * 2. (Session chain) If the snapshot wasn't the head, retry on current text
190
- * when line counts match AND every edit's anchor line content is unchanged
191
- * between snapshot and current the previous in-session edit advanced
192
- * the hash and the model's anchors still name the same logical rows. Emits
193
- * a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
194
- * both guards a coincidental insert+delete pair on duplicate rows can
195
- * still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
196
- * 3. Reconstruct from a sparse snapshot (lines map only), then 3-way-merge.
197
- * Sparse snapshots that still match the live file are direct-apply cases
198
- * owned by the patcher, so recovery declines them.
152
+ * 1. Apply the edits on the full-file version the tag names, then 3-way-merge
153
+ * the resulting patch onto the live content (handles external writes).
154
+ * 2. (Session chain) If that version wasn't the head, replay the edits onto
155
+ * the live content directly when line counts match AND every edit's anchor
156
+ * line content is unchanged between version and current a prior in-session
157
+ * edit advanced the tag and the model's anchors still name the same logical
158
+ * rows. Emits a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because
159
+ * even with both guards a coincidental insert+delete pair on duplicate rows
160
+ * can still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
199
161
  */
200
162
  export class Recovery {
201
163
  constructor(readonly store: SnapshotStore) {}
202
-
203
164
  /**
204
165
  * Attempt recovery. Returns `null` when no path forward is found — the
205
166
  * caller should then surface a {@link MismatchError}.
206
167
  */
207
168
  tryRecover(args: RecoveryArgs): RecoveryResult | null {
208
169
  const { path, currentText, fileHash, edits } = args;
209
- const head = this.store.head(path);
210
170
  const snapshot = this.store.byHash(path, fileHash);
211
- if (!snapshot || !snapshotHasEntries(snapshot)) return null;
212
-
213
- const isHead = isHeadSnapshot(head, snapshot);
171
+ if (!snapshot) return null;
172
+ const isHead = isHeadSnapshot(this.store.head(path), snapshot);
214
173
  const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
215
- const isSessionChain = !isHead;
216
-
217
- if (snapshot.fullText !== undefined) {
218
- const merged = applyEditsToSnapshot(snapshot.fullText, currentText, edits, recoveryWarning);
219
- if (merged !== null) return merged;
220
- // Session-chain fallback: the 3-way merge on the snapshot refused.
221
- // Replay onto current is gated by line-count equality AND
222
- // anchor-content alignment — see `replaySessionChainOnCurrent`
223
- // for why both guards together still don't fully prove correctness.
224
- if (isSessionChain) return replaySessionChainOnCurrent(snapshot.fullText, currentText, edits);
225
- return null;
226
- }
227
-
228
- if (!sparseSnapshotCoversAnchors(snapshot, edits)) return null;
229
- if (sparseSnapshotMatchesCurrent(currentText, snapshot)) return null;
230
- const overlayText = buildSparseOverlayText(currentText, snapshot);
231
- return applyEditsToSnapshot(overlayText, currentText, edits, recoveryWarning);
174
+ const merged = applyEditsToSnapshot(snapshot.text, currentText, edits, recoveryWarning);
175
+ if (merged !== null) return merged;
176
+ // Session-chain fallback: the 3-way merge on the version refused.
177
+ // Replay onto current is gated by line-count equality AND
178
+ // anchor-content alignment see `replaySessionChainOnCurrent`
179
+ // for why both guards together still don't fully prove correctness.
180
+ if (!isHead) return replaySessionChainOnCurrent(snapshot.text, currentText, edits);
181
+ return null;
232
182
  }
233
183
  }