@oh-my-pi/hashline 15.5.12 → 15.5.15

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.
@@ -3,49 +3,63 @@
3
3
  * display helpers. These are the single source of truth for the parser, the
4
4
  * tokenizer, the prompt, and the formal grammar.
5
5
  */
6
+ import type { Cursor } from "./types";
6
7
  /** File-section header prefix: `¶path#hash`. */
7
8
  export declare const HL_FILE_PREFIX = "\u00B6";
8
9
  /** Payload sigil for literal body rows. */
9
10
  export declare const HL_PAYLOAD_REPLACE = "+";
10
- /** Payload sigil for body rows that repeat original file lines. */
11
- export declare const HL_PAYLOAD_REPEAT = "&";
12
- /** All hashline payload sigils, concatenated for fast membership tests. */
13
- export declare const HL_PAYLOAD_CHARS = "+&";
11
+ /** Hunk-header keyword for concrete line replacement. */
12
+ export declare const HL_REPLACE_KEYWORD = "replace";
13
+ /** Hunk-header keyword for concrete line deletion. */
14
+ export declare const HL_DELETE_KEYWORD = "delete";
15
+ /** Hunk-header keyword for insertion operations. */
16
+ export declare const HL_INSERT_KEYWORD = "insert";
17
+ /** Insert position keyword for inserting before a concrete line. */
18
+ export declare const HL_INSERT_BEFORE = "before";
19
+ /** Insert position keyword for inserting after a concrete line. */
20
+ export declare const HL_INSERT_AFTER = "after";
21
+ /** Insert position keyword for inserting at the start of the file. */
22
+ export declare const HL_INSERT_HEAD = "head";
23
+ /** Insert position keyword for inserting at the end of the file. */
24
+ export declare const HL_INSERT_TAIL = "tail";
25
+ /** Hunk-header terminator for body-bearing operations. */
26
+ export declare const HL_HEADER_COLON = ":";
14
27
  /** Separator between a hashline file path and its opaque snapshot tag. */
15
28
  export declare const HL_FILE_HASH_SEP = "#";
16
29
  /** Separator between two line numbers in a range, e.g. `5..10`. */
17
30
  export declare const HL_RANGE_SEP = "..";
18
31
  /** Separator between a line number and displayed line content in hashline mode. */
19
32
  export declare const HL_LINE_BODY_SEP = ":";
20
- /**
21
- * Decoration prefix that may precede a line number in tool output:
22
- * `*` (match line), `>` (context line in grep). Any combination, in any
23
- * order, surrounded by optional whitespace. Output formatters emit at most
24
- * one decoration per line; the parser stays liberal because it accepts
25
- * whatever the model echoes back.
26
- */
27
- export declare const HL_ANCHOR_DECORATION_RE_RAW = "\\s*[>*]*\\s*";
28
- /** Capture-group regex source for a decorated bare line-number anchor. */
29
- export declare const HL_ANCHOR_RE_RAW = "\\s*[>*]*\\s*(\\d+)";
30
33
  /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
31
34
  export declare const HL_LINE_RE_RAW = "[1-9]\\d*";
32
35
  /** Capture-group form of {@link HL_LINE_RE_RAW}. */
33
36
  export declare const HL_LINE_CAPTURE_RE_RAW = "([1-9]\\d*)";
