@oh-my-pi/hashline 16.3.0 → 16.3.3

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.3] - 2026-07-02
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed SnapshotStore.byHashExact. Consumers should now use byHash, which resolves collisions by returning the most recently recorded version.
10
+
11
+ ### Changed
12
+
13
+ - Improved patch application robustness by resolving 16-bit snapshot tag collisions to the most recent version instead of rejecting them.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed frequent edit rejections after a structural-summary read (affecting parseable code over 100 lines) by automatically inlining unseen anchor lines and merging them into the snapshot's seen lines, allowing immediate retries to succeed without requiring a separate range re-read.
18
+
5
19
  ## [16.3.0] - 2026-07-02
6
20
 
7
21
  ### Changed
@@ -108,13 +108,33 @@ export declare function missingSnapshotTagMessage(sectionPath: string): string;
108
108
  * model (and user) learn the corrected path and stop reusing the wrong one.
109
109
  */
110
110
  export declare function pathRecoveredFromTagMessage(authoredPath: string, resolvedPath: string, tag: string): string;
111
+ /** One anchored line whose actual content is being surfaced in an error message. */
112
+ export interface RevealedLine {
113
+ line: number;
114
+ text: string;
115
+ }
116
+ /**
117
+ * Content preview handed to {@link unseenLinesMessage}. `lines` are the
118
+ * unseen anchor lines whose actual file content we surface inline (from the
119
+ * tagged snapshot the caller matched). `truncated` = true means the anchor
120
+ * range exceeded the inline reveal cap; the caller only revealed a prefix
121
+ * and the remaining unseen lines still require a range re-read.
122
+ */
123
+ export interface UnseenLinesReveal {
124
+ lines: readonly RevealedLine[];
125
+ truncated: boolean;
126
+ }
111
127
  /**
112
128
  * An anchored edit referenced lines the read that minted the cited tag never
113
129
  * displayed (a partial range, or a structural summary that collapsed bodies).
114
130
  * Editing lines you have not read is the off-by-memory failure that mangles
115
- * files; reject and make the model re-read those exact lines first.
131
+ * files. When `reveal.lines` is non-empty, the caller has already inlined the
132
+ * actual file content at those lines and merged them into the snapshot's
133
+ * seen-line set, so the message points the model at a straight retry with the
134
+ * same `[path#tag]` header; when the reveal is empty or truncated, the
135
+ * message falls back to instructing a range re-read.
116
136
  */
117
- export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string;
137
+ export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string, reveal?: UnseenLinesReveal): string;
118
138
  /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
119
139
  export type BlockOp = "replace" | "delete" | "insert_after";
120
140
  /**
@@ -23,8 +23,8 @@ export interface Snapshot {
23
23
  }
24
24
  /**
25
25
  * Storage seam for full-file version snapshots. The patcher calls {@link head}
26
- * for the latest version of a path and {@link byHashExact} when it needs the
27
- * specific historical version a section's stale tag names.
26
+ * for the latest version of a path and {@link byHash} when it needs the
27
+ * historical version a section's stale tag names.
28
28
  */
