@oh-my-pi/hashline 15.9.3 → 15.9.67
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/README.md +17 -16
- package/dist/types/format.d.ts +3 -2
- package/dist/types/input.d.ts +1 -1
- package/dist/types/patcher.d.ts +2 -2
- package/dist/types/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/format.ts +4 -3
- package/src/grammar.lark +2 -2
- package/src/input.ts +43 -23
- package/src/messages.ts +2 -2
- package/src/mismatch.ts +4 -4
- package/src/parser.ts +1 -1
- package/src/patcher.ts +2 -2
- package/src/prefixes.ts +3 -1
- package/src/prompt.md +9 -9
- package/src/tokenizer.ts +39 -21
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -23,10 +23,10 @@ const snapshots = new InMemorySnapshotStore();
|
|
|
23
23
|
const before = `const greeting = "hi";\nexport { greeting };\n`;
|
|
24
24
|
await fs.writeText("hello.ts", before);
|
|
25
25
|
|
|
26
|
-
const tag = snapshots.
|
|
26
|
+
const tag = snapshots.record("hello.ts", before);
|
|
27
27
|
const patcher = new Patcher({ fs, snapshots });
|
|
28
|
-
const patch = Patch.parse(String.raw
|
|
29
|
-
|
|
28
|
+
const patch = Patch.parse(String.raw`[hello.ts#${tag}]
|
|
29
|
+
replace 1..1:
|
|
30
30
|
+const greeting = "hello";`);
|
|
31
31
|
const result = await patcher.apply(patch);
|
|
32
32
|
|
|
@@ -39,19 +39,19 @@ console.log(await fs.readText("hello.ts"));
|
|
|
39
39
|
See [`src/prompt.md`](./src/prompt.md) for the user-facing description and
|
|
40
40
|
[`src/grammar.lark`](./src/grammar.lark) for the formal grammar.
|
|
41
41
|
|
|
42
|
-
Each file section starts with
|
|
43
|
-
|
|
44
|
-
and is not meaningful outside that store. The patcher
|
|
45
|
-
stale anchors by resolving the tag, verifying the
|
|
46
|
-
|
|
47
|
-
recovery on mismatch.
|
|
42
|
+
Each file section starts with `[PATH#TAG]`. The tag is a 4-hex
|
|
43
|
+
content hash of the full normalized file text recorded by the
|
|
44
|
+
`SnapshotStore`, and it is not meaningful outside that store. The patcher
|
|
45
|
+
protects against stale anchors by resolving the tag, verifying the live file
|
|
46
|
+
still matches the recorded content hash, and refusing or attempting
|
|
47
|
+
session-aware recovery on mismatch.
|
|
48
48
|
|
|
49
49
|
Inside a section:
|
|
50
|
-
-
|
|
51
|
-
-
|
|
50
|
+
- `replace A..B:` — replace lines A..B with following `+TEXT` body rows.
|
|
51
|
+
- `replace block A:` — replace the syntactic block beginning on line A.
|
|
52
|
+
- `delete A..B` / `delete block A` — delete concrete lines or a resolved block.
|
|
53
|
+
- `insert before A:` / `insert after A:` / `insert head:` / `insert tail:` — insert following body rows.
|
|
52
54
|
- `+TEXT` — literal body row (use `+` alone for a blank line).
|
|
53
|
-
- `&A..B` — repeat original file lines A..B inline (`&A` for one line).
|
|
54
|
-
- Empty body — delete the selected range.
|
|
55
55
|
|
|
56
56
|
## Abstractions
|
|
57
57
|
|
|
@@ -67,9 +67,10 @@ text-document protocol, a Git tree, anything.
|
|
|
67
67
|
|
|
68
68
|
### `SnapshotStore`
|
|
69
69
|
|
|
70
|
-
Required. Hashline tags are
|
|
71
|
-
the store that
|
|
72
|
-
snapshot and 3-way-merges onto current content
|
|
70
|
+
Required. Hashline tags are full-file content hashes recorded per path, so
|
|
71
|
+
`Patcher` must receive the store that observed them. Recovery replays edits
|
|
72
|
+
against the cached pre-edit snapshot and 3-way-merges onto current content
|
|
73
|
+
when the live file diverged.
|
|
73
74
|
|
|
74
75
|
### `Patcher`
|
|
75
76
|
|
package/dist/types/format.d.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* tokenizer, the prompt, and the formal grammar.
|
|
5
5
|
*/
|
|
6
6
|
import type { Cursor } from "./types";
|
|
7
|
-
/** File-section header
|
|
8
|
-
export declare const HL_FILE_PREFIX = "
|
|
7
|
+
/** File-section header delimiters: `[path#hash]`. */
|
|
8
|
+
export declare const HL_FILE_PREFIX = "[";
|
|
9
|
+
export declare const HL_FILE_SUFFIX = "]";
|
|
9
10
|
/** Payload sigil for literal body rows. */
|
|
10
11
|
export declare const HL_PAYLOAD_REPLACE = "+";
|
|
11
12
|
/** Hunk-header keyword for concrete line replacement. */
|
package/dist/types/input.d.ts
CHANGED
|
@@ -69,7 +69,7 @@ export declare class PatchSection {
|
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
|
72
|
-
* at a
|
|
72
|
+
* at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
|
|
73
73
|
*
|
|
74
74
|
* `Patch` is pure data: parsing is line-anchored and does not look at the
|
|
75
75
|
* filesystem. To apply a patch, hand it to {@link Patcher.apply}.
|
package/dist/types/patcher.d.ts
CHANGED
|
@@ -32,9 +32,9 @@ export interface PatchSectionResult {
|
|
|
32
32
|
persisted: string;
|
|
33
33
|
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
|
|
34
34
|
written: string;
|
|
35
|
-
/**
|
|
35
|
+
/** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
|
|
36
36
|
fileHash: string;
|
|
37
|
-
/** Hashline section header (
|
|
37
|
+
/** Hashline section header (`[path#tag]`) of the post-edit content. */
|
|
38
38
|
header: string;
|
|
39
39
|
/** 1-indexed first changed line in `after`, or `undefined` for noops. */
|
|
40
40
|
firstChangedLine?: number;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -75,7 +75,7 @@ export interface SplitOptions {
|
|
|
75
75
|
/** Resolves absolute paths inside hashline headers to cwd-relative form. */
|
|
76
76
|
cwd?: string;
|
|
77
77
|
/**
|
|
78
|
-
* Fallback path used when the input lacks a
|
|
78
|
+
* Fallback path used when the input lacks a `[PATH]` header but contains
|
|
79
79
|
* recognizable hashline operations. Lets streaming previews work before
|
|
80
80
|
* the model has written the header.
|
|
81
81
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.9.
|
|
4
|
+
"version": "15.9.67",
|
|
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/format.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Cursor } from "./types";
|
|
8
8
|
|
|
9
|
-
/** File-section header
|
|
10
|
-
export const HL_FILE_PREFIX = "
|
|
9
|
+
/** File-section header delimiters: `[path#hash]`. */
|
|
10
|
+
export const HL_FILE_PREFIX = "[";
|
|
11
|
+
export const HL_FILE_SUFFIX = "]";
|
|
11
12
|
|
|
12
13
|
/** Payload sigil for literal body rows. */
|
|
13
14
|
export const HL_PAYLOAD_REPLACE = "+";
|
|
@@ -118,7 +119,7 @@ export function describeAnchorExamples(linePrefix = ""): string {
|
|
|
118
119
|
|
|
119
120
|
/** Format a hashline section header for a file path and snapshot tag. */
|
|
120
121
|
export function formatHashlineHeader(filePath: string, fileHash: string): string {
|
|
121
|
-
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
|
|
122
|
+
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}${HL_FILE_SUFFIX}`;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
/** Formats a single numbered line as `LINE:TEXT`. */
|
package/src/grammar.lark
CHANGED
|
@@ -3,9 +3,9 @@ begin_patch: "*** Begin Patch" LF
|
|
|
3
3
|
end_patch: "*** End Patch" LF?
|
|
4
4
|
|
|
5
5
|
file_patch: file_header hunk+
|
|
6
|
-
file_header: "
|
|
6
|
+
file_header: "[" filename "#" file_hash "]" LF
|
|
7
7
|
file_hash: /[0-9A-F]{4}/
|
|
8
|
-
filename: /[
|
|
8
|
+
filename: /[^#\r\n]+/
|
|
9
9
|
|
|
10
10
|
hunk: replace_hunk | replace_block_hunk | insert_hunk | delete_hunk | delete_block_hunk
|
|
11
11
|
replace_hunk: replace_anchor LF emit_op*
|
package/src/input.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Top-level patch parser. Splits an authored hashline input into a list of
|
|
3
|
-
* {@link PatchSection}s, each rooted at a
|
|
3
|
+
* {@link PatchSection}s, each rooted at a `[PATH#HASH]` header, then exposes
|
|
4
4
|
* a {@link Patch} class that gives lazy access to the parsed edits per
|
|
5
5
|
* section.
|
|
6
6
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { applyEdits } from "./apply";
|
|
12
12
|
import { resolveBlockEdits } from "./block";
|
|
13
|
-
import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
13
|
+
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
14
14
|
import { parsePatch, parsePatchStreaming } from "./parser";
|
|
15
15
|
import { Tokenizer } from "./tokenizer";
|
|
16
16
|
import type { ApplyResult, BlockResolver, Edit, SplitOptions } from "./types";
|
|
@@ -47,21 +47,41 @@ function stripApplyPatchPathNoise(pathText: string): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Best-effort recovery for
|
|
50
|
+
* Best-effort recovery for bracketed header lines the strict tokenizer
|
|
51
51
|
* rejects. Strips apply_patch keyword noise (`Update File:`, `Update:`,
|
|
52
|
-
* etc.) and an extra leading `***` (some models emit a hybrid
|
|
53
|
-
* shape), then expects `PATH(#HASH)
|
|
52
|
+
* etc.) and an extra leading `***` (some models emit a hybrid
|
|
53
|
+
* `[***foo.ts#HASH]` shape), then expects `PATH(#HASH)?`.
|
|
54
54
|
* Returns `null` when no clean path can be salvaged.
|
|
55
55
|
*/
|
|
56
56
|
function tryParseRecoveryHeader(line: string, cwd?: string): RawSection | null {
|
|
57
|
-
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
|
58
|
-
const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length).trim());
|
|
57
|
+
if (!line.startsWith(HL_FILE_PREFIX) || !line.endsWith(HL_FILE_SUFFIX)) return null;
|
|
58
|
+
const body = stripApplyPatchPathNoise(line.slice(HL_FILE_PREFIX.length, line.length - HL_FILE_SUFFIX.length).trim());
|
|
59
59
|
if (body.length === 0) return null;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
|
|
61
|
+
// Trailing `#XXXX` is the tag; everything before it is the path. The
|
|
62
|
+
// path may contain whitespace (Windows OneDrive folders, Program Files,
|
|
63
|
+
// etc.), so we anchor the tag at end-of-body rather than scanning
|
|
64
|
+
// forward and stopping at the first space.
|
|
65
|
+
const trailing = new RegExp(`#([0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}})\\s*$`).exec(body);
|
|
66
|
+
let pathText: string;
|
|
67
|
+
let fileHash: string | undefined;
|
|
68
|
+
if (trailing !== null) {
|
|
69
|
+
pathText = body.slice(0, trailing.index);
|
|
70
|
+
fileHash = trailing[1].toUpperCase();
|
|
71
|
+
} else {
|
|
72
|
+
pathText = body.replace(/\s+$/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Same rule as the strict tokenizer: the hashline header grammar uses
|
|
76
|
+
// `#` as the path/tag separator and does not allow `#` inside
|
|
77
|
+
// filenames. Anything `#` left in the path body — short tags, non-hex
|
|
78
|
+
// tags, over-long tags, stale-tag copy-paste, line-suffixed tags —
|
|
79
|
+
// means the header is malformed, not a path with an embedded hash.
|
|
80
|
+
if (pathText.includes("#")) return null;
|
|
81
|
+
|
|
82
|
+
const path = normalizeHashlinePath(pathText, cwd);
|
|
63
83
|
if (path.length === 0) return null;
|
|
64
|
-
return
|
|
84
|
+
return fileHash !== undefined ? { path, fileHash, diff: "" } : { path, diff: "" };
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
|
@@ -79,9 +99,9 @@ interface RawSection {
|
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
/**
|
|
82
|
-
* Parse a
|
|
83
|
-
* not start with
|
|
84
|
-
* when a
|
|
102
|
+
* Parse a `[PATH]` or `[PATH#hash]` header line. Returns `null` for lines that do
|
|
103
|
+
* not start with `[`. Throws the strict "Input header must be …" error
|
|
104
|
+
* when a bracketed line fails the strict shape (so malformed paths
|
|
85
105
|
* surface immediately instead of being silently re-classified as payload).
|
|
86
106
|
*/
|
|
87
107
|
function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null {
|
|
@@ -91,18 +111,18 @@ function parseHashlineHeaderLine(line: string, cwd?: string): RawSection | null
|
|
|
91
111
|
const token = TOKENIZER.tokenize(trimmed);
|
|
92
112
|
if (token.kind !== "header") {
|
|
93
113
|
// Recovery: try to extract a path from the raw line after stripping
|
|
94
|
-
// apply_patch noise. This handles
|
|
114
|
+
// apply_patch noise. This handles `[*** Update File:foo.ts#CB5A]` and
|
|
95
115
|
// the half-dozen variants models actually emit.
|
|
96
116
|
const recovered = tryParseRecoveryHeader(trimmed, cwd);
|
|
97
117
|
if (recovered !== null) return recovered;
|
|
98
118
|
throw new Error(
|
|
99
|
-
`Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
|
|
119
|
+
`Input header must be ${HL_FILE_PREFIX}PATH${HL_FILE_SUFFIX} or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}TAG${HL_FILE_SUFFIX} with a ${HL_FILE_HASH_LENGTH}-hex content-hash tag; got ${JSON.stringify(trimmed)}.`,
|
|
100
120
|
);
|
|
101
121
|
}
|
|
102
122
|
|
|
103
123
|
const parsedPath = normalizeHashlinePath(token.path, cwd);
|
|
104
124
|
if (parsedPath.length === 0) {
|
|
105
|
-
throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
|
|
125
|
+
throw new Error(`Input header "${HL_FILE_PREFIX}${HL_FILE_SUFFIX}" is empty; provide a file path.`);
|
|
106
126
|
}
|
|
107
127
|
return token.fileHash !== undefined
|
|
108
128
|
? { path: parsedPath, fileHash: token.fileHash, diff: "" }
|
|
@@ -145,7 +165,7 @@ function normalizeFallbackInput(input: string, options: SplitOptions): string {
|
|
|
145
165
|
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
|
146
166
|
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
|
147
167
|
if (fallbackPath.length === 0) return input;
|
|
148
|
-
return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
|
|
168
|
+
return `${HL_FILE_PREFIX}${fallbackPath}${HL_FILE_SUFFIX}\n${input}`;
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
function splitRawSections(input: string, options: SplitOptions = {}): RawSection[] {
|
|
@@ -160,13 +180,13 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
|
|
|
160
180
|
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(firstTrimmed)) {
|
|
161
181
|
throw new Error(
|
|
162
182
|
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
|
|
163
|
-
|
|
183
|
+
`File sections start with \`${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}\`; use \`replace\`, \`delete\`, or \`insert\` ops.`,
|
|
164
184
|
);
|
|
165
185
|
}
|
|
166
186
|
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
167
187
|
throw new Error(
|
|
168
|
-
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
|
|
169
|
-
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}
|
|
188
|
+
`input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH${HL_FILE_SUFFIX}" on the first non-blank line for anchored edits; got: ${preview}. ` +
|
|
189
|
+
`Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX}" then edit ops.`,
|
|
170
190
|
);
|
|
171
191
|
}
|
|
172
192
|
|
|
@@ -187,7 +207,7 @@ function splitRawSections(input: string, options: SplitOptions = {}): RawSection
|
|
|
187
207
|
if (token.kind === "envelope-end" || token.kind === "abort") break;
|
|
188
208
|
if (token.kind === "envelope-begin") continue;
|
|
189
209
|
|
|
190
|
-
// Route every
|
|
210
|
+
// Route every bracket-prefixed line through parseHashlineHeaderLine so
|
|
191
211
|
// malformed headers still raise the strict "Input header must be …"
|
|
192
212
|
// diagnostic (the tokenizer alone would silently classify them as
|
|
193
213
|
// payload).
|
|
@@ -323,7 +343,7 @@ export class PatchSection {
|
|
|
323
343
|
|
|
324
344
|
/**
|
|
325
345
|
* A parsed hashline patch — zero or more {@link PatchSection}s, each rooted
|
|
326
|
-
* at a
|
|
346
|
+
* at a `[PATH#HASH]` header. Construct via {@link Patch.parse}.
|
|
327
347
|
*
|
|
328
348
|
* `Patch` is pure data: parsing is line-anchored and does not look at the
|
|
329
349
|
* filesystem. To apply a patch, hand it to {@link Patcher.apply}.
|
package/src/messages.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* them.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
8
|
+
import { HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
9
9
|
|
|
10
10
|
/** Lines of context shown either side of a hash mismatch. */
|
|
11
11
|
export const MISMATCH_CONTEXT = 2;
|
|
@@ -124,5 +124,5 @@ export const HEADTAIL_DRIFT_WARNING =
|
|
|
124
124
|
* this single builder to stay in lockstep.
|
|
125
125
|
*/
|
|
126
126
|
export function missingSnapshotTagMessage(sectionPath: string): string {
|
|
127
|
-
return `Missing hashline snapshot tag for edit to ${sectionPath}; use \`${HL_FILE_PREFIX}${sectionPath}${HL_FILE_HASH_SEP}tag\` from your latest read/search output. To create a new file, use the write tool.`;
|
|
127
|
+
return `Missing hashline snapshot tag for edit to ${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.`;
|
|
128
128
|
}
|
package/src/mismatch.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* plus a couple of lines of surrounding context. The {@link MismatchError}
|
|
7
7
|
* formats this into a message at construction time.
|
|
8
8
|
*/
|
|
9
|
-
import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
|
|
9
|
+
import { formatNumberedLine, HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
|
|
10
10
|
import { MISMATCH_CONTEXT } from "./messages";
|
|
11
11
|
|
|
12
12
|
const LINE_REF_RE = /^\s*[>+\-*]*\s*(\d+)(?::.*)?\s*$/;
|
|
@@ -15,7 +15,7 @@ export function formatFullAnchorRequirement(raw?: string): string {
|
|
|
15
15
|
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
|
16
16
|
return (
|
|
17
17
|
`a bare line number from read/search output plus the section header content-hash tag ` +
|
|
18
|
-
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]} and line "160")${received}`
|
|
18
|
+
`(for example ${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}${HL_FILE_HASH_EXAMPLES[0]}${HL_FILE_SUFFIX} and line "160")${received}`
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -99,12 +99,12 @@ export class MismatchError extends Error {
|
|
|
99
99
|
if (!hashRecognized) {
|
|
100
100
|
return [
|
|
101
101
|
`Edit rejected${pathText}: hash ${HL_FILE_HASH_SEP}${details.expectedFileHash} is not from this session.`,
|
|
102
|
-
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag header — never invent the tag and never reuse one from a prior session.`,
|
|
102
|
+
`The current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. Re-read the file with \`read\` to copy a current ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}tag${HL_FILE_SUFFIX} header — never invent the tag and never reuse one from a prior session.`,
|
|
103
103
|
];
|
|
104
104
|
}
|
|
105
105
|
return [
|
|
106
106
|
`Edit rejected${pathText}: file changed between read and edit.`,
|
|
107
|
-
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
|
|
107
|
+
`Section is bound to ${HL_FILE_HASH_SEP}${details.expectedFileHash}, but the current file hashes to ${HL_FILE_HASH_SEP}${details.actualFileHash}. If a prior edit in this session modified this file, copy the ${HL_FILE_PREFIX}path${HL_FILE_HASH_SEP}newhash${HL_FILE_SUFFIX} header from that edit's response; otherwise re-read the file with \`read\` to refresh the tag before retrying.`,
|
|
108
108
|
];
|
|
109
109
|
}
|
|
110
110
|
|
package/src/parser.ts
CHANGED
|
@@ -43,7 +43,7 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
|
|
|
43
43
|
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
|
44
44
|
return (
|
|
45
45
|
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
46
|
-
"File sections start with
|
|
46
|
+
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
|
|
47
47
|
"Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
|
|
48
48
|
);
|
|
49
49
|
}
|
package/src/patcher.ts
CHANGED
|
@@ -64,9 +64,9 @@ export interface PatchSectionResult {
|
|
|
64
64
|
persisted: string;
|
|
65
65
|
/** Final text that the {@link Filesystem} actually wrote (may differ if the FS transformed it). */
|
|
66
66
|
written: string;
|
|
67
|
-
/**
|
|
67
|
+
/** 4-hex content-hash tag for `after`. Use to anchor follow-up edits. */
|
|
68
68
|
fileHash: string;
|
|
69
|
-
/** Hashline section header (
|
|
69
|
+
/** Hashline section header (`[path#tag]`) of the post-edit content. */
|
|
70
70
|
header: string;
|
|
71
71
|
/** 1-indexed first changed line in `after`, or `undefined` for noops. */
|
|
72
72
|
firstChangedLine?: number;
|
package/src/prefixes.ts
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* otherwise turn every content line into a (malformed) op.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { HL_FILE_HASH_LENGTH } from "./format";
|
|
18
|
+
|
|
17
19
|
const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
|
|
18
20
|
const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
|
|
19
|
-
const HL_HEADER_RE =
|
|
21
|
+
const HL_HEADER_RE = new RegExp(`^\\s*\\[[^#\\r\\n]+#[0-9a-fA-F]{${HL_FILE_HASH_LENGTH}}\\]\\s*$`);
|
|
20
22
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
21
23
|
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
|
|
22
24
|
|
package/src/prompt.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `delete` has no body.
|
|
2
2
|
|
|
3
3
|
<headers>
|
|
4
|
-
Every file section starts with
|
|
4
|
+
Every file section starts with `[PATH#TAG]`. `TAG` is the 4-hex snapshot tag from your latest `read`/`search`, and is REQUIRED on every section — there is no hashless form. To create a new file, use the `write` tool; hashline only edits files that already exist.
|
|
5
5
|
</headers>
|
|
6
6
|
|
|
7
7
|
<ops>
|
|
@@ -23,9 +23,9 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
23
23
|
</body-rows>
|
|
24
24
|
|
|
25
25
|
<rules>
|
|
26
|
-
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the
|
|
26
|
+
- Line numbers come from `read`/`search` (`LINE:TEXT`). Copy the `[PATH#TAG]` header; use the bare LINE numbers.
|
|
27
27
|
- Numbers refer to the ORIGINAL file and stay valid for the whole patch — they do not shift as hunks apply.
|
|
28
|
-
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the
|
|
28
|
+
- Across calls they do NOT survive: each applied edit mints a fresh `#TAG` and renumbers the file, so the tag and line numbers you just used are dead. Anchor the next edit on the `[PATH#TAG]` and lines from the edit response (or re-`read`), never on pre-edit numbers.
|
|
29
29
|
- A line number is an offset, not a structural boundary: never `insert after N` into a construct you have not read, and never start or end a `replace`/`delete` range mid-expression or mid-block. If unsure what is on those lines, `read` them first.
|
|
30
30
|
- On a stale-tag rejection — or any result you cannot fully account for — STOP and re-`read`. Never stack more line-numbered edits onto output you have not re-grounded; that compounds corruption.
|
|
31
31
|
- One hunk per range; the body is the final content, never an old/new pair.
|
|
@@ -37,7 +37,7 @@ There is NO other body row kind. NEVER write `-old` or a bare/context line. To k
|
|
|
37
37
|
<example>
|
|
38
38
|
Original (the exact shape `read` returns):
|
|
39
39
|
```
|
|
40
|
-
|
|
40
|
+
[greet.py#A1B2]
|
|
41
41
|
1:def greet(name):
|
|
42
42
|
2: msg = "Hello, " + name
|
|
43
43
|
3: print(msg)
|
|
@@ -46,14 +46,14 @@ Original (the exact shape `read` returns):
|
|
|
46
46
|
|
|
47
47
|
Insert a guard after line 1:
|
|
48
48
|
```
|
|
49
|
-
|
|
49
|
+
[greet.py#A1B2]
|
|
50
50
|
insert after 1:
|
|
51
51
|
+ if not name: name = "stranger"
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Replace line 2 with two lines:
|
|
55
55
|
```
|
|
56
|
-
|
|
56
|
+
[greet.py#A1B2]
|
|
57
57
|
replace 2..2:
|
|
58
58
|
+ greeting = "Hi"
|
|
59
59
|
+ msg = f"{greeting}, {name}"
|
|
@@ -61,13 +61,13 @@ replace 2..2:
|
|
|
61
61
|
|
|
62
62
|
Delete line 3:
|
|
63
63
|
```
|
|
64
|
-
|
|
64
|
+
[greet.py#A1B2]
|
|
65
65
|
delete 3
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
Add a header and trailer:
|
|
69
69
|
```
|
|
70
|
-
|
|
70
|
+
[greet.py#A1B2]
|
|
71
71
|
insert head:
|
|
72
72
|
+# generated header
|
|
73
73
|
insert tail:
|
|
@@ -76,7 +76,7 @@ insert tail:
|
|
|
76
76
|
|
|
77
77
|
Replace the whole `greet` function block — `replace block 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
|
|
78
78
|
```
|
|
79
|
-
|
|
79
|
+
[greet.py#A1B2]
|
|
80
80
|
replace block 1:
|
|
81
81
|
+def greet(name):
|
|
82
82
|
+ print(f"Hello, {name}")
|
package/src/tokenizer.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Format shape:
|
|
5
5
|
* ```
|
|
6
|
-
*
|
|
6
|
+
* [path/to/file.ts#1A2B]
|
|
7
7
|
* replace 5..7:
|
|
8
8
|
* +literal new line
|
|
9
9
|
* ```
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
HL_FILE_HASH_LENGTH,
|
|
16
16
|
HL_FILE_HASH_SEP,
|
|
17
17
|
HL_FILE_PREFIX,
|
|
18
|
+
HL_FILE_SUFFIX,
|
|
18
19
|
HL_HEADER_COLON,
|
|
19
20
|
HL_INSERT_AFTER,
|
|
20
21
|
HL_INSERT_BEFORE,
|
|
@@ -45,6 +46,7 @@ const CHAR_LOWER_F = 102;
|
|
|
45
46
|
const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
|
|
46
47
|
const CHAR_COLON = HL_HEADER_COLON.charCodeAt(0);
|
|
47
48
|
const FILE_PREFIX_LENGTH = HL_FILE_PREFIX.length;
|
|
49
|
+
const FILE_SUFFIX_LENGTH = HL_FILE_SUFFIX.length;
|
|
48
50
|
|
|
49
51
|
function isDigitCode(code: number): boolean {
|
|
50
52
|
return code >= CHAR_ZERO && code <= CHAR_NINE;
|
|
@@ -137,7 +139,7 @@ export function parseLid(raw: string, lineNum: number): Anchor {
|
|
|
137
139
|
if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) {
|
|
138
140
|
throw new Error(
|
|
139
141
|
`line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` +
|
|
140
|
-
`got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`,
|
|
142
|
+
`got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash${HL_FILE_SUFFIX} from your latest read for file-version binding.`,
|
|
141
143
|
);
|
|
142
144
|
}
|
|
143
145
|
return { line: number.line };
|
|
@@ -312,28 +314,44 @@ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
|
|
|
312
314
|
function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
|
|
313
315
|
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
|
314
316
|
const end = trimEndIndex(line);
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const path = line.slice(pathStart, index);
|
|
317
|
+
if (FILE_PREFIX_LENGTH + FILE_SUFFIX_LENGTH >= end) return null;
|
|
318
|
+
if (!line.endsWith(HL_FILE_SUFFIX, end)) return null;
|
|
319
|
+
const bodyEnd = end - FILE_SUFFIX_LENGTH;
|
|
320
|
+
if (FILE_PREFIX_LENGTH >= bodyEnd) return null;
|
|
321
|
+
|
|
322
|
+
// The snapshot tag, when present, is the trailing `#XXXX` block inside the
|
|
323
|
+
// bracketed header. We detect it from the suffix so the path may
|
|
324
|
+
// legitimately contain whitespace (e.g. `OneDrive - Company/file.ts`).
|
|
325
|
+
let pathEnd = bodyEnd;
|
|
325
326
|
let fileHash: string | undefined;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
327
|
+
const trailingHashStart = bodyEnd - HL_FILE_HASH_LENGTH - 1;
|
|
328
|
+
if (trailingHashStart >= FILE_PREFIX_LENGTH && line.charCodeAt(trailingHashStart) === CHAR_HASH) {
|
|
329
|
+
let allHex = true;
|
|
330
|
+
for (let probe = trailingHashStart + 1; probe < bodyEnd; probe++) {
|
|
331
|
+
if (!isHexDigitCode(line.charCodeAt(probe))) {
|
|
332
|
+
allHex = false;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (allHex) {
|
|
337
|
+
pathEnd = trailingHashStart;
|
|
338
|
+
fileHash = line.slice(trailingHashStart + 1, bodyEnd).toUpperCase();
|
|
332
339
|
}
|
|
333
|
-
fileHash = line.slice(hashStart, hashEnd).toUpperCase();
|
|
334
|
-
index = hashEnd;
|
|
335
340
|
}
|
|
336
|
-
|
|
341
|
+
|
|
342
|
+
// The hashline header grammar uses `#` as the path/tag separator and
|
|
343
|
+
// does not allow `#` inside filenames. Anything `#` left in the path
|
|
344
|
+
// body — short tags (`#1A2`), non-hex tags (`#1A2G`), over-long tags
|
|
345
|
+
// (`#1A2B5`), stale-tag copy-paste (`#1A2B copied from read`), or
|
|
346
|
+
// line-suffixed tags (`#1A2B:42`) — means the header is malformed.
|
|
347
|
+
// Surface the focused diagnostic instead of silently mis-routing the
|
|
348
|
+
// edit or reporting a missing tag downstream.
|
|
349
|
+
for (let i = FILE_PREFIX_LENGTH; i < pathEnd; i++) {
|
|
350
|
+
if (line.charCodeAt(i) === CHAR_HASH) return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (pathEnd === FILE_PREFIX_LENGTH) return null;
|
|
354
|
+
const path = line.slice(FILE_PREFIX_LENGTH, pathEnd);
|
|
337
355
|
return fileHash !== undefined ? { path, fileHash } : { path };
|
|
338
356
|
}
|
|
339
357
|
|
package/src/types.ts
CHANGED
|
@@ -72,7 +72,7 @@ export interface SplitOptions {
|
|
|
72
72
|
/** Resolves absolute paths inside hashline headers to cwd-relative form. */
|
|
73
73
|
cwd?: string;
|
|
74
74
|
/**
|
|
75
|
-
* Fallback path used when the input lacks a
|
|
75
|
+
* Fallback path used when the input lacks a `[PATH]` header but contains
|
|
76
76
|
* recognizable hashline operations. Lets streaming previews work before
|
|
77
77
|
* the model has written the header.
|
|
78
78
|
*/
|