34
- /** Regex for repeat payload rows (`&A..B`). */
35
- export declare const HL_PAYLOAD_REPEAT_RE: RegExp;
36
- /** Number of hex characters in an opaque snapshot tag. */
37
- export declare const HL_FILE_HASH_LENGTH = 3;
38
- /** Canonical uppercase hexadecimal opaque snapshot tag carried by a hashline section header. */
39
- export declare const HL_FILE_HASH_RE_RAW = "[0-9A-F]{3}";
37
+ /** Format a concrete replacement hunk header. */
38
+ export declare function formatReplaceHeader(start: number, end: number): string;
39
+ /** Format a concrete deletion hunk header. */
40
+ export declare function formatDeleteHeader(start: number, end?: number): string;
41
+ /** Format an insertion hunk header for a cursor position. */
42
+ export declare function formatInsertHeader(cursor: Cursor): string;
43
+ /** Number of hex characters in a content-derived file-hash tag. */
44
+ export declare const HL_FILE_HASH_LENGTH = 4;
45
+ /** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
46
+ export declare const HL_FILE_HASH_RE_RAW = "[0-9A-F]{4}";
40
47
  /** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
41
- export declare const HL_FILE_HASH_CAPTURE_RE_RAW = "([0-9A-F]{3})";
48
+ export declare const HL_FILE_HASH_CAPTURE_RE_RAW = "([0-9A-F]{4})";
42
49
  /** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
43
50
  export declare const HL_LINE_BODY_SEP_RE_RAW: string;
44
51
  /**
45
- * Representative snapshot tags for use in user-facing error messages and
52
+ * Representative file-hash tags for use in user-facing error messages and
46
53
  * prompt examples.
47
54
  */
48
- export declare const HL_FILE_HASH_EXAMPLES: readonly ["0A3", "1F7", "3C9"];
55
+ export declare const HL_FILE_HASH_EXAMPLES: readonly ["1A2B", "3C4D", "9F3E"];
56
+ /**
57
+ * Compute the content-derived hash tag carried by a hashline section header.
58
+ * The tag is a 4-hex fingerprint of the whole file's normalized text: any read
59
+ * of byte-identical content mints the same tag, and a follow-up edit anchored
60
+ * at any line validates whenever the live file still hashes to it.
61
+ */
62
+ export declare function computeFileHash(text: string): string;
49
63
  /**
50
64
  * Format a comma-separated list of example anchors with an optional line-number
51
65
  * prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
@@ -36,9 +36,9 @@ export declare class PatchSection {
36
36
  /** Warnings emitted during parsing of this section. */
37
37
  get warnings(): readonly string[];
38
38
  /**
39
- * True when at least one edit anchors to concrete file content. Pure BOF/EOF
40
- * literal inserts do not count: those are safe to apply to files that don't
41
- * yet exist.
39
+ * True when at least one edit anchors to concrete file content. Pure
40
+ * `insert head:` / `insert tail:` literal inserts do not count: those are
41
+ * safe to apply to files that don't yet exist.
42
42
  */
43
43
  get hasAnchorScopedEdit(): boolean;
44
44
  /** Anchor lines touched by this section, sorted ascending and deduplicated. */
@@ -16,40 +16,20 @@ export declare const END_PATCH_MARKER = "*** End Patch";
16
16
  * parsing — terminates the line loop — and does not surface a warning.
17
17
  */
18
18
  export declare const ABORT_MARKER = "*** Abort";