29
29
  export declare abstract class SnapshotStore {
30
30
  /** Most-recently recorded version for `path`, or `null` if none. */
@@ -32,25 +32,13 @@ export declare abstract class SnapshotStore {
32
32
  /**
33
33
  * Recorded version for `path` whose tag equals `hash`, or `null`. When two
34
34
  * distinct texts collide on the 16-bit tag, returns the most-recently
35
- * recorded one; callers that treat the tag as content identity must use
36
- * {@link byHashExact} (or verify {@link Snapshot.text} via {@link byContent}).
35
+ * recorded one.
37
36
  */
38
37
  abstract byHash(path: string, hash: string): Snapshot | null;
39
- /**
40
- * Collision-safe {@link byHash}: the single retained version for `path`
41
- * whose tag equals `hash`, or `null` when none is retained OR when two or
42
- * more distinct texts collide on the tag. In the collision case there is
43
- * no way to know which retained text the model's line anchors were minted
44
- * against, so consumers that replay anchors (recovery, previews) must
45
- * refuse rather than pick one.
46
- */
47
- abstract byHashExact(path: string, hash: string): Snapshot | null;
48
38
  /**
49
39
  * Recorded version for `path` whose {@link Snapshot.text} equals `fullText`,
50
- * or `null`. Disambiguates hash collisions where two distinct file states
51
- * share the same 4-hex tag: the patcher consults this before taking the
52
- * no-drift path so a colliding live text is never accepted as the exact
53
- * snapshot the model's line anchors were minted against.
40
+ * or `null`. The patcher uses it on the no-drift path to attach seen-line
41
+ * provenance to the exact text the model read.
54
42
  */
55
43
  abstract byContent(path: string, fullText: string): Snapshot | null;
56
44
  /**
@@ -116,7 +104,6 @@ export declare class InMemorySnapshotStore extends SnapshotStore {
116
104
  constructor(options?: InMemorySnapshotStoreOptions);
117
105
  head(path: string): Snapshot | null;
118
106
  byHash(path: string, hash: string): Snapshot | null;
119
- byHashExact(path: string, hash: string): Snapshot | null;
120
107
  byContent(path: string, fullText: string): Snapshot | null;
121
108
  findByHash(hash: string): Snapshot[];
122
109
  record(path: string, fullText: string, seenLines?: Iterable<number>): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "16.3.0",
4
+ "version": "16.3.3",
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/messages.ts CHANGED
@@ -223,21 +223,66 @@ function formatLineRanges(lines: readonly number[]): string {
223
223
  return parts.join(", ");
224
224
  }
225
225
 
226
+ /** One anchored line whose actual content is being surfaced in an error message. */
227
+ export interface RevealedLine {
228
+ line: number;
229
+ text: string;
230
+ }
231
+
232
+ /**
233
+ * Content preview handed to {@link unseenLinesMessage}. `lines` are the
234
+ * unseen anchor lines whose actual file content we surface inline (from the
235
+ * tagged snapshot the caller matched). `truncated` = true means the anchor
236
+ * range exceeded the inline reveal cap; the caller only revealed a prefix
237
+ * and the remaining unseen lines still require a range re-read.
238
+ */
239
+ export interface UnseenLinesReveal {
240
+ lines: readonly RevealedLine[];
241
+ truncated: boolean;
242
+ }
243
+
226
244
  /**
227
245
  * An anchored edit referenced lines the read that minted the cited tag never
228
246
  * displayed (a partial range, or a structural summary that collapsed bodies).
229
247
  * Editing lines you have not read is the off-by-memory failure that mangles
230
- * files; reject and make the model re-read those exact lines first.
248
+ * files. When `reveal.lines` is non-empty, the caller has already inlined the
249
+ * actual file content at those lines and merged them into the snapshot's
250
+ * seen-line set, so the message points the model at a straight retry with the
251
+ * same `[path#tag]` header; when the reveal is empty or truncated, the
252
+ * message falls back to instructing a range re-read.
231
253
  */
232
- export function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string {
254
+ export function unseenLinesMessage(
255
+ sectionPath: string,
256
+ unseenLines: readonly number[],
257
+ tag: string,
258
+ reveal: UnseenLinesReveal = { lines: [], truncated: false },
259
+ ): string {
233
260
  const ranges = formatLineRanges(unseenLines);
234
261
  const selector = ranges.replace(/, /g, ",");
235
- return (
262
+ const header =
236
263
  `This edit anchors to lines ${ranges} of ${sectionPath} that ` +
237
264
  `${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} never displayed (it showed a ` +
238
- `partial range, a search hit, or a folded summary). Re-read them in full first with a ranged read like ` +
239
- `\`${sectionPath}:${selector}\` — it skips summarization and mints a fresh tag (a plain re-read just re-folds ` +
240
- `them) — then re-issue the edit.`
265
+ `partial range, a search hit, or a folded summary).`;
266
+ if (reveal.lines.length === 0) {
267
+ return (
268
+ `${header} Re-read them in full first with a ranged read like ` +
269
+ `\`${sectionPath}:${selector}\` — it skips summarization and mints a fresh tag (a plain re-read just re-folds ` +
270
+ `them) — then re-issue the edit.`
271
+ );
272
+ }
273
+ const preview = reveal.lines.map(({ line, text }) => ` ${formatNumberedLine(line, text)}`).join("\n");
274
+ if (reveal.truncated) {
275
+ return (
276
+ `${header} Preview of the actual file content at the first ${reveal.lines.length} unseen line(s):\n${preview}\n` +
277
+ `The range exceeds the inline preview cap — re-read the remainder with \`${sectionPath}:${selector}\` before ` +
278
+ `re-issuing the edit.`
279
+ );
280
+ }
281
+ return (
282
+ `${header} Actual file content at those lines:\n${preview}\n` +
283
+ `Verify the content matches what you intend to touch, then re-issue the edit with the same ` +
284
+ `${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — a straight retry now succeeds without a re-read. ` +
285
+ `If the content does NOT match, fix your line numbers.`
241
286
  );
242
287
  }
243
288
 
package/src/patcher.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  HEADTAIL_DRIFT_WARNING,
34
34
  missingSnapshotTagMessage,
35
35
  pathRecoveredFromTagMessage,
36
+ type RevealedLine,
36
37
  unseenLinesMessage,
37
38
  } from "./messages";
38
39
  import { MismatchError } from "./mismatch";
@@ -41,6 +42,26 @@ import { Recovery, type RecoveryResult } from "./recovery";
41
42
  import type { Snapshot, SnapshotStore } from "./snapshots";
42
43
  import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types";
43
44
 
45
+ /**
46
+ * Upper bound on the number of unseen anchor lines whose actual file content
47
+ * we inline into a rejection error (see {@link Patcher.assertSeenLines}). Big
48
+ * enough to fit the common "edit a whole function body" retry path in one
49
+ * message, small enough to keep the error human-readable when the model
50
+ * over-anchors and to preserve the "re-read first" fallback for genuinely
51
+ * blind wide edits (only the revealed prefix gets merged into `seenLines`).
52
+ */
53
+ const SEEN_LINE_REVEAL_CAP = 40;
54
+
55
+ /**
56
+ * Per-revealed-line character cap. Matches the read/search column cap so a
57
+ * revealed anchor line can never dump a minified megabyte-wide bundle line
58
+ * into the tool error, TUI, and model context. Lines longer than the cap
59
+ * are trimmed to `cap` characters plus an `…` marker AND flag the entire
60
+ * reveal as truncated so no line joins `seenLines` — the model must re-read
61
+ * the range to prove it saw the full width.
62
+ */
63
+ const SEEN_LINE_REVEAL_MAX_COLUMNS = 512;
64
+
44
65
  export interface PatcherOptions {
45
66
  /** Storage backend used for all reads and writes. */
46
67
  fs: Filesystem;
@@ -486,13 +507,55 @@ export class Patcher {
486
507
  * externally minted or aged out), so the edit applies as before. Only runs
487
508
  * on the no-drift path, where anchor line numbers index the tagged content
488
509
  * 1:1.
510
+ *
511
+ * The rejection inlines the actual file content at the unseen anchor lines
512
+ * (from `matchedSnapshot.text`, which by definition equals the live
513
+ * normalized content) so the model can verify what it was about to touch.
514
+ * When the reveal covers EVERY unseen anchor line in full width
515
+ * (`truncated === false`) those lines also merge into the snapshot's
516
+ * seen-line set, so a straight retry with the same `[path#tag]` header
517
+ * succeeds without a follow-up range read — the content the model
518
+ * received in the error IS proof it has now seen those lines. When the
519
+ * anchor range exceeds {@link SEEN_LINE_REVEAL_CAP} lines OR any
520
+ * revealed line exceeds {@link SEEN_LINE_REVEAL_MAX_COLUMNS} characters
521
+ * (`truncated === true`), NO lines merge: the message keeps the
522
+ * range-re-read guidance intact and the model cannot piecewise-reveal
523
+ * its way past the guard across multiple retries
524
+ * (over-cap retry → tail reveal → next retry applies), nor coax the tool
525
+ * into dumping a minified megabyte-wide line into the error preview.
489
526
  */
490
527
  #assertSeenLines(section: PatchSection, expected: string, matchedSnapshot: Snapshot | null): void {
491
528
  const seen = matchedSnapshot?.seenLines;
492
529
  if (!seen || seen.size === 0) return;
493
530
  const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
494
531
  if (unseen.length === 0) return;
495
- throw new Error(unseenLinesMessage(section.path, unseen, expected));
532
+ const sourceLines = matchedSnapshot?.text.split("\n") ?? [];
533
+ const revealed: RevealedLine[] = [];
534
+ const revealCount = Math.min(unseen.length, SEEN_LINE_REVEAL_CAP);
535
+ let columnTruncated = false;
536
+ for (let i = 0; i < revealCount; i++) {
537
+ const line = unseen[i];
538
+ // Out-of-range anchors are caught by parse/apply with a better
539
+ // message; skip them here so they never join the revealed set.
540
+ if (line < 1 || line > sourceLines.length) continue;
541
+ const source = sourceLines[line - 1] ?? "";
542
+ if (source.length > SEEN_LINE_REVEAL_MAX_COLUMNS) {
543
+ revealed.push({ line, text: `${source.slice(0, SEEN_LINE_REVEAL_MAX_COLUMNS)}…` });
544
+ columnTruncated = true;
545
+ } else {
546
+ revealed.push({ line, text: source });
547
+ }
548
+ }
549
+ const truncated = unseen.length > revealed.length || columnTruncated;
550
+ // Only merge when the reveal covered every unseen anchor line in full
551
+ // width. A prefix-truncated reveal would let the model split a blind
552
+ // edit into <=cap-line retries and land it without ever running the
553
+ // required range re-read; a column-clipped reveal would leave part of
554
+ // each line unseen while the model receives an "ok to retry" signal.
555
+ if (!truncated) {
556
+ for (const { line } of revealed) seen.add(line);
557
+ }
558
+ throw new Error(unseenLinesMessage(section.path, unseen, expected, { lines: revealed, truncated }));
496
559
  }
497
560
  #mismatchError(
498
561
  section: PatchSection,
@@ -521,23 +584,13 @@ export class Patcher {
521
584
  }): ApplyResult {
522
585
  const { section, canonicalPath, exists, normalized, edits } = args;
523
586
  const expected = exists ? section.fileHash : undefined;
524
- // A 16-bit tag can collide across two different file states, so equality
525
- // on `computeFileHash(normalized) === expected` alone is not enough to
526
- // prove the live text IS the snapshot the tag names. Also require that,
527
- // when a snapshot for `(path, expected)` is retained, exactly one stored
528
- // version carries the tag and its full text matches the live text. If
529
- // multiple versions share the tag, the header is ambiguous: there is no
530
- // safe way to know which stored text the model's line anchors came from.
531
- const storedSnapshotsForTag =
532
- expected === undefined
533
- ? []
534
- : this.snapshots.findByHash(expected).filter(snapshot => snapshot.path === canonicalPath);
535
- const ambiguousStoredTag = storedSnapshotsForTag.length > 1;
587
+ // The 4-hex tag is content-derived: when the live text hashes to it,
588
+ // trust the match and apply directly. `storedSnapshotForTag` feeds the
589
+ // drift paths below (block resolution, 3-way recovery); on a 16-bit
590
+ // tag collision it resolves to the most-recently recorded text.
536
591
  const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected);
537
- const hashMatches = expected !== undefined && computeFileHash(normalized) === expected;
538
- const matchedSnapshot = hashMatches ? this.snapshots.byContent(canonicalPath, normalized) : null;
539
- const liveMatches =
540
- hashMatches && !ambiguousStoredTag && (storedSnapshotForTag === null || matchedSnapshot !== null);
592
+ const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
593
+ const matchedSnapshot = liveMatches ? this.snapshots.byContent(canonicalPath, normalized) : null;
541
594
 
542
595
  // Resolve `replace_block N:` edits to concrete ranges before recovery
543
596
  // runs. Block anchors are expressed against the snapshot the section tag
@@ -552,9 +605,6 @@ export class Patcher {
552
605
  const resolveWarnings: string[] = [];
553
606
  let resolved: readonly Edit[] = edits;
554
607
  if (hasBlockEdit(edits)) {
555
- if (ambiguousStoredTag) {
556
- throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
557
- }
558
608
  const baseText = expected === undefined || liveMatches ? normalized : storedSnapshotForTag?.text;
559
609
  if (baseText === undefined) {
560
610
  throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
@@ -590,9 +640,6 @@ export class Patcher {
590
640
  const result = applyEdits(normalized, resolved);
591
641
  return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
592
642
  }
593
- if (ambiguousStoredTag) {
594
- throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
595
- }
596
643
  // File drifted: try to replay the edit against the version the tag
597
644
  // names and 3-way-merge it onto the live content.
598
645
  const recovered = this.recovery.tryRecover({
package/src/recovery.ts CHANGED
@@ -392,11 +392,10 @@ export class Recovery {
392
392
  */
393
393
  tryRecover(args: RecoveryArgs): RecoveryResult | null {
394
394
  const { path, currentText, fileHash, edits } = args;
395
- // Collision-safe lookup: when two retained texts share the 16-bit tag
396
- // there is no way to know which one the model's anchors were minted
397
- // against replaying against the wrong collider would land the edit
398
- // on unrelated content. Refuse and let the caller reject (re-read).
399
- const snapshot = this.store.byHashExact(path, fileHash);
395
+ // When two retained texts collide on the 16-bit tag, resolve to the
396
+ // most-recently recorded one; a wrong pick can only land if one of the
397
+ // merge/remap/session-chain strategies below applies it cleanly.
398
+ const snapshot = this.store.byHash(path, fileHash);
400
399
  if (!snapshot) return null;
401
400
  const isHead = isHeadSnapshot(this.store.head(path), snapshot);
402
401
  const recoveryWarning = isHead ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
package/src/snapshots.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * {@link SnapshotStore.record} with the full normalized text they observed.
12
12
  * The store hashes it, dedups against the per-path history, and returns the
13
13
  * tag. Consumers (recovery, the patcher) resolve a stale tag back to the
14
- * recorded full text via {@link SnapshotStore.byHashExact} and 3-way-merge the
14
+ * recorded full text via {@link SnapshotStore.byHash} and 3-way-merge the
15
15
  * would-be edit onto the live content.
16
16
  *
17
17
  * The abstract base class lets callers plug in whatever storage they like
@@ -49,8 +49,8 @@ export interface Snapshot {
49
49
 
50
50
  /**
51
51
  * Storage seam for full-file version snapshots. The patcher calls {@link head}
52
- * for the latest version of a path and {@link byHashExact} when it needs the
53
- * specific historical version a section's stale tag names.
52
+ * for the latest version of a path and {@link byHash} when it needs the
53
+ * historical version a section's stale tag names.
54
54
  */
55
55
  export abstract class SnapshotStore {
56
56
  /** Most-recently recorded version for `path`, or `null` if none. */
@@ -59,27 +59,14 @@ export abstract class SnapshotStore {
59
59
  /**
60
60
  * Recorded version for `path` whose tag equals `hash`, or `null`. When two
61
61
  * distinct texts collide on the 16-bit tag, returns the most-recently
62
- * recorded one; callers that treat the tag as content identity must use
63
- * {@link byHashExact} (or verify {@link Snapshot.text} via {@link byContent}).
62
+ * recorded one.
64
63
  */
65
64
  abstract byHash(path: string, hash: string): Snapshot | null;
66
65
 
67
- /**
68
- * Collision-safe {@link byHash}: the single retained version for `path`
69
- * whose tag equals `hash`, or `null` when none is retained OR when two or
70
- * more distinct texts collide on the tag. In the collision case there is
71
- * no way to know which retained text the model's line anchors were minted
72
- * against, so consumers that replay anchors (recovery, previews) must
73
- * refuse rather than pick one.
74
- */
75
- abstract byHashExact(path: string, hash: string): Snapshot | null;
76
-
77
66
  /**
78
67
  * Recorded version for `path` whose {@link Snapshot.text} equals `fullText`,
79
- * or `null`. Disambiguates hash collisions where two distinct file states
80
- * share the same 4-hex tag: the patcher consults this before taking the
81
- * no-drift path so a colliding live text is never accepted as the exact
82
- * snapshot the model's line anchors were minted against.
68
+ * or `null`. The patcher uses it on the no-drift path to attach seen-line
69
+ * provenance to the exact text the model read.
83
70
  */
84
71
  abstract byContent(path: string, fullText: string): Snapshot | null;
85
72
 
@@ -189,20 +176,6 @@ export class InMemorySnapshotStore extends SnapshotStore {
189
176
  return history?.find(version => version.hash === hash) ?? null;
190
177
  }
191
178
 
192
- byHashExact(path: string, hash: string): Snapshot | null {
193
- const history = this.#versions.get(path);
194
- if (history === undefined) return null;
195
- let match: Snapshot | null = null;
196
- for (const version of history) {
197
- if (version.hash !== hash) continue;
198
- // Two retained versions with one tag are distinct texts by
199
- // construction (record() dedups on full-text equality) — ambiguous.
200
- if (match !== null) return null;
201
- match = version;
202
- }
203
- return match;
204
- }
205
-
206
179
  byContent(path: string, fullText: string): Snapshot | null {
207
180
  const history = this.#versions.get(path);
208
181
  return history?.find(version => version.text === fullText) ?? null;