@oh-my-pi/hashline 15.13.0 → 15.13.1
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 +99 -34
- package/dist/types/messages.d.ts +17 -0
- package/dist/types/snapshots.d.ts +24 -3
- package/package.json +1 -1
- package/src/apply.ts +54 -0
- package/src/block.ts +10 -0
- package/src/messages.ts +61 -0
- package/src/patcher.ts +21 -1
- package/src/prompt.md +4 -4
- package/src/snapshots.ts +40 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,79 +2,144 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.13.1] - 2026-06-15
|
|
6
|
+
|
|
5
7
|
### Breaking Changes
|
|
6
8
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
+
- Rejected edits anchored to lines not displayed in the tagged read/search output, requiring unseen ranges to be re-read before reapplying
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Rejected `replace block`, `delete block`, and `insert after block` operations that resolve to a single line and instructed users to use the plain single-line form or anchor the true construct opener
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Auto-repaired one-sided multi-line boundary echoes by dropping delimiter-neutral duplicated boundary lines and emitted a boundary-echo warning
|
|
18
|
+
- Normalized cwd-relative hashline paths to forward-slash form on Windows.
|
|
19
|
+
- Parser now treats a leading `\` on inline payload bodies as the payload delimiter, matching standalone payload rows.
|
|
20
|
+
- Restored the warning emitted when escaped indented payload rows (`\\ TEXT`) are accepted as payload delimiters.
|
|
21
|
+
|
|
22
|
+
## [15.12.5] - 2026-06-13
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Fixed delimiter-balance boundary repair so it does not keep a deleted structural closer when the replacement payload already restates that closer.
|
|
27
|
+
|
|
28
|
+
## [15.12.0] - 2026-06-12
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Condensed all parser/applier/patcher error and warning messages: shorter wording, same diagnostic anchors (op names, line numbers, suggested fallback forms)
|
|
33
|
+
|
|
34
|
+
## [15.11.4] - 2026-06-12
|
|
9
35
|
|
|
10
36
|
### Added
|
|
11
37
|
|
|
12
38
|
- Added inward landing correction for `insert after block N:`: a body indented deeper than the block's closing line now slides back across the block's trailing closer lines and lands inside the block at its claimed depth, with a warning naming the landing line. Same conservative guards as the outward shift — comparable indentation only, closers only, abandoned when another hunk targets a crossed line; plain `insert after M:` stays literal
|
|
13
39
|
- Added closer-anchor lowering for `insert after block N:`: anchoring on a pure closing-delimiter line (where no block begins, so resolution previously failed the whole patch) now applies as plain `insert after N:` with a warning teaching the opener-only rule. `resolveBlockEdits` gained an `onWarning` callback; apply, preview, and patcher paths surface it on `warnings`
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Condensed the edit-tool prompt: one-line op definitions, 5–20-word rules, and a tighter `<critical>` recap; landing-correction mechanics are no longer described to the agent
|
|
44
|
+
|
|
45
|
+
## [15.11.1] - 2026-06-11
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Fixed the `insert after block N:` prompt guidance so it explicitly says N must be the block opener, not the closing delimiter or last visible line, and points visible closing-line edits to plain `insert after M:`. ([#2292](https://github.com/can1357/oh-my-pi/issues/2292))
|
|
50
|
+
|
|
51
|
+
## [15.11.0] - 2026-06-10
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- Block-unresolved errors (`replace block N:` / `delete block N` / `insert after block N:` failing to resolve a syntactic block) now append a numbered preview of the file around the anchor line — same `*`-marked context rows the hash-mismatch error shows — so the offending line is visible without a re-read
|
|
56
|
+
|
|
57
|
+
## [15.10.11] - 2026-06-10
|
|
58
|
+
|
|
59
|
+
### Breaking Changes
|
|
60
|
+
|
|
61
|
+
- Changed `BlockResolution.isDelete` to `BlockResolution.op` (`"replace" | "delete" | "insert_after"`) so resolutions can describe every block-anchored op
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
14
65
|
- Added `insert after block N:` patch syntax to insert body rows after the last line of the tree-sitter-resolved block beginning on line N, so a statement can be placed after a construct without counting to its closing line
|
|
15
66
|
- Added depth-guided landing correction for `insert after N:` hunks: a body indented shallower than its anchor line slides past the structural closer lines below the anchor until depth returns to the body's level, with a warning naming the final landing line. The shift never crosses content lines, skips incomparable indentation styles and pure-closer bodies, and is abandoned when another hunk targets a crossed line
|
|
16
67
|
- Added a global byte ceiling to `InMemorySnapshotStore` (`maxTotalBytes`, default 64 MiB): the cap was previously per-file only, so a session reading many large files retained up to 30 paths × 4 full-text versions indefinitely
|
|
17
|
-
- Added `maxAddedRunContext` option to control how many added lines are shown at each side of collapsed inserted runs, with `maxUnchangedRun` kept as a backward-compatible alias
|
|
18
|
-
- Added a `BlockResolution` type and surfaced resolved block spans on `ApplyResult.blockResolutions` / `PatchSectionResult.blockResolutions`. `resolveBlockEdits` now accepts an `onResolved` callback that reports each `replace block N:` / `delete block N` anchor's resolved `[start, end]` span (and whether it was a delete). Spans are surfaced only on the no-drift apply paths, where the resolved line numbers line up with the tag the caller read.
|
|
19
|
-
- Added `replace block N:` and `delete block N` patch syntax to replace or delete the entire syntactic block that begins on line N using tree-sitter-resolved spans
|
|
20
|
-
- Added `BlockResolver` support in `Patcher` and `PatchSection.applyTo`/`applyPartialTo` to wire language-specific block-resolution at apply time
|
|
21
|
-
- Added `resolveBlockEdits` and block edit type definitions to the package API for resolving deferred `replace block` / `delete block` edits
|
|
22
68
|
|
|
23
69
|
### Changed
|
|
24
70
|
|
|
25
|
-
- Condensed all parser/applier/patcher error and warning messages: shorter wording, same diagnostic anchors (op names, line numbers, suggested fallback forms)
|
|
26
|
-
- Condensed the edit-tool prompt: one-line op definitions, 5–20-word rules, and a tighter `<critical>` recap; landing-correction mechanics are no longer described to the agent
|
|
27
|
-
- Block-unresolved errors (`replace block N:` / `delete block N` / `insert after block N:` failing to resolve a syntactic block) now append a numbered preview of the file around the anchor line — same `*`-marked context rows the hash-mismatch error shows — so the offending line is visible without a re-read
|
|
28
71
|
- Trimmed the `replace block N:` ops entry in the patch prompt to grammar and pointing rules; the usage doctrine it duplicated stays in the rules section
|
|
29
72
|
- Changed `buildCompactDiffPreview` to treat blank rows as gap separators alongside `…` markers: separators never stack (removed lines omitted from the preview no longer leave two adjacent), and leading/trailing separators are trimmed
|
|
30
|
-
- Changed `buildCompactDiffPreview` to omit removed lines from the preview while preserving removal counts for offset tracking
|
|
31
|
-
- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a bare `…` marker, keeping only the first and last `maxAddedRunContext` lines visible (the surrounding line numbers convey how many were elided)
|
|
32
|
-
- Reworked the `edit` tool prompt (`prompt.md`): added a `replace block N` vs `replace N..M` decision rule, documented that a leading decorator/attribute/doc-comment is a separate node not swept into the block (point N at the first decorator line, or use `replace N..M` for a Rust-style `///` sibling comment), reframed the blast-radius guidance so "block replace" no longer reads as the dangerous option, and added a decorated-definition example.
|
|
33
73
|
|
|
34
74
|
### Fixed
|
|
35
75
|
|
|
36
|
-
- Normalized cwd-relative hashline paths to forward-slash form on Windows.
|
|
37
|
-
- Fixed delimiter-balance boundary repair so it does not keep a deleted structural closer when the replacement payload already restates that closer.
|
|
38
|
-
- Fixed the `insert after block N:` prompt guidance so it explicitly says N must be the block opener, not the closing delimiter or last visible line, and points visible closing-line edits to plain `insert after M:`. ([#2292](https://github.com/can1357/oh-my-pi/issues/2292))
|
|
39
76
|
- Fixed the boundary-echo repair stripping payload edges without the balance-neutrality guard its own documentation promised: in brace-heavy code where bare `}` lines repeat, a payload intentionally beginning/ending with lines identical to the range's neighbors had both edges silently dropped, writing content that differed from what was authored
|
|
40
77
|
- Fixed lenient bare-body handling silently mutating payloads: interior blank rows in an un-prefixed body were dropped outright, and a body of numeric-keyed literals (`1: "one"` dict/YAML shapes) satisfied the uniform line-prefix check and had its keys stripped from every line — blank rows are now preserved when proven interior, and the uniform strip refuses lone-literal remainders
|
|
41
78
|
- Fixed the multi-section "all-or-nothing" claim being false for write failures: commits run serially, so a mid-batch write error left earlier sections on disk while the thrown error said nothing — the error now lists exactly which sections were written and which were not
|
|
42
79
|
- Fixed `delete`/`replace` ranges ending on the phantom trailing line of a newline-terminated file silently stripping the file's final newline; such anchors are now rejected with guidance toward `N-1` / `insert tail:` (inserts there remain valid, and genuine empty last lines of unterminated files stay deletable)
|
|
43
|
-
- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a bare `…` elision marker.
|
|
44
|
-
- Fixed compact edit previews to render added/current lines without diff-prefix padding and normalize adjacent ASCII/Unicode elision markers to one `…`.
|
|
45
|
-
- Stripped read-output line-number prefixes (`N:`) from auto-piped bare body rows so that pasting `3:text` without a `+` prefix no longer injects `3:` as literal content. Stripping is applied only when *every* bare row in the hunk carries the prefix (the signature of a pasted snapshot) and removes at most one prefix per row, so a genuine body that merely starts with `digits:` (YAML port maps, timestamps) is left intact ([#1492](https://github.com/can1357/oh-my-pi/issues/1492)).
|
|
46
|
-
- Fixed missing-header diagnostics and copied-content prefix stripping to consistently teach and recognize 4-hex snapshot tags.
|
|
47
|
-
- Fixed delimiter-balance boundary repair to also drop a single duplicated structural opener (e.g. a restated `foo(` / `if (x) {` signature line surviving just above the range), not only duplicated closers. Zero-balance duplicates remain untouched.
|
|
48
|
-
- Fixed hashline replacements that accidentally restated unchanged lines above and below the selected range so they no longer duplicate both boundary lines ([#1664](https://github.com/can1357/oh-my-pi/issues/1664)).
|
|
49
|
-
|
|
50
|
-
## [15.13.0] - 2026-06-14
|
|
51
80
|
|
|
52
|
-
## [15.
|
|
81
|
+
## [15.10.5] - 2026-06-08
|
|
53
82
|
|
|
54
|
-
|
|
83
|
+
### Added
|
|
55
84
|
|
|
56
|
-
|
|
85
|
+
- Added `maxAddedRunContext` option to control how many added lines are shown at each side of collapsed inserted runs, with `maxUnchangedRun` kept as a backward-compatible alias
|
|
57
86
|
|
|
58
|
-
|
|
87
|
+
### Changed
|
|
59
88
|
|
|
60
|
-
|
|
89
|
+
- Changed `buildCompactDiffPreview` to omit removed lines from the preview while preserving removal counts for offset tracking
|
|
90
|
+
- Changed `buildCompactDiffPreview` to collapse long contiguous added runs with a bare `…` marker, keeping only the first and last `maxAddedRunContext` lines visible (the surrounding line numbers convey how many were elided)
|
|
61
91
|
|
|
62
|
-
|
|
92
|
+
### Fixed
|
|
63
93
|
|
|
64
|
-
|
|
94
|
+
- Fixed compact edit previews to omit deleted content, keep visible lines anchored to the current file, and collapse long inserted runs with a bare `…` elision marker.
|
|
95
|
+
- Fixed compact edit previews to render added/current lines without diff-prefix padding and normalize adjacent ASCII/Unicode elision markers to one `…`.
|
|
65
96
|
|
|
66
97
|
## [15.10.3] - 2026-06-08
|
|
67
98
|
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- Added a `BlockResolution` type and surfaced resolved block spans on `ApplyResult.blockResolutions` / `PatchSectionResult.blockResolutions`. `resolveBlockEdits` now accepts an `onResolved` callback that reports each `replace block N:` / `delete block N` anchor's resolved `[start, end]` span (and whether it was a delete). Spans are surfaced only on the no-drift apply paths, where the resolved line numbers line up with the tag the caller read.
|
|
102
|
+
|
|
103
|
+
### Changed
|
|
104
|
+
|
|
105
|
+
- Reworked the `edit` tool prompt (`prompt.md`): added a `replace block N` vs `replace N..M` decision rule, documented that a leading decorator/attribute/doc-comment is a separate node not swept into the block (point N at the first decorator line, or use `replace N..M` for a Rust-style `///` sibling comment), reframed the blast-radius guidance so "block replace" no longer reads as the dangerous option, and added a decorated-definition example.
|
|
106
|
+
|
|
68
107
|
## [15.10.2] - 2026-06-08
|
|
69
108
|
|
|
109
|
+
### Fixed
|
|
110
|
+
|
|
111
|
+
- Stripped read-output line-number prefixes (`N:`) from auto-piped bare body rows so that pasting `3:text` without a `+` prefix no longer injects `3:` as literal content. Stripping is applied only when *every* bare row in the hunk carries the prefix (the signature of a pasted snapshot) and removes at most one prefix per row, so a genuine body that merely starts with `digits:` (YAML port maps, timestamps) is left intact ([#1492](https://github.com/can1357/oh-my-pi/issues/1492)).
|
|
112
|
+
|
|
70
113
|
## [15.9.67] - 2026-06-06
|
|
71
114
|
|
|
115
|
+
### Breaking Changes
|
|
116
|
+
|
|
117
|
+
- Changed hashline file section headers from `¶PATH#TAG` to `[PATH#TAG]` so model-authored edits use ASCII delimiters instead of a pilcrow sigil.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
|
|
121
|
+
- Fixed missing-header diagnostics and copied-content prefix stripping to consistently teach and recognize 4-hex snapshot tags.
|
|
122
|
+
|
|
72
123
|
## [15.8.2] - 2026-06-03
|
|
73
124
|
|
|
125
|
+
### Fixed
|
|
126
|
+
|
|
127
|
+
- Fixed delimiter-balance boundary repair to also drop a single duplicated structural opener (e.g. a restated `foo(` / `if (x) {` signature line surviving just above the range), not only duplicated closers. Zero-balance duplicates remain untouched.
|
|
128
|
+
|
|
74
129
|
## [15.8.0] - 2026-06-02
|
|
75
130
|
|
|
131
|
+
### Fixed
|
|
132
|
+
|
|
133
|
+
- Fixed hashline replacements that accidentally restated unchanged lines above and below the selected range so they no longer duplicate both boundary lines ([#1664](https://github.com/can1357/oh-my-pi/issues/1664)).
|
|
134
|
+
|
|
76
135
|
## [15.7.0] - 2026-05-31
|
|
77
136
|
|
|
137
|
+
### Added
|
|
138
|
+
|
|
139
|
+
- Added `replace block N:` and `delete block N` patch syntax to replace or delete the entire syntactic block that begins on line N using tree-sitter-resolved spans
|
|
140
|
+
- Added `BlockResolver` support in `Patcher` and `PatchSection.applyTo`/`applyPartialTo` to wire language-specific block-resolution at apply time
|
|
141
|
+
- Added `resolveBlockEdits` and block edit type definitions to the package API for resolving deferred `replace block` / `delete block` edits
|
|
142
|
+
|
|
78
143
|
## [15.5.13] - 2026-05-29
|
|
79
144
|
|
|
80
145
|
### Breaking Changes
|
|
@@ -85,7 +150,7 @@
|
|
|
85
150
|
### Added
|
|
86
151
|
|
|
87
152
|
- Added `maxPaths` and `maxVersionsPerPath` options to `InMemorySnapshotStore` to bound tracked paths and per-path snapshot history
|
|
88
|
-
- Re-introduced balance-validated boundary repair in `applyEdits`. A replacement hunk (`replace N..M:` + body) is normalized so its payload preserves the deleted region's delimiter balance: when the body restates a closing delimiter that survives just outside the range (duplicate `}` / `);` / `]`) the echo is dropped, and when the range deletes a structural closer the body never restates (missing closer) the closer is spared instead of deleted. A repair fires only when one boundary operation drives the per-channel `()` / `[]` / `{}` imbalance to exactly zero while leaving surrounding text byte-identical (single-line ops are limited to pure structural-closer lines), so balance-preserving edits and intentional balanced duplicates are never touched. Bracket
|
|
153
|
+
- Re-introduced balance-validated boundary repair in `applyEdits`. A replacement hunk (`replace N..M:` + body) is normalized so its payload preserves the deleted region's delimiter balance: when the body restates a closing delimiter that survives just outside the range (duplicate `}` / `);` / `]`) the echo is dropped, and when the range deletes a structural closer the body never restates (missing closer) the closer is spared instead of deleted. A repair fires only when one boundary operation drives the per-channel `()` / `[]` / `{}` imbalance to exactly zero while leaving surrounding text byte-identical (single-line ops are limited to pure structural-closer lines), so balance-preserving edits and intentional balanced duplicates are never touched. Bracket counting skips strings, template literals, and comments. Each repair surfaces a `delimiter-balance` warning through `ApplyResult.warnings`.
|
|
89
154
|
|
|
90
155
|
### Changed
|
|
91
156
|
|
|
@@ -153,7 +218,7 @@
|
|
|
153
218
|
|
|
154
219
|
### Breaking Changes
|
|
155
220
|
|
|
156
|
-
-
|
|
221
|
+
- Changed hashline payload continuations from `+TEXT` to `\TEXT`; use `\` for an explicit blank payload line.
|
|
157
222
|
|
|
158
223
|
### Added
|
|
159
224
|
|
|
@@ -166,7 +231,7 @@
|
|
|
166
231
|
|
|
167
232
|
### Removed
|
|
168
233
|
|
|
169
|
-
- Removed
|
|
234
|
+
- Removed the `A-B!` / `A!` deletion operator. Use `A-B:` with the desired payload (or empty payload to blank the range) instead.
|
|
170
235
|
|
|
171
236
|
All notable changes to this package will be documented in this file.
|
|
172
237
|
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -96,3 +96,20 @@ export declare const HEADTAIL_DRIFT_WARNING = "Applied the `insert head:`/`inser
|
|
|
96
96
|
* ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
|
|
97
97
|
*/
|
|
98
98
|
export declare function missingSnapshotTagMessage(sectionPath: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* An anchored edit referenced lines the read that minted the cited tag never
|
|
101
|
+
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
102
|
+
* Editing lines you have not read is the off-by-memory failure that mangles
|
|
103
|
+
* files; reject and make the model re-read those exact lines first.
|
|
104
|
+
*/
|
|
105
|
+
export declare function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string;
|
|
106
|
+
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
|
107
|
+
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
108
|
+
/**
|
|
109
|
+
* A `replace block`/`delete block`/`insert after block` anchor resolved to a
|
|
110
|
+
* single line — almost always a bare statement the model mis-anchored, not a
|
|
111
|
+
* multi-line construct. The plain op is unambiguous for one line; the block
|
|
112
|
+
* form only earns its keep when it spares counting a closing line you cannot
|
|
113
|
+
* see. Reject and point at both fixes.
|
|
114
|
+
*/
|
|
115
|
+
export declare function blockSingleLineMessage(line: number, op: BlockOp): string;
|
|
@@ -11,6 +11,15 @@ export interface Snapshot {
|
|
|
11
11
|
readonly hash: string;
|
|
12
12
|
/** Timestamp (ms since epoch) the version was recorded. */
|
|
13
13
|
recordedAt: number;
|
|
14
|
+
/**
|
|
15
|
+
* 1-indexed file lines a producer (read/search) actually *displayed* under
|
|
16
|
+
* this tag. A partial read (range, or a structural summary that collapsed
|
|
17
|
+
* bodies) leaves this sparse; a whole-file read fills every line. Multiple
|
|
18
|
+
* reads of the same content union into one set. `undefined` means "no
|
|
19
|
+
* provenance recorded" — the patcher then skips the seen-line check and
|
|
20
|
+
* applies as before. Mutated in place as more of the same content is read.
|
|
21
|
+
*/
|
|
22
|
+
seenLines?: Set<number>;
|
|
14
23
|
}
|
|
15
24
|
/**
|
|
16
25
|
* Storage seam for full-file version snapshots. The patcher calls {@link head}
|
|
@@ -22,8 +31,19 @@ export declare abstract class SnapshotStore {
|
|
|
22
31
|
abstract head(path: string): Snapshot | null;
|
|
23
32
|
/** Recorded version for `path` whose tag equals `hash`, or `null`. */
|
|
24
33
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
25
|
-
/**
|
|
26
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Record the full normalized text of `path` and return its content tag.
|
|
36
|
+
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
|
37
|
+
* they merge into {@link Snapshot.seenLines} across reads of identical text.
|
|
38
|
+
*/
|
|
39
|
+
abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
|
40
|
+
/**
|
|
41
|
+
* Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
|
|
42
|
+
* equals `hash`. No-op when no such version is retained (the content aged
|
|
43
|
+
* out or was overwritten). Lets producers attach displayed lines after the
|
|
44
|
+
* tag was already minted (the body is formatted after the hash is computed).
|
|
45
|
+
*/
|
|
46
|
+
abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
|
27
47
|
/** Drop the version history for a single path. */
|
|
28
48
|
abstract invalidate(path: string): void;
|
|
29
49
|
/** Drop every version history. */
|
|
@@ -55,7 +75,8 @@ export declare class InMemorySnapshotStore extends SnapshotStore {
|
|
|
55
75
|
constructor(options?: InMemorySnapshotStoreOptions);
|
|
56
76
|
head(path: string): Snapshot | null;
|
|
57
77
|
byHash(path: string, hash: string): Snapshot | null;
|
|
58
|
-
record(path: string, fullText: string): string;
|
|
78
|
+
record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
|
79
|
+
recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
|
59
80
|
invalidate(path: string): void;
|
|
60
81
|
clear(): void;
|
|
61
82
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.13.
|
|
4
|
+
"version": "15.13.1",
|
|
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
|
@@ -443,6 +443,50 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
|
|
443
443
|
);
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* A single-sided boundary echo in an otherwise delimiter-balanced *multi-line*
|
|
448
|
+
* replacement: the payload's leading XOR trailing edge exactly restates the
|
|
449
|
+
* surviving line(s) just outside the range — the off-by-one "range one line
|
|
450
|
+
* short of the keeper I retyped" mistake (e.g. att: payload ends with
|
|
451
|
+
* `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes
|
|
452
|
+
* are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes
|
|
453
|
+
* by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}.
|
|
454
|
+
*
|
|
455
|
+
* Scoped to multi-line ranges (a construct rewrite) on purpose: a single-line
|
|
456
|
+
* `replace N..N` expanding into several lines is an *expansion* where every
|
|
457
|
+
* payload line is intentional new content, so a payload line that happens to
|
|
458
|
+
* equal a neighbor stays — only a genuine block rewrite retypes a boundary
|
|
459
|
+
* keeper by mistake. The dropped lines must be delimiter-neutral so removing the
|
|
460
|
+
* duplicate keeps the already-balanced result balanced, and must not consume the
|
|
461
|
+
* whole payload.
|
|
462
|
+
*/
|
|
463
|
+
function findOneSidedBoundaryEcho(
|
|
464
|
+
group: ReplacementGroup,
|
|
465
|
+
fileLines: readonly string[],
|
|
466
|
+
): { side: "leading" | "trailing"; count: number } | undefined {
|
|
467
|
+
if (group.deleteIndices.length <= 1) return undefined;
|
|
468
|
+
const leading = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
469
|
+
const trailing = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
470
|
+
if (leading > 0 === trailing > 0) return undefined;
|
|
471
|
+
const side = leading > 0 ? "leading" : "trailing";
|
|
472
|
+
const count = leading > 0 ? leading : trailing;
|
|
473
|
+
if (count >= group.payload.length) return undefined;
|
|
474
|
+
const echoLines =
|
|
475
|
+
side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count);
|
|
476
|
+
if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined;
|
|
477
|
+
return { side, count };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string {
|
|
481
|
+
const where = side === "leading" ? "above" : "below";
|
|
482
|
+
return (
|
|
483
|
+
`Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
|
|
484
|
+
`dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` +
|
|
485
|
+
`The range was one line short of the content you retyped — issue the payload as the final content for the ` +
|
|
486
|
+
`selected range only, and widen the range to consume any keeper you restate.`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
446
490
|
/**
|
|
447
491
|
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
448
492
|
* unchanged surrounding lines or structural closers. Returns the repaired edit
|
|
@@ -481,6 +525,16 @@ function repairReplacementBoundaries(
|
|
|
481
525
|
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
482
526
|
);
|
|
483
527
|
if (balanceIsZero(delta)) {
|
|
528
|
+
const oneSided = findOneSidedBoundaryEcho(group, fileLines);
|
|
529
|
+
if (oneSided) {
|
|
530
|
+
warnings.push(describeOneSidedEchoRepair(group, oneSided.side, oneSided.count));
|
|
531
|
+
const trimmed =
|
|
532
|
+
oneSided.side === "leading"
|
|
533
|
+
? inserts.slice(oneSided.count)
|
|
534
|
+
: inserts.slice(0, inserts.length - oneSided.count);
|
|
535
|
+
out.push(...trimmed, ...deletes);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
484
538
|
out.push(...inserts, ...deletes);
|
|
485
539
|
continue;
|
|
486
540
|
}
|
package/src/block.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { STRUCTURAL_CLOSER_RE } from "./apply";
|
|
16
16
|
import {
|
|
17
17
|
BLOCK_RESOLVER_UNAVAILABLE,
|
|
18
|
+
blockSingleLineMessage,
|
|
18
19
|
blockUnresolvedMessage,
|
|
19
20
|
insertAfterBlockCloserLoweredWarning,
|
|
20
21
|
insertAfterBlockUnresolvedLoweredWarning,
|
|
@@ -110,6 +111,15 @@ export function resolveBlockEdits(
|
|
|
110
111
|
}`,
|
|
111
112
|
);
|
|
112
113
|
}
|
|
114
|
+
if (span.start === span.end) {
|
|
115
|
+
// A single-line block resolution means line N is a bare statement, not
|
|
116
|
+
// the opening line of a multi-line construct — the common mis-anchor
|
|
117
|
+
// that lands a body in the wrong scope (e.g. between a `case` body line
|
|
118
|
+
// and its `break;`). The plain op is exact for one line, so reject and
|
|
119
|
+
// point at it; drop instead on the lenient preview path.
|
|
120
|
+
if (onUnresolved === "drop") continue;
|
|
121
|
+
throw new Error(`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op)}`);
|
|
122
|
+
}
|
|
113
123
|
options.onResolved?.({
|
|
114
124
|
anchorLine: edit.anchor.line,
|
|
115
125
|
start: span.start,
|
package/src/messages.ts
CHANGED
|
@@ -179,3 +179,64 @@ export const HEADTAIL_DRIFT_WARNING =
|
|
|
179
179
|
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
180
180
|
return `Missing hashline snapshot tag for ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX}\` from your latest read/search output. To create a new file, use the write tool.`;
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
/** Compress a line list into a sorted `1-4, 7, 10-12` range string. */
|
|
184
|
+
function formatLineRanges(lines: readonly number[]): string {
|
|
185
|
+
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
|
186
|
+
if (sorted.length === 0) return "";
|
|
187
|
+
const parts: string[] = [];
|
|
188
|
+
let start = sorted[0];
|
|
189
|
+
let prev = sorted[0];
|
|
190
|
+
for (let i = 1; i <= sorted.length; i++) {
|
|
191
|
+
const current = sorted[i];
|
|
192
|
+
if (current === prev + 1) {
|
|
193
|
+
prev = current;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
parts.push(start === prev ? `${start}` : `${start}-${prev}`);
|
|
197
|
+
start = current;
|
|
198
|
+
prev = current;
|
|
199
|
+
}
|
|
200
|
+
return parts.join(", ");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* An anchored edit referenced lines the read that minted the cited tag never
|
|
205
|
+
* displayed (a partial range, or a structural summary that collapsed bodies).
|
|
206
|
+
* Editing lines you have not read is the off-by-memory failure that mangles
|
|
207
|
+
* files; reject and make the model re-read those exact lines first.
|
|
208
|
+
*/
|
|
209
|
+
export function unseenLinesMessage(sectionPath: string, unseenLines: readonly number[], tag: string): string {
|
|
210
|
+
return (
|
|
211
|
+
`This edit targets line(s) ${formatLineRanges(unseenLines)} of ${sectionPath} that were not shown in the ` +
|
|
212
|
+
`read/search output for ${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}${tag}${HL_FILE_SUFFIX} — a partial ` +
|
|
213
|
+
`range, a search hit, or a structural summary that collapsed bodies was displayed, not those exact lines. ` +
|
|
214
|
+
`Re-read those lines, then re-issue the edit against the fresh tag. NEVER author hunks against line numbers ` +
|
|
215
|
+
`you have not seen in the current snapshot.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
|
|
220
|
+
export type BlockOp = "replace" | "delete" | "insert_after";
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A `replace block`/`delete block`/`insert after block` anchor resolved to a
|
|
224
|
+
* single line — almost always a bare statement the model mis-anchored, not a
|
|
225
|
+
* multi-line construct. The plain op is unambiguous for one line; the block
|
|
226
|
+
* form only earns its keep when it spares counting a closing line you cannot
|
|
227
|
+
* see. Reject and point at both fixes.
|
|
228
|
+
*/
|
|
229
|
+
export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
|
230
|
+
const blockForm = op === "insert_after" ? "insert after block" : op === "delete" ? "delete block" : "replace block";
|
|
231
|
+
const plainForm =
|
|
232
|
+
op === "insert_after"
|
|
233
|
+
? `insert after ${line}:`
|
|
234
|
+
: op === "delete"
|
|
235
|
+
? `delete ${line}`
|
|
236
|
+
: `replace ${line}..${line}:`;
|
|
237
|
+
return (
|
|
238
|
+
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
|
239
|
+
`of a multi-line construct. For that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
|
|
240
|
+
`on the line that OPENS it (e.g. its \`function\`/\`if\`/\`case\` header), never a statement inside it.`
|
|
241
|
+
);
|
|
242
|
+
}
|
package/src/patcher.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { computeFileHash, formatHashlineHeader } from "./format";
|
|
|
28
28
|
import type { Filesystem, WriteResult } from "./fs";
|
|
29
29
|
import { isNotFound } from "./fs";
|
|
30
30
|
import type { Patch, PatchSection } from "./input";
|
|
31
|
-
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage } from "./messages";
|
|
31
|
+
import { HEADTAIL_DRIFT_WARNING, missingSnapshotTagMessage, unseenLinesMessage } from "./messages";
|
|
32
32
|
import { MismatchError } from "./mismatch";
|
|
33
33
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
34
34
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
@@ -341,6 +341,22 @@ export class Patcher {
|
|
|
341
341
|
#recordFullSnapshot(canonicalPath: string, normalized: string): string {
|
|
342
342
|
return this.snapshots.record(canonicalPath, normalized);
|
|
343
343
|
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reject an anchored edit that references a line the read which minted
|
|
347
|
+
* `expected` never displayed. The snapshot's `seenLines` is the set of
|
|
348
|
+
* 1-indexed lines a producer (read/search) actually showed under that tag;
|
|
349
|
+
* absent or empty means no provenance was recorded, so the edit applies as
|
|
350
|
+
* before. Only runs on the no-drift path, where anchor line numbers index
|
|
351
|
+
* the tagged content 1:1.
|
|
352
|
+
*/
|
|
353
|
+
#assertSeenLines(section: PatchSection, canonicalPath: string, expected: string): void {
|
|
354
|
+
const seen = this.snapshots.byHash(canonicalPath, expected)?.seenLines;
|
|
355
|
+
if (!seen || seen.size === 0) return;
|
|
356
|
+
const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
|
|
357
|
+
if (unseen.length === 0) return;
|
|
358
|
+
throw new Error(unseenLinesMessage(section.path, unseen, expected));
|
|
359
|
+
}
|
|
344
360
|
#mismatchError(
|
|
345
361
|
section: PatchSection,
|
|
346
362
|
canonicalPath: string,
|
|
@@ -404,6 +420,10 @@ export class Patcher {
|
|
|
404
420
|
// the caller read, so echo them back. (A drifted file falls through to
|
|
405
421
|
// recovery below, where line numbers shift, so resolutions are dropped.)
|
|
406
422
|
if (expected === undefined || liveMatches) {
|
|
423
|
+
// The line numbers in `edits` index the exact content the tag names.
|
|
424
|
+
// Reject any anchor the read never displayed: editing lines the model
|
|
425
|
+
// has not seen is the off-by-memory mistake that mangles files.
|
|
426
|
+
if (expected !== undefined) this.#assertSeenLines(section, canonicalPath, expected);
|
|
407
427
|
const result = applyEdits(normalized, resolved);
|
|
408
428
|
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
|
|
409
429
|
}
|
package/src/prompt.md
CHANGED
|
@@ -27,8 +27,8 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
27
27
|
- Line numbers and the `[PATH#TAG]` header come from your latest `read`/`search` (`LINE:TEXT` rows).
|
|
28
28
|
- Numbers refer to the ORIGINAL file; they do not shift as hunks apply.
|
|
29
29
|
- They die with the call: every applied edit mints a fresh `#TAG` and renumbers — anchor the next edit on the edit response or a fresh `read`.
|
|
30
|
-
- Touch only lines
|
|
31
|
-
- Elided regions (
|
|
30
|
+
- Touch only lines your latest `read`/`search` literally displayed as `LINE:TEXT`; the tag certifies the snapshot, not your memory of it. A hunk anchored on a line you never displayed is REJECTED — re-`read` those exact lines first. (Seeing a line ≠ it holding the code you mean: confirm the numbers map to the construct you intend, especially far from your last-read window.)
|
|
31
|
+
- Elided regions are UNSEEN: `…`/`..` markers and a collapsed `N-M:` summary row (only boundary lines N and M were shown) hide their interior. NEVER place or span a hunk inside one — `read` the range first.
|
|
32
32
|
- Never start or end a range mid-expression or mid-block.
|
|
33
33
|
- Indent body rows exactly for the depth they should live at.
|
|
34
34
|
- On a stale-tag rejection or any surprising result: STOP and re-`read` before further edits.
|
|
@@ -36,9 +36,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
36
36
|
- Ranges cover ONLY lines whose content changes. Never widen over unchanged lines — a stale wide range shreds everything it spans.
|
|
37
37
|
- Whole construct → `replace block N` (tree-sitter resolves the end); lines inside it → `replace N..M`.
|
|
38
38
|
- `replace block N` resolves EXACTLY the node at N. Leading decorators/attributes/doc-comments are separate nodes: point N at the FIRST decorator to sweep both; standalone line-comments are never swept — use `replace N..M`.
|
|
39
|
-
- `insert after block
|
|
39
|
+
- Block ops (`replace block`/`delete block`/`insert after block`) anchor the OPENING line of a MULTI-LINE construct — never its closer, its last line, or a bare statement inside it. Anchoring a single statement resolves to ONE line and is REJECTED: use the plain op (`replace N..N` / `delete N` / `insert after N`) for one line, or point N at the real opener. Saw the closer? Use plain `insert after M:`.
|
|
40
40
|
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
|
|
41
|
-
- Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped.
|
|
41
|
+
- Pure additions use `insert`, never a widened `replace` — retyped keepers are exactly what gets dropped. A multi-line `replace` whose body restates the line just outside the range is auto-dropped as an off-by-one keeper (with a warning), but issue the payload as the final content for the range only and never lean on the repair.
|
|
42
42
|
- NEVER format/restyle code with this tool; run the project formatter instead.
|
|
43
43
|
</rules>
|
|
44
44
|
|
package/src/snapshots.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface Snapshot {
|
|
|
36
36
|
readonly hash: string;
|
|
37
37
|
/** Timestamp (ms since epoch) the version was recorded. */
|
|
38
38
|
recordedAt: number;
|
|
39
|
+
/**
|
|
40
|
+
* 1-indexed file lines a producer (read/search) actually *displayed* under
|
|
41
|
+
* this tag. A partial read (range, or a structural summary that collapsed
|
|
42
|
+
* bodies) leaves this sparse; a whole-file read fills every line. Multiple
|
|
43
|
+
* reads of the same content union into one set. `undefined` means "no
|
|
44
|
+
* provenance recorded" — the patcher then skips the seen-line check and
|
|
45
|
+
* applies as before. Mutated in place as more of the same content is read.
|
|
46
|
+
*/
|
|
47
|
+
seenLines?: Set<number>;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -50,8 +59,20 @@ export abstract class SnapshotStore {
|
|
|
50
59
|
/** Recorded version for `path` whose tag equals `hash`, or `null`. */
|
|
51
60
|
abstract byHash(path: string, hash: string): Snapshot | null;
|
|
52
61
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Record the full normalized text of `path` and return its content tag.
|
|
64
|
+
* `seenLines` (optional) are the 1-indexed lines the producer displayed;
|
|
65
|
+
* they merge into {@link Snapshot.seenLines} across reads of identical text.
|
|
66
|
+
*/
|
|
67
|
+
abstract record(path: string, fullText: string, seenLines?: Iterable<number>): string;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge `lines` into the {@link Snapshot.seenLines} of the version whose tag
|
|
71
|
+
* equals `hash`. No-op when no such version is retained (the content aged
|
|
72
|
+
* out or was overwritten). Lets producers attach displayed lines after the
|
|
73
|
+
* tag was already minted (the body is formatted after the hash is computed).
|
|
74
|
+
*/
|
|
75
|
+
abstract recordSeenLines(path: string, hash: string, lines: Iterable<number>): void;
|
|
55
76
|
|
|
56
77
|
/** Drop the version history for a single path. */
|
|
57
78
|
abstract invalidate(path: string): void;
|
|
@@ -65,6 +86,13 @@ const DEFAULT_MAX_VERSIONS_PER_PATH = 4;
|
|
|
65
86
|
/** Global ceiling on retained snapshot text across all paths (UTF-16 code units). */
|
|
66
87
|
const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
67
88
|
|
|
89
|
+
/** Union `lines` into `snapshot.seenLines`, lazily creating the set. */
|
|
90
|
+
function mergeSeenLines(snapshot: Snapshot, lines: Iterable<number> | undefined): void {
|
|
91
|
+
if (lines === undefined) return;
|
|
92
|
+
if (snapshot.seenLines === undefined) snapshot.seenLines = new Set<number>();
|
|
93
|
+
for (const line of lines) snapshot.seenLines.add(line);
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
export interface InMemorySnapshotStoreOptions {
|
|
69
97
|
/** Maximum number of distinct paths tracked at once (default 30). LRU eviction. */
|
|
70
98
|
maxPaths?: number;
|
|
@@ -114,15 +142,17 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
114
142
|
return history?.find(version => version.hash === hash) ?? null;
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
record(path: string, fullText: string): string {
|
|
145
|
+
record(path: string, fullText: string, seenLines?: Iterable<number>): string {
|
|
118
146
|
const hash = computeFileHash(fullText);
|
|
119
147
|
// `get` refreshes LRU recency for `path`.
|
|
120
148
|
const history = this.#versions.get(path) ?? [];
|
|
121
149
|
const existing = history.find(version => version.hash === hash);
|
|
122
150
|
if (existing) {
|
|
123
151
|
// Same content state observed again: refresh recency and promote to
|
|
124
|
-
// head (it is the current file content), then reuse the tag.
|
|
152
|
+
// head (it is the current file content), then reuse the tag. Union any
|
|
153
|
+
// newly-displayed lines so re-reading more of the file widens coverage.
|
|
125
154
|
existing.recordedAt = Date.now();
|
|
155
|
+
mergeSeenLines(existing, seenLines);
|
|
126
156
|
if (history[0] !== existing) {
|
|
127
157
|
this.#versions.set(path, [existing, ...history.filter(version => version !== existing)]);
|
|
128
158
|
}
|
|
@@ -130,10 +160,16 @@ export class InMemorySnapshotStore extends SnapshotStore {
|
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
const snapshot: Snapshot = { path, text: fullText, hash, recordedAt: Date.now() };
|
|
163
|
+
mergeSeenLines(snapshot, seenLines);
|
|
133
164
|
this.#versions.set(path, [snapshot, ...history].slice(0, this.#maxVersionsPerPath));
|
|
134
165
|
return hash;
|
|
135
166
|
}
|
|
136
167
|
|
|
168
|
+
recordSeenLines(path: string, hash: string, lines: Iterable<number>): void {
|
|
169
|
+
const version = this.#versions.get(path)?.find(snapshot => snapshot.hash === hash);
|
|
170
|
+
if (version) mergeSeenLines(version, lines);
|
|
171
|
+
}
|
|
172
|
+
|
|
137
173
|
invalidate(path: string): void {
|
|
138
174
|
this.#versions.delete(path);
|
|
139
175
|
}
|