19
- /**
20
- * Warning text appended when two consecutive hunks target the exact same
21
- * concrete range. The second hunk wins; the first is discarded.
22
- */
23
- export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected two identical-range hashline hunks; kept only the second hunk. Issue ONE hunk per range \u2014 payload is the final desired content, never both old and new.";
24
- /**
25
- * Warning text appended when a bare hunk header (`A B` with no body)
26
- * is followed by an overlapping concrete hunk. The earlier bare hunk is
27
- * dropped on the assumption that the model expressed an old/new pair across
28
- * two hunks; only the second hunk's payload is applied.
29
- */
30
- export declare const REPLACE_PAIR_COALESCED_OVERLAP_WARNING = "Detected an overlapping bare hashline hunk immediately followed by a concrete hunk; dropped the earlier bare hunk. Issue ONE hunk per range \u2014 payload is the final desired content, never both old and new.";
31
- /**
32
- * Warning text appended when bare body rows (no `+` / `&` prefix) follow a
33
- * hunk header and the parser auto-converts them to `+literal` rows because
34
- * no `+`/`&` row was present in the hunk. Helps the model learn the
35
- * canonical body-row syntax while keeping the patch applying.
36
- */
37
- export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Always start payload rows with `+TEXT` (literal) or `&A..B` (repeat) \u2014 pasting raw code as payload is not a portable shape.";
38
- /**
39
- * Warning text emitted when a body row begins with `+&A..B` — the model
40
- * mistakenly prefixed a repeat row with the `+` literal sigil. We reroute
41
- * the row as a `&A..B` repeat so the patch still applies, then surface this
42
- * warning so the model sees the mistake on the next turn.
43
- */
44
- export declare const PLUS_PREFIXED_REPEAT_WARNING = "A body row started with `+&A..B`. `+` (literal text) and `&A..B` (repeat) are sibling row kinds \u2014 a row uses exactly one of them. Treated as `&A..B`; remove the leading `+` next time.";
45
- /**
46
- * Warning text emitted when a hunk body contains unified-diff-style rows
47
- * (`-old`, ` context`) and the parser silently converts them: `-` rows are
48
- * dropped (the hunk header's range already deletes those lines), and the
49
- * leading metadata-space on context rows is stripped once unified-diff
50
- * mode is detected. Bare body rows are auto-prefixed with `+` regardless.
51
- */
52
- export declare const UNIFIED_DIFF_BODY_AUTO_CONVERT_WARNING = "Hunk body contained unified-diff-style rows (`-old`, ` context`). The `-` rows were dropped (the hunk header's range already deletes those lines); context rows were treated as `+TEXT` literals. Use `+TEXT` (literal) or `&A..B` (repeat) directly next time.";
19
+ /** Warning text appended when two consecutive hunks target the exact same concrete range. */
20
+ export declare const REPLACE_PAIR_COALESCED_WARNING = "Detected two identical-range hashline hunks; kept only the second hunk. Issue ONE `replace N..M:` hunk per range \u2014 payload is the final desired content, never both old and new.";
21
+ /** Warning text appended when an empty bodyless hunk is followed by an overlapping concrete hunk. */
22
+ export declare const REPLACE_PAIR_COALESCED_OVERLAP_WARNING = "Detected an overlapping bare hashline hunk immediately followed by a concrete hunk; dropped the earlier bare hunk. Issue ONE `replace N..M:` hunk per range \u2014 payload is the final desired content, never both old and new.";
23
+ /** Warning text appended when bare body rows are auto-converted to literal rows. */
24
+ export declare const BARE_BODY_AUTO_PIPED_WARNING = "Auto-prefixed bare body row(s) with `+`. Body rows must be `+TEXT` literal lines; pasting raw code as payload is not a portable shape.";
25
+ /** Error text emitted when a hunk body contains a unified-diff-style `-` row. */
26
+ export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; hashline ranges already name the lines being changed. To insert a literal line starting with `-`, write `+-\u2026`.";
27
+ /** Error text emitted when a replace hunk has no body. */
28
+ export declare const EMPTY_REPLACE = "`replace N..M:` needs at least one `+TEXT` body row. To delete lines, use `delete N..M`.";
29
+ /** Error text emitted when a delete hunk receives a body row. */
30
+ export declare const DELETE_TAKES_NO_BODY = "`delete N..M` does not take body rows. Remove the body, or use `replace N..M:`.";
31
+ /** Error text emitted when an insert hunk has no body. */
32
+ export declare const EMPTY_INSERT = "`insert` needs at least one `+TEXT` body row.";
53
33
  /** Warning text emitted by `Recovery` when an external write fits a cached snapshot. */
54
34
  export declare const RECOVERY_EXTERNAL_WARNING = "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
55
35
  /** Warning text emitted by `Recovery` when a prior in-session edit advanced the hash. */
@@ -1,75 +1,22 @@
1
1
  import { type Token } from "./tokenizer";
2
2
  import type { Edit } from "./types";
