@oh-my-pi/hashline 15.5.4

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.
@@ -0,0 +1,38 @@
1
+ import type { SnapshotStore } from "./snapshots";
2
+ import type { ApplyOptions, Edit } from "./types";
3
+ export interface RecoveryArgs {
4
+ path: string;
5
+ currentText: string;
6
+ fileHash: string;
7
+ edits: readonly Edit[];
8
+ options?: ApplyOptions;
9
+ }
10
+ export interface RecoveryResult {
11
+ /** Post-recovery text. */
12
+ text: string;
13
+ /** First changed line (1-indexed) relative to the live `currentText`, or `undefined`. */
14
+ firstChangedLine: number | undefined;
15
+ /** Warnings collected during recovery, including the user-facing recovery banner. */
16
+ warnings: string[];
17
+ }
18
+ /**
19
+ * Stateless recovery driver over a {@link SnapshotStore}. Construct once and
20
+ * call {@link Recovery.tryRecover} per stale-hash incident. The default
21
+ * implementation tries three strategies in order:
22
+ *
23
+ * 1. Apply on the cached `fullText` snapshot, then 3-way-merge onto current.
24
+ * 2. (Session chain) If the snapshot wasn't the head, retry on current text
25
+ * when line counts match — the user's previous edit advanced the hash but
26
+ * didn't shift line numbers.
27
+ * 3. Reconstruct from a sparse snapshot (lines map only), verify the rebuilt
28
+ * text hashes to the expected value, then 3-way-merge.
29
+ */
30
+ export declare class Recovery {
31
+ readonly store: SnapshotStore;
32
+ constructor(store: SnapshotStore);
33
+ /**
34
+ * Attempt recovery. Returns `null` when no path forward is found — the
35
+ * caller should then surface a {@link MismatchError}.
36
+ */
37
+ tryRecover(args: RecoveryArgs): RecoveryResult | null;
38
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * One snapshot of a file as it was observed at a point in time. Either the
3
+ * full text is recorded (`fullText` set) for full-file reads, or a sparse
4
+ * map of `(lineNumber, content)` pairs for partial views (search matches,
5
+ * range reads).
6
+ */
7
+ export interface Snapshot {
8
+ /** 1-indexed line number → exact content as observed. */
9
+ readonly lines: Map<number, string>;
10
+ /** Full normalized text when the read observed the whole file. */
11
+ fullText?: string;
12
+ /** 4-hex hash carried alongside the read, when known. */
13
+ fileHash?: string;
14
+ /** Timestamp (ms since epoch) the snapshot was recorded. */
15
+ recordedAt: number;
16
+ }
17
+ /** Optional metadata supplied at snapshot record time. */
18
+ export interface SnapshotMetadata {
19
+ /** Full normalized text, when the producer observed the whole file. */
20
+ fullText?: string;
21
+ /** 4-hex hash carried by the read, when known. */
22
+ fileHash?: string;
23
+ }
24
+ /**
25
+ * Storage seam for file-content snapshots. The patcher calls {@link head}
26
+ * for the latest snapshot of a path and {@link byHash} when it needs the
27
+ * specific historical snapshot that matches a section's stale hash.
28
+ */
29
+ export declare abstract class SnapshotStore {
30
+ /** Most-recent snapshot for `path`, or `null` if none. */
31
+ abstract head(path: string): Snapshot | null;
32
+ /** Most-recent snapshot for `path` whose `fileHash` equals `fileHash`. */
33
+ abstract byHash(path: string, fileHash: string): Snapshot | null;
34
+ /** Record a contiguous run of lines (e.g. from a `read` tool). `startLine` is 1-indexed. */
35
+ abstract recordContiguous(path: string, startLine: number, lines: readonly string[], metadata?: SnapshotMetadata): void;
36
+ /** Record sparse `(lineNumber, content)` pairs (e.g. a `search` match plus context). */
37
+ abstract recordSparse(path: string, entries: Iterable<readonly [number, string]>, metadata?: SnapshotMetadata): void;
38
+ /** Drop the snapshot history for a single path. */
39
+ abstract invalidate(path: string): void;
40
+ /** Drop every snapshot history. */
41
+ abstract clear(): void;
42
+ }
43
+ export interface InMemorySnapshotStoreOptions {
44
+ /** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
45
+ maxPaths?: number;
46
+ /** Maximum snapshots retained per path (default 4). Oldest dropped first. */
47
+ maxSnapshotsPerPath?: number;
48
+ }
49
+ /**
50
+ * In-memory {@link SnapshotStore} backed by `lru-cache`. Per-path snapshot
51
+ * history is a short ring (oldest dropped first); per-session path tracking
52
+ * is LRU-bounded so cold paths age out automatically.
53
+ *
54
+ * Newer snapshots merge into the head when their entries don't conflict and
55
+ * the recorded `fileHash` (if any) still agrees; otherwise a fresh snapshot
56
+ * is pushed onto the front of the history list.
57
+ */
58
+ export declare class InMemorySnapshotStore extends SnapshotStore {
59
+ #private;
60
+ constructor(options?: InMemorySnapshotStoreOptions);
61
+ head(path: string): Snapshot | null;
62
+ byHash(path: string, fileHash: string): Snapshot | null;
63
+ recordContiguous(path: string, startLine: number, lines: readonly string[], metadata?: SnapshotMetadata): void;
64
+ recordSparse(path: string, entries: Iterable<readonly [number, string]>, metadata?: SnapshotMetadata): void;
65
+ invalidate(path: string): void;
66
+ clear(): void;
67
+ }
@@ -0,0 +1,2 @@
1
+ import type { StreamOptions } from "./types";
2
+ export declare function streamHashLines(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>, options?: StreamOptions): AsyncGenerator<string>;
@@ -0,0 +1,104 @@
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
+ * The tokenizer is intentionally permissive about decorations and prefixes
11
+ * the model may echo back from `read`/`search` output — leading `*`/`>`/`-`
12
+ * markers, CR-terminated lines, leading whitespace before line numbers, and
13
+ * so on are all stripped before classification.
14
+ */
15
+ import type { Anchor, Cursor, ParsedRange } from "./types";
16
+ /**
17
+ * Split a hashline diff into individual lines without losing the trailing
18
+ * empty line that callers may rely on for explicit blank payloads. CRLF pairs
19
+ * are normalized to a single line break.
20
+ *
21
+ * This mirrors the line-splitting performed by {@link Tokenizer}'s streaming
22
+ * drain loop and is kept for non-streaming callers that prefer a single-shot
23
+ * split.
24
+ */
25
+ export declare function splitHashlineLines(text: string): string[];
26
+ export declare function cloneCursor(cursor: Cursor): Cursor;
27
+ /** Parse a bare line-number anchor (used by insert ops). Throws on malformed input. */
28
+ export declare function parseLid(raw: string, lineNum: number): Anchor;
29
+ /**
30
+ * Returns true when the line scans as `LINE!payload` (delete sigil followed
31
+ * by additional content). The parser uses this for the dedicated "deletes
32
+ * only" diagnostic, separate from the standard "unrecognized op" path.
33
+ */
34
+ export declare function isDeleteOpWithPayload(line: string): boolean;
35
+ interface TokenBase {
36
+ /** 1-indexed line number in the original input stream. */
37
+ lineNum: number;
38
+ }
39
+ export type Token = (TokenBase & {
40
+ kind: "blank";
41
+ }) | (TokenBase & {
42
+ kind: "envelope-begin";
43
+ }) | (TokenBase & {
44
+ kind: "envelope-end";
45
+ }) | (TokenBase & {
46
+ kind: "abort";
47
+ }) | (TokenBase & {
48
+ kind: "header";
49
+ path: string;
50
+ fileHash?: string;
51
+ }) | (TokenBase & {
52
+ kind: "op-insert";
53
+ cursor: Cursor;
54
+ inlineBody: string | undefined;
55
+ }) | (TokenBase & {
56
+ kind: "op-replace";
57
+ range: ParsedRange;
58
+ inlineBody: string | undefined;
59
+ }) | (TokenBase & {
60
+ kind: "op-delete";
61
+ range: ParsedRange;
62
+ trailingPayload: boolean;
63
+ }) | (TokenBase & {
64
+ kind: "payload";
65
+ text: string;
66
+ }) | (TokenBase & {
67
+ kind: "raw";
68
+ text: string;
69
+ });
70
+ /**
71
+ * Stateful, line-oriented classifier for hashline diff text. Use the
72
+ * streaming {@link feed}/{@link end} pair to ingest text in chunks (each
73
+ * completed line emits exactly one token; a trailing partial line stays
74
+ * buffered until the next chunk or {@link end}). Use the stateless
75
+ * {@link tokenize}/predicate methods for callers that already hold whole
76
+ * lines and only need classification without buffering.
77
+ */
78
+ export declare class Tokenizer {
79
+ #private;
80
+ /**
81
+ * Ingest a chunk of input text. Each newline-terminated line in the
82
+ * combined buffer produces one token. A trailing partial line (no `\n`
83
+ * yet, possibly ending in a lone `\r`) stays buffered until the next
84
+ * `feed`/`end` call so CRLF pairs that straddle chunk boundaries are
85
+ * still normalized correctly.
86
+ */
87
+ feed(chunk: string): Token[];
88
+ /**
89
+ * Flush any buffered residual line (the last line of input when it lacks
90
+ * a trailing newline) and mark the tokenizer closed. Calling `end` a
91
+ * second time returns `[]`; reuse requires `reset`.
92
+ */
93
+ end(): Token[];
94
+ /** Discard any buffered text and reset the line counter to 1. */
95
+ reset(): void;
96
+ /** Convenience: feed an entire text and immediately flush. */
97
+ tokenizeAll(text: string): Token[];
98
+ /** Stateless one-shot classification. Does not touch the streaming buffer. */
99
+ tokenize(line: string, lineNum?: number): Token;
100
+ isOp(line: string): boolean;
101
+ isHeader(line: string): boolean;
102
+ isEnvelopeMarker(line: string): boolean;
103
+ }
104
+ export type { ParsedRange } from "./types";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pure data types shared across the hashline parser, applier, and patcher.
3
+ * Nothing in this file references a filesystem, agent runtime, or schema
4
+ * library — keep it that way.
5
+ */
6
+ /** A line-number anchor (1-indexed). */
7
+ export interface Anchor {
8
+ line: number;
9
+ }
10
+ /** Where an `insert` edit should land relative to existing content. */
11
+ export type Cursor = {
12
+ kind: "bof";
13
+ } | {
14
+ kind: "eof";
15
+ } | {
16
+ kind: "before_anchor";
17
+ anchor: Anchor;
18
+ } | {
19
+ kind: "after_anchor";
20
+ anchor: Anchor;
21
+ };
22
+ /**
23
+ * A single low-level edit produced by the parser and consumed by the applier.
24
+ * Multi-line replacements decompose to one `insert` per replacement line plus
25
+ * one `delete` per consumed line.
26
+ */
27
+ export type Edit = {
28
+ kind: "insert";
29
+ cursor: Cursor;
30
+ text: string;
31
+ lineNum: number;
32
+ index: number;
33
+ } | {
34
+ kind: "delete";
35
+ anchor: Anchor;
36
+ lineNum: number;
37
+ index: number;
38
+ oldAssertion?: string;
39
+ };
40
+ /** Result of applying a parsed set of edits to a text body. */
41
+ export interface ApplyResult {
42
+ /** Post-edit text body. */
43
+ text: string;
44
+ /** First line number (1-indexed) that changed, or `undefined` for a no-op apply. */
45
+ firstChangedLine?: number;
46
+ /** Diagnostic warnings collected by the applier (auto-absorb, boundary checks, …). */
47
+ warnings?: string[];
48
+ }
49
+ /** Optional knobs forwarded to {@link Edit} application. */
50
+ export interface ApplyOptions {
51
+ /**
52
+ * When `true`, pure-insert and single-line replacement-boundary duplicates
53
+ * are dropped opportunistically. Default `false`: only multi-line block
54
+ * duplicates and structural-boundary single lines are absorbed.
55
+ */
56
+ autoDropPureInsertDuplicates?: boolean;
57
+ }
58
+ /** A parsed `[A..B]` line range. */
59
+ export interface ParsedRange {
60
+ start: Anchor;
61
+ end: Anchor;
62
+ }
63
+ /** Optional hints for {@link splitPatchInput}. */
64
+ export interface SplitOptions {
65
+ /** Resolves absolute paths inside hashline headers to cwd-relative form. */
66
+ cwd?: string;
67
+ /**
68
+ * Fallback path used when the input lacks a `¶PATH` header but contains
69
+ * recognizable hashline operations. Lets streaming previews work before
70
+ * the model has written the header.
71
+ */
72
+ path?: string;
73
+ }
74
+ /** Streaming-formatter knobs for {@link streamHashLines}. */
75
+ export interface StreamOptions {
76
+ /** First line number to use when formatting (1-indexed, default 1). */
77
+ startLine?: number;
78
+ /** Maximum formatted lines per yielded chunk (default 200). */
79
+ maxChunkLines?: number;
80
+ /** Maximum UTF-8 bytes per yielded chunk (default 64 KiB). */
81
+ maxChunkBytes?: number;
82
+ }
83
+ /** Result of {@link buildCompactDiffPreview}. */
84
+ export interface CompactDiffPreview {
85
+ preview: string;
86
+ addedLines: number;
87
+ removedLines: number;
88
+ }
89
+ /** Optional knobs for {@link buildCompactDiffPreview}. Reserved for future use. */
90
+ export interface CompactDiffOptions {
91
+ /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
92
+ maxUnchangedRun?: number;
93
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@oh-my-pi/hashline",
4
+ "version": "15.5.4",
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
+ "homepage": "https://omp.sh",
7
+ "author": "Can Boluk",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
12
+ "directory": "packages/hashline"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/can1357/oh-my-pi/issues"
16
+ },
17
+ "keywords": [
18
+ "patch",
19
+ "diff",
20
+ "edit",
21
+ "hashline",
22
+ "agent",
23
+ "llm"
24
+ ],
25
+ "main": "./src/index.ts",
26
+ "types": "./dist/types/index.d.ts",
27
+ "scripts": {
28
+ "check": "biome check . && bun run check:types",
29
+ "check:types": "tsgo -p tsconfig.json --noEmit",
30
+ "lint": "biome lint .",
31
+ "fix": "biome check --write --unsafe .",
32
+ "fmt": "biome format --write ."
33
+ },
34
+ "dependencies": {
35
+ "diff": "^9.0.0",
36
+ "lru-cache": "11.3.6"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "^1.3.14"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.3.14"
43
+ },
44
+ "files": [
45
+ "src",
46
+ "dist/types"
47
+ ],
48
+ "exports": {
49
+ ".": {
50
+ "types": "./dist/types/index.d.ts",
51
+ "import": "./src/index.ts"
52
+ },
53
+ "./grammar.lark": "./src/grammar.lark",
54
+ "./prompt.md": "./src/prompt.md",
55
+ "./*": {
56
+ "types": "./dist/types/*.d.ts",
57
+ "import": "./src/*.ts"
58
+ },
59
+ "./*.js": "./src/*.ts"
60
+ }
61
+ }