3
- /**
4
- * Token-driven state machine that turns a stream of {@link Token}s into a
5
- * flat list of {@link Edit}s.
6
- *
7
- * `feed()` accepts tokens one at a time; hunk body rows accumulate until
8
- * the next hunk header or {@link end} flushes them. After `terminated`
9
- * flips true (on `envelope-end` or `abort`) subsequent feeds are silently
10
- * ignored so callers can keep draining their tokenizer.
11
- */
12
3
  export declare class Executor {
13
4
  #private;
14
- /** True once an `envelope-end` or `abort` token has been observed. */
15
- get terminated(): boolean;
16
- /**
17
- * Consume one token. After `terminated` flips true subsequent feeds are
18
- * silently ignored so callers can keep draining the tokenizer without
19
- * explicit early-exit guards.
20
- */
21
5
  feed(token: Token): void;
22
- /**
23
- * Flush any open pending hunk and return the accumulated edits and
24
- * warnings. The executor is single-use; {@link reset} is required for
25
- * reuse.
26
- *
27
- * Throws if two hunks target the same line with non-identical ranges.
28
- * Identical-range hunks in the same patch are coalesced last-wins by
29
- * `feed()` with a warning, so they never reach the validator.
30
- */
31
6
  end(): {
32
7
  edits: Edit[];
33
8
  warnings: string[];
34
9
  };
35
- /**
36
- * Streaming-tolerant variant of {@link end}. Identical, except a pending
37
- * hunk whose body has not yet accumulated any rows is treated as still
38
- * in flight and dropped instead of flushed (which would otherwise commit
39
- * a destructive delete while the model may still be typing payload).
40
- */
41
10
  endStreaming(): {
42
11
  edits: Edit[];
43
12
  warnings: string[];
44
13
  };
45
- /** Reset to a fresh state so the same instance can drive another parse. */
46
14
  reset(): void;
47
15
  }
48
- /**
49
- * Drive a full hashline diff through the tokenizer + executor pipeline and
50
- * return the resulting edits plus any parse-time warnings. This is the
51
- * convenience entry point most callers want; reach for {@link Tokenizer} /
52
- * {@link Executor} directly only when you need streaming feeds, cross-section
53
- * state, or custom token handling.
54
- */
55
16
  export declare function parsePatch(diff: string): {
56
17
  edits: Edit[];
57
18
  warnings: string[];
58
19
  };
59
- /**
60
- * Streaming-tolerant variant of {@link parsePatch}. Returns whatever edits
61
- * parsed successfully when the diff is still being typed:
62
- *
63
- * - per-token feed errors stop the drain but preserve the edits already
64
- * collected (the trailing hunk is malformed mid-stream — wait for the
65
- * next chunk),
66
- * - the trailing pending hunk is dropped if it has no payload yet (avoids
67
- * a destructive bare-delete preview while payload may still be coming).
68
- *
69
- * Throws only on the cross-hunk overlap validator, which catches conflicting
70
- * shapes (two hunks hitting the same anchor). Streaming preview callers
71
- * should treat any throw here as "no preview this tick".
72
- */
73
20
  export declare function parsePatchStreaming(diff: string): {
74
21
  edits: Edit[];
75
22
  warnings: string[];
@@ -16,20 +16,18 @@ export interface RecoveryResult {
16
16
  }
17
17
  /**
18
18
  * Stateless recovery driver over a {@link SnapshotStore}. Construct once and
19
- * call {@link Recovery.tryRecover} per stale-hash incident. The default
20
- * implementation tries three strategies in order:
19
+ * call {@link Recovery.tryRecover} per stale-tag incident. The default
20
+ * implementation tries two strategies in order:
21
21
  *
22
- * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
23
- * 2. (Session chain) If the snapshot wasn't the head, retry on current text
24
- * when line counts match AND every edit's anchor line content is unchanged
25
- * between snapshot and current the previous in-session edit advanced
26
- * the hash and the model's anchors still name the same logical rows. Emits
27
- * a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because even with
28
- * both guards a coincidental insert+delete pair on duplicate rows can
29
- * still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
30
- * 3. Reconstruct from a sparse snapshot (lines map only), then 3-way-merge.
31
- * Sparse snapshots that still match the live file are direct-apply cases
32
- * owned by the patcher, so recovery declines them.
22
+ * 1. Apply the edits on the full-file version the tag names, then 3-way-merge
23
+ * the resulting patch onto the live content (handles external writes).
24
+ * 2. (Session chain) If that version wasn't the head, replay the edits onto
25
+ * the live content directly when line counts match AND every edit's anchor
26
+ * line content is unchanged between version and current a prior in-session
27
+ * edit advanced the tag and the model's anchors still name the same logical
28
+ * rows. Emits a dedicated {@link RECOVERY_SESSION_REPLAY_WARNING} because
29
+ * even with both guards a coincidental insert+delete pair on duplicate rows
30
+ * can still land the edit on the wrong row; see {@link replaySessionChainOnCurrent}.
33
31
  */
34
32
  export declare class Recovery {
35
33
  readonly store: SnapshotStore;
@@ -1,133 +1,55 @@
1
1
  /**
2
- * Per-session snapshot store used by {@link Recovery} and {@link Patcher} to
3
- * bind hashline section tags to the exact file view that minted them.
4
- *
5
- * Producers (typically `read` / `search` tools) record the lines they showed
6
- * the model. The store returns a three-hex opaque tag. Consumers resolve that
7
- * tag back to the recorded snapshot and verify the recorded lines against the
8
- * live file before applying anchored edits.
9
- *
10
- * Tags are scrambled by a deterministic permutation of the 12-bit hex space
11
- * built once at module load. Slot index `i` always maps to the same tag
12
- * across restarts (good for tests and reproducibility), but consecutive slot
13
- * indices produce unrelated tags — `mint()` followed by another `mint()` does
14
- * not return e.g. `000` then `001`, and the first tag a store ever hands out
15
- * is not `000`. This is hallucination prevention, not adversarial security: it
16
- * stops an LLM from guessing `001` after observing `000`, or assuming any
17
- * monotonic counter pattern. The patcher catches stale-tag misuse via
18
- * content verification at apply time, so determinism doesn't reduce safety.
19
- *
20
- * Tag → slot resolution is a single `Map` lookup (no `parseInt`, no regex):
21
- * the inverse table is populated with both lowercase and uppercase forms of
22
- * every tag at module load.
23
- *
24
- * Snapshots are an open abstract type. The two concrete impls cover the
25
- * shapes producers actually emit: {@link ContiguousSnapshot} for `read`-style
26
- * runs (no map allocation, range arithmetic for lookup and superset checks)
27
- * and {@link SparseSnapshot} for `search`-style hits.
28
- *
29
- * The abstract base class lets callers plug in whatever storage they like.
30
- * {@link InMemorySnapshotStore} ships as a single 4096-slot ring shared across
31
- * paths — snapshots carry their own path, so the global ring is fine and
32
- * `byHash` rejects cross-path lookups.
2
+ * One full-file version observed at a point in time. The tag the model sees is
3
+ * {@link Snapshot.hash}; recovery replays edits against {@link Snapshot.text}.
33
4
  */
34
- /**
35
- * One snapshot of a file view as observed at a point in time. The two
36
- * primitive methods subclasses must supply — `get` and `entries` — give callers
37
- * (and the default `isSuperset` / `matchesLiveFile` impls) everything they
38
- * need to verify recorded content against the live file.
39
- */
40
- export declare abstract class Snapshot {
41
- /** Canonical path this snapshot belongs to. */
42
- abstract readonly path: string;
43
- /** Timestamp (ms since epoch) the snapshot was recorded. */
44
- abstract readonly recordedAt: number;
45
- /** Full normalized text when the read observed the whole file. */
46
- abstract readonly fullText?: string;
47
- /** Recorded content for 1-indexed `lineNumber`, or `undefined` if this snapshot doesn't cover that line. */
48
- abstract get(lineNumber: number): string | undefined;
49
- /** Iterate (1-indexed lineNumber, content) pairs in stable order. */
50
- abstract entries(): Iterable<[number, string]>;
51
- /** True iff every (line, content) the `other` snapshot asserts is also present here with matching content. */
52
- isSuperset(other: Snapshot): boolean;
53
- /** True iff every recorded line matches `currentLines` (0-indexed array of live file lines). */
54
- matchesLiveFile(currentLines: readonly string[]): boolean;
55
- }
56
- /**
57
- * A contiguous run of lines starting at `offset` (1-indexed). Backed by a
58
- * plain `string[]`; lookup is `lines[n - offset]`, superset against another
59
- * contiguous snapshot is pure range arithmetic.
60
- */
61
- export declare class ContiguousSnapshot extends Snapshot {
62
- readonly path: string;
63
- readonly offset: number;
64
- readonly lines: readonly string[];
65
- readonly fullText?: string | undefined;
66
- readonly recordedAt: number;
67
- constructor(path: string, offset: number, lines: readonly string[], fullText?: string | undefined, recordedAt?: number);
68
- get(lineNumber: number): string | undefined;
69
- entries(): IterableIterator<[number, string]>;
70
- isSuperset(other: Snapshot): boolean;
71
- matchesLiveFile(currentLines: readonly string[]): boolean;
72
- }
73
- /**
74
- * A sparse `(lineNumber → content)` map, used for snapshots that don't cover a
75
- * single contiguous run — e.g. search hits plus their context windows.
76
- */
77
- export declare class SparseSnapshot extends Snapshot {
5
+ export interface Snapshot {
6
+ /** Canonical path this version belongs to. */
78
7
  readonly path: string;
79
- readonly lines: ReadonlyMap<number, string>;
80
- readonly fullText?: string | undefined;
81
- readonly recordedAt: number;
82
- constructor(path: string, lines: ReadonlyMap<number, string>, fullText?: string | undefined, recordedAt?: number);
83
- get(lineNumber: number): string | undefined;
84
- entries(): IterableIterator<[number, string]>;
85
- }
86
- /** Optional metadata supplied at snapshot record time. */
87
- export interface SnapshotMetadata {
88
- /** Full normalized text, when the producer observed the whole file. */
89
- fullText?: string;
8
+ /** Full normalized (LF, no BOM) file text as observed. */
9
+ readonly text: string;
10
+ /** Content-derived tag for {@link Snapshot.text} (see {@link computeFileHash}). */
11
+ readonly hash: string;
12
+ /** Timestamp (ms since epoch) the version was recorded. */
13
+ recordedAt: number;
90
14
  }
91
15
  /**
92
- * Storage seam for file-content snapshots. Hashline section tags are opaque
93
- * store pointers; without the store that minted them they carry no meaning.
16
+ * Storage seam for full-file version snapshots. The patcher calls {@link head}
17
+ * for the latest version of a path and {@link byHash} when it needs the
18
+ * specific historical version a section's stale tag names.
94
19
  */
95
20
  export declare abstract class SnapshotStore {
96
- /** Most-recently pushed snapshot for `path`, or `null` if none. */
21
+ /** Most-recently recorded version for `path`, or `null` if none. */
97
22
  abstract head(path: string): Snapshot | null;
98
- /** Snapshot currently occupying `tag`'s slot for `path`, or `null`. */
99
- abstract byHash(path: string, tag: string): Snapshot | null;
100
- /** Record a contiguous run of lines (e.g. from a `read` tool). `startLine` is 1-indexed. */
101
- abstract recordContiguous(path: string, startLine: number, lines: readonly string[], metadata?: SnapshotMetadata): string;
102
- /** Record sparse `(lineNumber, content)` pairs (e.g. a `search` match plus context). */
103
- abstract recordSparse(path: string, entries: Iterable<readonly [number, string]>, metadata?: SnapshotMetadata): string;
104
- /** Drop snapshots belonging to a single path. */
23
+ /** Recorded version for `path` whose tag equals `hash`, or `null`. */
24
+ abstract byHash(path: string, hash: string): Snapshot | null;
25
+ /** Record the full normalized text of `path` and return its content tag. */
26
+ abstract record(path: string, fullText: string): string;
27
+ /** Drop the version history for a single path. */
105
28
  abstract invalidate(path: string): void;
106
- /** Drop every snapshot. */
29
+ /** Drop every version history. */
107
30
  abstract clear(): void;
108
31
  }
32
+ export interface InMemorySnapshotStoreOptions {
33
+ /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
34
+ maxPaths?: number;
35
+ /** Maximum full-file versions retained per path (default 4). Oldest dropped first. */
36
+ maxVersionsPerPath?: number;
37
+ }
109
38
  /**
110
- * In-memory {@link SnapshotStore} backed by a flat 4096-slot ring shared across
111
- * all paths. Slot allocation is a simple `counter & 0xfff`; the tag the model
112
- * sees is `FORWARD[slot]` from the module-level permutation, so consecutive
113
- * pushes hand out unrelated tags. Before allocating, {@link InMemorySnapshotStore}
114
- * folds a new view into an existing same-path slot when the two agree on every
115
- * shared line: one covering the other reuses it verbatim (dedup), overlapping
116
- * or abutting runs extend in place, and gapped runs union into a sparse view
117
- * (coalesce). All reuse the original tag, so sequential reads of an unchanged
118
- * file collapse onto one anchor instead of fragmenting. A disagreeing shared
119
- * line means the file changed on disk, so a fresh slot (new tag) is minted.
120
- * Slot reuse on wrap is intentional: stale tags may
121
- * alias after 4096 distinct pushes, and the patcher catches misuse by verifying
122
- * the resolved snapshot's content (and path) against the live file before
123
- * applying edits.
39
+ * In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path history is a
40
+ * short ring of full-file versions (oldest dropped first); per-session path
41
+ * tracking is LRU-bounded so cold paths age out automatically.
42
+ *
43
+ * Recording byte-identical content again refreshes recency and reuses the
44
+ * existing tag (read fusion); recording new content unshifts a fresh version
45
+ * onto the front of the path history.
124
46
  */
125
47
  export declare class InMemorySnapshotStore extends SnapshotStore {
126
48
  #private;
49
+ constructor(options?: InMemorySnapshotStoreOptions);
127
50
  head(path: string): Snapshot | null;
128
- byHash(path: string, tag: string): Snapshot | null;
129
- recordContiguous(path: string, startLine: number, lines: readonly string[], metadata?: SnapshotMetadata): string;
130
- recordSparse(path: string, entries: Iterable<readonly [number, string]>, metadata?: SnapshotMetadata): string;
51
+ byHash(path: string, hash: string): Snapshot | null;
52
+ record(path: string, fullText: string): string;
131
53
  invalidate(path: string): void;
132
54
  clear(): void;
133
55
  }
@@ -1,43 +1,26 @@
1
- /**
2
- * Stateful, line-oriented classifier for hashline diff text.
3
- *
4
- * The {@link Tokenizer} can be fed in chunks ({@link Tokenizer.feed}/{@link
5
- * Tokenizer.end}) for streaming use, or in one shot ({@link
6
- * Tokenizer.tokenizeAll}). Each emitted token carries its 1-indexed source
7
- * line number so downstream consumers (parser, validators, error messages)
8
- * can refer back to the input precisely.
9
- *
10
- * Format shape:
11
- * ```
12
- * *** path/to/file.ts#0A3
13
- * @@ 5,7 @@
14
- * +literal new line
15
- * &3,4
16
- * ```
17
- * Each `***` line opens a new file section; each `@@ A,B @@` line opens a
18
- * new hunk whose body (zero or more `+`/`&` rows) replaces the selected
19
- * range. Empty body = delete the selected range.
20
- */
21
1
  import type { Anchor, Cursor, ParsedRange } from "./types";
22
- /**
23
- * Split a hashline diff into individual lines without losing the trailing
24
- * empty line that callers may rely on for explicit blank payloads. CRLF pairs
25
- * are normalized to a single line break.
26
- */
27
2
  export declare function splitHashlineLines(text: string): string[];
28
3
  export declare function cloneCursor(cursor: Cursor): Cursor;
29
4
  /** Parse a bare line-number anchor. Throws on malformed input. */
30
5
  export declare function parseLid(raw: string, lineNum: number): Anchor;
31
6
  export type BlockTarget = {
32
- kind: "range";
7
+ kind: "replace";
33
8
  range: ParsedRange;
9
+ } | {
10
+ kind: "delete";
11
+ range: ParsedRange;
12
+ } | {
13
+ kind: "insert_before";
14
+ anchor: Anchor;
15
+ } | {
16
+ kind: "insert_after";
17
+ anchor: Anchor;
34
18
  } | {
35
19
  kind: "bof";
36
20
  } | {
37
21
  kind: "eof";
38
22
  };
39
23
  interface TokenBase {
40
- /** 1-indexed line number in the original input stream. */
41
24
  lineNum: number;
42
25
  }
43
26
  export type Token = (TokenBase & {
@@ -58,42 +41,16 @@ export type Token = (TokenBase & {
58
41
  }) | (TokenBase & {
59
42
  kind: "payload-literal";
60
43
  text: string;
61
- }) | (TokenBase & {
62
- kind: "payload-repeat";
63
- range: ParsedRange;
64
44
  }) | (TokenBase & {
65
45
  kind: "raw";
66
46
  text: string;
67
47
  });
68
- /**
69
- * Stateful, line-oriented classifier for hashline diff text. Use the
70
- * streaming {@link feed}/{@link end} pair to ingest text in chunks (each
71
- * completed line emits exactly one token; a trailing partial line stays
72
- * buffered until the next chunk or {@link end}). Use the stateless
73
- * {@link tokenize}/predicate methods for callers that already hold whole
74
- * lines and only need classification without buffering.
75
- */
76
48
  export declare class Tokenizer {
77
49
  #private;
78
- /**
79
- * Ingest a chunk of input text. Each newline-terminated line in the
80
- * combined buffer produces one token. A trailing partial line (no `\n`
81
- * yet, possibly ending in a lone `\r`) stays buffered until the next
82
- * `feed`/`end` call so CRLF pairs that straddle chunk boundaries are
83
- * still normalized correctly.
84
- */
85
50
  feed(chunk: string): Token[];
86
- /**
87
- * Flush any buffered residual line (the last line of input when it lacks
88
- * a trailing newline) and mark the tokenizer closed. Calling `end` a
89
- * second time returns `[]`; reuse requires `reset`.
90
- */
91
51
  end(): Token[];
92
- /** Discard any buffered text and reset the line counter to 1. */
93
52
  reset(): void;
94
- /** Convenience: feed an entire text and immediately flush. */
95
53
  tokenizeAll(text: string): Token[];
96
- /** Stateless one-shot classification. Does not touch the streaming buffer. */
97
54
  tokenize(line: string, lineNum?: number): Token;
98
55
  isOp(line: string): boolean;
99
56
  isHeader(line: string): boolean;
@@ -7,7 +7,7 @@
7
7
  export interface Anchor {
8
8
  line: number;
9
9
  }
10
- /** Where an `insert` or `repeat` edit should land relative to existing content. */
10
+ /** Where an `insert` edit should land relative to existing content. */
11
11
  export type Cursor = {
12
12
  kind: "bof";
13
13
  } | {
@@ -15,12 +15,15 @@ export type Cursor = {
15
15
  } | {
16
16
  kind: "before_anchor";
17
17
  anchor: Anchor;
18
+ } | {
19
+ kind: "after_anchor";
20
+ anchor: Anchor;
18
21
  };
19
22
  /**
20
23
  * A single low-level edit produced by the parser and consumed by the applier.
21
- * Multi-line replacements decompose to one `insert`/`repeat` per replacement
22
- * line plus one `delete` per consumed line. Replacement payloads are tagged so
23
- * the applier can distinguish literal insertion from new content for a deleted
24
+ * Multi-line replacements decompose to one `insert` per replacement line plus
25
+ * one `delete` per consumed line. Replacement payloads are tagged so the
26
+ * applier can distinguish literal insertion from new content for a deleted
24
27
  * line.
25
28
  */
26
29
  export type Edit = {
@@ -30,13 +33,6 @@ export type Edit = {
30
33
  lineNum: number;
31
34
  index: number;
32
35
  mode?: "replacement";
33
- } | {
34
- kind: "repeat";
35
- cursor: Cursor;
36
- range: ParsedRange;
37
- lineNum: number;
38
- index: number;
39
- mode?: "replacement";
40
36
  } | {
41
37
  kind: "delete";
42
38
  anchor: Anchor;
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.12",
4
+ "version": "15.5.15",
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",
@@ -33,7 +33,8 @@
33
33
  "fmt": "biome format --write ."
34
34
  },
35
35
  "dependencies": {
36
- "diff": "^9.0.0"
36
+ "diff": "^9.0.0",
37
+ "lru-cache": "11.3.6"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/bun": "^1.3.14"