@oh-my-pi/pi-coding-agent 15.5.3 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/settings-schema.d.ts +27 -0
  3. package/dist/types/config.d.ts +31 -5
  4. package/dist/types/edit/file-snapshot-store.d.ts +18 -0
  5. package/dist/types/edit/hashline/diff.d.ts +30 -0
  6. package/dist/types/edit/hashline/execute.d.ts +29 -0
  7. package/dist/types/edit/hashline/filesystem.d.ts +57 -0
  8. package/dist/types/edit/hashline/index.d.ts +4 -0
  9. package/dist/types/edit/hashline/params.d.ts +12 -0
  10. package/dist/types/edit/index.d.ts +4 -3
  11. package/dist/types/edit/normalize.d.ts +4 -16
  12. package/dist/types/index.d.ts +0 -1
  13. package/dist/types/tools/index.d.ts +6 -5
  14. package/dist/types/tools/path-utils.d.ts +18 -0
  15. package/dist/types/utils/changelog.d.ts +8 -3
  16. package/package.json +8 -15
  17. package/src/config/settings-schema.ts +32 -0
  18. package/src/config.ts +42 -15
  19. package/src/edit/file-snapshot-store.ts +22 -0
  20. package/src/edit/hashline/diff.ts +88 -0
  21. package/src/edit/hashline/execute.ts +188 -0
  22. package/src/edit/hashline/filesystem.ts +129 -0
  23. package/src/edit/hashline/index.ts +4 -0
  24. package/src/edit/hashline/params.ts +11 -0
  25. package/src/edit/index.ts +7 -15
  26. package/src/edit/normalize.ts +11 -41
  27. package/src/edit/renderer.ts +1 -1
  28. package/src/edit/streaming.ts +8 -9
  29. package/src/index.ts +0 -1
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/sdk.ts +8 -1
  32. package/src/tools/ast-edit.ts +1 -1
  33. package/src/tools/ast-grep.ts +3 -3
  34. package/src/tools/index.ts +6 -5
  35. package/src/tools/path-utils.ts +81 -0
  36. package/src/tools/read.ts +14 -72
  37. package/src/tools/search.ts +136 -17
  38. package/src/tools/write.ts +3 -3
  39. package/src/utils/changelog.ts +11 -3
  40. package/src/utils/file-mentions.ts +1 -1
  41. package/dist/types/edit/file-read-cache.d.ts +0 -36
  42. package/dist/types/hashline/anchors.d.ts +0 -26
  43. package/dist/types/hashline/apply.d.ts +0 -14
  44. package/dist/types/hashline/constants.d.ts +0 -48
  45. package/dist/types/hashline/diff-preview.d.ts +0 -2
  46. package/dist/types/hashline/diff.d.ts +0 -16
  47. package/dist/types/hashline/execute.d.ts +0 -4
  48. package/dist/types/hashline/executor.d.ts +0 -56
  49. package/dist/types/hashline/hash.d.ts +0 -76
  50. package/dist/types/hashline/index.d.ts +0 -14
  51. package/dist/types/hashline/input.d.ts +0 -4
  52. package/dist/types/hashline/prefixes.d.ts +0 -7
  53. package/dist/types/hashline/recovery.d.ts +0 -21
  54. package/dist/types/hashline/stream.d.ts +0 -2
  55. package/dist/types/hashline/tokenizer.d.ts +0 -94
  56. package/dist/types/hashline/types.d.ts +0 -75
  57. package/src/edit/file-read-cache.ts +0 -138
  58. package/src/hashline/anchors.ts +0 -104
  59. package/src/hashline/apply.ts +0 -790
  60. package/src/hashline/bigrams.json +0 -649
  61. package/src/hashline/constants.ts +0 -60
  62. package/src/hashline/diff-preview.ts +0 -42
  63. package/src/hashline/diff.ts +0 -82
  64. package/src/hashline/execute.ts +0 -334
  65. package/src/hashline/executor.ts +0 -347
  66. package/src/hashline/grammar.lark +0 -22
  67. package/src/hashline/hash.ts +0 -131
  68. package/src/hashline/index.ts +0 -14
  69. package/src/hashline/input.ts +0 -137
  70. package/src/hashline/prefixes.ts +0 -111
  71. package/src/hashline/recovery.ts +0 -139
  72. package/src/hashline/stream.ts +0 -123
  73. package/src/hashline/tokenizer.ts +0 -473
  74. package/src/hashline/types.ts +0 -66
  75. package/src/prompts/tools/hashline.md +0 -83
@@ -1,347 +0,0 @@
1
- import {
2
- ABORT_WARNING,
3
- IMPLICIT_CONTINUATION_WARNING,
4
- INLINE_PAYLOAD_ACCEPTED_WARNING,
5
- PAYLOAD_LINE_PREFIX_DEMOTED_WARNING,
6
- REPLACE_PAIR_COALESCED_WARNING,
7
- } from "./constants";
8
- import {
9
- HL_OP_CHARS,
10
- HL_OP_DELETE,
11
- HL_OP_INSERT_AFTER,
12
- HL_OP_INSERT_BEFORE,
13
- HL_OP_REPLACE,
14
- HL_PAYLOAD_PREFIX,
15
- } from "./hash";
16
- import {
17
- cloneCursor,
18
- type HashlineToken,
19
- HashlineTokenizer,
20
- isDeleteOpWithPayload,
21
- type ParsedRange,
22
- } from "./tokenizer";
23
- import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
24
-
25
- function validateRangeOrder(range: ParsedRange, lineNum: number): void {
26
- if (range.end.line < range.start.line) {
27
- throw new Error(`line ${lineNum}: range ${range.start.line}-${range.end.line} ends before it starts.`);
28
- }
29
- }
30
-
31
- function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
32
- return a.start.line === b.start.line && a.end.line === b.end.line;
33
- }
34
-
35
- function rangeContains(outer: ParsedRange, inner: ParsedRange): boolean {
36
- return outer.start.line <= inner.start.line && inner.end.line <= outer.end.line;
37
- }
38
-
39
- function expandRange(range: ParsedRange): Anchor[] {
40
- const anchors: Anchor[] = [];
41
- for (let line = range.start.line; line <= range.end.line; line++) {
42
- anchors.push({ line });
43
- }
44
- return anchors;
45
- }
46
-
47
- type PendingOp =
48
- | { kind: "insert"; cursor: HashlineCursor; lineNum: number }
49
- | { kind: "replace"; range: ParsedRange; lineNum: number };
50
-
51
- interface Pending {
52
- op: PendingOp;
53
- payload: string[];
54
- }
55
-
56
- /**
57
- * Token-driven state machine that turns a stream of {@link HashlineToken}s
58
- * into the flat list of {@link HashlineEdit}s applied downstream by the
59
- * apply/diff layers.
60
- *
61
- * The executor owns:
62
- * - the running edit index (kept monotonic across pending flushes),
63
- * - the pending-payload buffer (lines accumulated for the most recently
64
- * opened insert/replace op),
65
- * - all parse-time diagnostics (range order, "delete with payload",
66
- * orphan payload, unrecognized op),
67
- * - the {@link terminated} flag set by `envelope-end`/`abort`.
68
- *
69
- * Tokens are dispatched in the order they arrive; the matching tokenizer
70
- * supplies the line numbers carried inside each token so diagnostics line
71
- * up with the source.
72
- */
73
- export class HashlineExecutor {
74
- #edits: HashlineEdit[] = [];
75
- #warnings: string[] = [];
76
- #editIndex = 0;
77
- #pending: Pending | undefined;
78
- #terminated = false;
79
-
80
- /** True once an `envelope-end` or `abort` token has been observed. */
81
- get terminated(): boolean {
82
- return this.#terminated;
83
- }
84
-
85
- /**
86
- * Consume one token. After `terminated` flips true subsequent feeds
87
- * are silently ignored so callers can keep draining their tokenizer
88
- * without explicit early-exit guards.
89
- */
90
- feed(token: HashlineToken): void {
91
- if (this.#terminated) return;
92
-
93
- switch (token.kind) {
94
- case "envelope-begin":
95
- return;
96
- case "envelope-end":
97
- this.#terminated = true;
98
- return;
99
- case "abort":
100
- this.#warnings.push(ABORT_WARNING);
101
- this.#terminated = true;
102
- return;
103
- case "header":
104
- this.#flushPending();
105
- return;
106
- case "blank":
107
- return;
108
- case "payload":
109
- this.#handlePayload(token.text, token.lineNum);
110
- return;
111
- case "raw":
112
- this.#handleRaw(token.text, token.lineNum);
113
- return;
114
- case "op-delete":
115
- this.#flushPending();
116
- if (token.trailingPayload) {
117
- throw new Error(
118
- `line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
119
- );
120
- }
121
- validateRangeOrder(token.range, token.lineNum);
122
- for (const anchor of expandRange(token.range)) {
123
- this.#edits.push({ kind: "delete", anchor, lineNum: token.lineNum, index: this.#editIndex++ });
124
- }
125
- return;
126
- case "op-insert":
127
- this.#flushPending();
128
- this.#pending = {
129
- op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
130
- payload: [],
131
- };
132
- if (token.inlineBody !== undefined) {
133
- this.#pending.payload.push(token.inlineBody);
134
- if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
135
- this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
136
- }
137
- }
138
- return;
139
- case "op-replace":
140
- validateRangeOrder(token.range, token.lineNum);
141
- if (this.#pending !== undefined && this.#pending.op.kind === "replace") {
142
- const outer = this.#pending.op.range;
143
- const inner = token.range;
144
- if (rangesEqual(outer, inner)) {
145
- // Identical-range before/after pair. Drop the "before" payload
146
- // silently; the second op proceeds as the lone winner. Other
147
- // overlap shapes (different ranges, replace+delete, delete+delete)
148
- // still hit the post-hoc validator.
149
- this.#pending = undefined;
150
- if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
151
- this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
152
- }
153
- } else if (rangeContains(outer, inner)) {
154
- // Model wrote a payload line in read-output `LINE:TEXT` format
155
- // (or `A-B:TEXT` for a sub-range) inside an outer `A-B:` block.
156
- // The tokenizer can't tell payload from op when the anchor and
157
- // sigil shape are identical, so demote: append the op's inline
158
- // body to the pending payload, strip the `LINE:` prefix, and
159
- // keep accumulating. Without this the inner anchors would each
160
- // register as their own delete and clash with the outer range.
161
- this.#pending.payload.push(token.inlineBody ?? "");
162
- if (!this.#warnings.includes(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING)) {
163
- this.#warnings.push(PAYLOAD_LINE_PREFIX_DEMOTED_WARNING);
164
- }
165
- return;
166
- }
167
- }
168
- this.#flushPending();
169
- this.#pending = {
170
- op: { kind: "replace", range: token.range, lineNum: token.lineNum },
171
- payload: [],
172
- };
173
- if (token.inlineBody !== undefined) {
174
- this.#pending.payload.push(token.inlineBody);
175
- if (!this.#warnings.includes(INLINE_PAYLOAD_ACCEPTED_WARNING)) {
176
- this.#warnings.push(INLINE_PAYLOAD_ACCEPTED_WARNING);
177
- }
178
- }
179
- return;
180
- }
181
- }
182
-
183
- /**
184
- * Flush any open pending op (with its full accumulated payload, including
185
- * explicit `+` blank lines) and return the accumulated edits and warnings.
186
- * The executor is single-use; reset() is required for reuse.
187
- * Throws if two replace/delete ops target the same line with non-identical
188
- * shapes (different ranges, replace+delete, delete+delete). Identical-range
189
- * `A-B:` pairs in the same hunk are coalesced last-wins by `feed()` with a
190
- * warning, so they never reach the validator.
191
- */
192
- end(): { edits: HashlineEdit[]; warnings: string[] } {
193
- this.#flushPending();
194
- this.#validateNoOverlappingDeletes();
195
- return { edits: this.#edits, warnings: this.#warnings };
196
- }
197
-
198
- /** Reset to a fresh state so the same instance can drive another parse. */
199
- reset(): void {
200
- this.#edits = [];
201
- this.#warnings = [];
202
- this.#editIndex = 0;
203
- this.#pending = undefined;
204
- this.#terminated = false;
205
- }
206
-
207
- /**
208
- * Each `:` / `!` op contributes a delete edit per line in its range; if
209
- * any line ends up targeted by deletes originating from two different
210
- * source ops (distinguished by their `lineNum`), the patch is internally
211
- * inconsistent. Identical-range `A-B:` pairs are already collapsed by
212
- * `feed()`; remaining shapes here are an `A-B:` that overlaps a later
213
- * `N!`/`N:` with a different range, or two `!` deletes on the same line.
214
- * The applier would run both literally and the file would end up with two
215
- * copies of the line, not a chosen winner.
216
- */
217
- #validateNoOverlappingDeletes(): void {
218
- const sourceLinesByAnchor = new Map<number, number[]>();
219
- for (const edit of this.#edits) {
220
- if (edit.kind !== "delete") continue;
221
- let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
222
- if (sourceLines === undefined) {
223
- sourceLines = [];
224
- sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
225
- }
226
- if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
227
- }
228
- for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
229
- if (sourceLines.length < 2) continue;
230
- const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
231
- throw new Error(
232
- `line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
233
- `Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
234
- );
235
- }
236
- }
237
-
238
- #handlePayload(text: string, lineNum: number): void {
239
- if (this.#pending) {
240
- this.#pending.payload.push(text);
241
- return;
242
- }
243
-
244
- throw new Error(
245
- `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
246
- `Got ${JSON.stringify(`${HL_PAYLOAD_PREFIX}${text}`)}.`,
247
- );
248
- }
249
-
250
- #handleRaw(text: string, lineNum: number): void {
251
- if (this.#pending) {
252
- if (text.trim().length === 0) return;
253
- // Lenient legacy fallback: the tokenizer routes a line to `raw` only
254
- // when it does not parse as an op, header, payload, or envelope
255
- // marker. A `raw` token while a pending op exists is therefore an
256
- // unambiguous continuation row that the model authored without the
257
- // `+` prefix. Accept it as payload and warn so the canonical
258
- // `+`-prefixed form remains preferred.
259
- this.#pending.payload.push(text);
260
- if (!this.#warnings.includes(IMPLICIT_CONTINUATION_WARNING)) {
261
- this.#warnings.push(IMPLICIT_CONTINUATION_WARNING);
262
- }
263
- return;
264
- }
265
-
266
- // Whitespace-only raw lines outside any pending op are silently dropped;
267
- // fully empty lines arrive as `blank` tokens.
268
- if (text.trim().length === 0) return;
269
- // Orphan raw text outside any pending op: pick the most specific
270
- // diagnostic so the model sees the actionable hint.
271
- if (isDeleteOpWithPayload(text)) {
272
- throw new Error(
273
- `line ${lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
274
- );
275
- }
276
-
277
- const firstChar = text[0];
278
- const startsWithOp = firstChar !== undefined && HL_OP_CHARS.includes(firstChar);
279
- if (startsWithOp || firstChar === "-" || firstChar === "@" || firstChar === "«" || firstChar === "»") {
280
- throw new Error(
281
- `line ${lineNum}: unrecognized op. Use LINE${HL_OP_INSERT_BEFORE} (insert before), LINE${HL_OP_INSERT_AFTER} (insert after), LINE${HL_OP_REPLACE} / A-B${HL_OP_REPLACE} (replace), or LINE${HL_OP_DELETE} / A-B${HL_OP_DELETE} (delete). ` +
282
- `Got ${JSON.stringify(text)}.`,
283
- );
284
- }
285
-
286
- throw new Error(
287
- `line ${lineNum}: payload line has no preceding ${HL_OP_INSERT_BEFORE}, ${HL_OP_INSERT_AFTER}, ${HL_OP_REPLACE}, or ${HL_OP_DELETE} operation. ` +
288
- `Got ${JSON.stringify(text)}.`,
289
- );
290
- }
291
-
292
- #flushPending(): void {
293
- const pending = this.#pending;
294
- if (!pending) return;
295
-
296
- const { op, payload } = pending;
297
- const linesToInsert = payload.length === 0 ? [""] : payload;
298
-
299
- if (op.kind === "insert") {
300
- for (const text of linesToInsert) {
301
- this.#edits.push({
302
- kind: "insert",
303
- cursor: cloneCursor(op.cursor),
304
- text,
305
- lineNum: op.lineNum,
306
- index: this.#editIndex++,
307
- });
308
- }
309
- } else {
310
- for (const text of linesToInsert) {
311
- this.#edits.push({
312
- kind: "insert",
313
- cursor: { kind: "before_anchor", anchor: { ...op.range.start } },
314
- text,
315
- lineNum: op.lineNum,
316
- index: this.#editIndex++,
317
- });
318
- }
319
- for (const anchor of expandRange(op.range)) {
320
- this.#edits.push({ kind: "delete", anchor, lineNum: op.lineNum, index: this.#editIndex++ });
321
- }
322
- }
323
-
324
- this.#pending = undefined;
325
- }
326
- }
327
-
328
- /**
329
- * Drive a full hashline diff through the tokenizer + executor pipeline and
330
- * return the resulting edits plus any parse-time warnings. This is the
331
- * convenience entry point most callers want; reach for {@link
332
- * HashlineTokenizer}/{@link HashlineExecutor} directly only when you need
333
- * streaming feeds, cross-section state, or custom token handling.
334
- */
335
- export function parseHashline(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
336
- const tokenizer = new HashlineTokenizer();
337
- const executor = new HashlineExecutor();
338
- const drain = (tokens: HashlineToken[]): void => {
339
- for (const token of tokens) {
340
- if (executor.terminated) return;
341
- executor.feed(token);
342
- }
343
- };
344
- drain(tokenizer.feed(diff));
345
- drain(tokenizer.end());
346
- return executor.end();
347
- }
@@ -1,22 +0,0 @@
1
- start: begin_patch hunk+ end_patch
2
- begin_patch: "*** Begin Patch" LF
3
- end_patch: "*** End Patch" LF?
4
-
5
- hunk: update_hunk
6
- update_hunk: "$HFILE$" filename ("#" file_hash)? LF line_op*
7
-
8
- filename: /([^\s#]+)/
9
- file_hash: /[0-9a-f]{4}/
10
-
11
- line_op: insert_before | insert_after | replace | delete
12
- insert_before: anchor "$HOP_INSERT_BEFORE$" LF payload*
13
- insert_after: anchor "$HOP_INSERT_AFTER$" LF payload*
14
- replace: range "$HOP_REPLACE$" LF payload*
15
- delete: range "$HOP_DELETE$" LF
16
- payload: "+" /[^\n]*/ LF
17
-
18
- anchor: LID | "EOF" | "BOF"
19
- range: LID ("-" LID)?
20
- LID: /[1-9]\d*/
21
-
22
- %import common.LF
@@ -1,131 +0,0 @@
1
- /**
2
- * Core hash utilities shared by hashline edit mode, read/search output,
3
- * and prompt helpers.
4
- */
5
-
6
- const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
-
8
- /**
9
- * Decoration prefix that may precede a line number in tool output:
10
- * `>` (context line in grep), `-` (removed line), `*` (match line).
11
- * Any combination, in any order, surrounded by optional
12
- * whitespace. Output formatters emit at most one decoration per line; the
13
- * parser stays liberal because it accepts whatever the model echoes back.
14
- */
15
- export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>\\-*]*\\s*`;
16
-
17
- /** Capture-group regex source for a decorated bare line-number anchor. */
18
- export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
19
-
20
- /** Bare positive line-number Lid (no decorations, no captures, no anchors). */
21
- export const HL_LINE_RE_RAW = `[1-9]\\d*`;
22
-
23
- /** Capture-group form of {@link HL_LINE_RE_RAW}. */
24
- export const HL_LINE_CAPTURE_RE_RAW = `([1-9]\\d*)`;
25
-
26
- /** Four-hex-character file hash carried by a hashline section header. */
27
- export const HL_FILE_HASH_RE_RAW = `[0-9a-f]{4}`;
28
-
29
- /** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
30
- export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
31
-
32
- /** Separator between a hashline file path and its file hash. */
33
- export const HL_FILE_HASH_SEP = "#";
34
-
35
- /** Separator between a line number and displayed line content in hashline mode. */
36
- export const HL_LINE_BODY_SEP = ":";
37
-
38
- /** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
39
- export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP);
40
-
41
- /**
42
- * Representative file hashes for use in user-facing error messages and prompt
43
- * examples.
44
- */
45
- export const HL_FILE_HASH_EXAMPLES = ["1a2b", "3c4d", "9f3e"] as const;
46
-
47
- /**
48
- * Format a comma-separated list of example anchors with an optional line-number
49
- * prefix, quoted for inclusion in error messages: `"160", "42", "7"`.
50
- */
51
- export function describeAnchorExamples(linePrefix = ""): string {
52
- const examples = linePrefix ? [linePrefix, `${linePrefix.slice(0, -1) || "4"}2`, "7"] : ["160", "42", "7"];
53
- return examples.map(e => `"${e}"`).join(", ");
54
- }
55
-
56
- /**
57
- * Substitute every grammar placeholder with the value derived from its
58
- * TypeScript counterpart. Grammars that don't reference these placeholders
59
- * pass through unchanged.
60
- */
61
- export function resolveHashlineGrammarPlaceholders(grammar: string): string {
62
- return grammar
63
- .replaceAll("$HFMT$", "")
64
- .replaceAll("$HFILE_HASH$", HL_FILE_HASH_RE_RAW)
65
- .replaceAll("$HFILE_HASH_SEP$", HL_FILE_HASH_SEP)
66
- .replaceAll("$HOP_INSERT_BEFORE$", HL_OP_INSERT_BEFORE)
67
- .replaceAll("$HOP_INSERT_AFTER$", HL_OP_INSERT_AFTER)
68
- .replaceAll("$HOP_REPLACE$", HL_OP_REPLACE)
69
- .replaceAll("$HOP_DELETE$", HL_OP_DELETE)
70
- .replaceAll("$HOP_CHARS$", HL_OP_CHARS)
71
- .replaceAll("$HFILE$", HL_FILE_PREFIX);
72
- }
73
-
74
- /**
75
- * op lines have an `ANCHOR<SIGIL>[INLINE_PAYLOAD]` shape, where SIGIL is one of
76
- * {@link HL_OP_INSERT_BEFORE}, {@link HL_OP_INSERT_AFTER}, {@link HL_OP_REPLACE},
77
- * or {@link HL_OP_DELETE}. Multi-line payloads follow on subsequent lines
78
- * prefixed with {@link HL_PAYLOAD_PREFIX}; that prefix is stripped before the
79
- * payload is written.
80
- *
81
- * These constants are the single source of truth for the edit parser, grammar,
82
- * renderer, and prompt.
83
- */
84
- export const HL_OP_INSERT_BEFORE = "↑";
85
- export const HL_OP_INSERT_AFTER = "↓";
86
- export const HL_OP_REPLACE = ":";
87
- export const HL_OP_DELETE = "!";
88
-
89
- /** Prefix for payload continuation lines. The prefix itself is not written. */
90
- export const HL_PAYLOAD_PREFIX = "+";
91
-
92
- /** All hashline edit op sigils, concatenated for fast membership tests. */
93
- export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}${HL_OP_DELETE}`;
94
-
95
- /** Hashline edit file section header marker. */
96
- export const HL_FILE_PREFIX = "¶";
97
-
98
- function normalizeFileHashText(text: string): string {
99
- return text
100
- .replace(/\r/g, "")
101
- .split("\n")
102
- .map(line => line.trimEnd())
103
- .join("\n");
104
- }
105
-
106
- /**
107
- * Compute the 4-hex-character hash carried by a hashline section header.
108
- * The hash normalizes CR characters and trailing whitespace before hashing so
109
- * platform line endings and display-trimmed lines do not invalidate anchors.
110
- */
111
- export function computeFileHash(text: string): string {
112
- const normalized = normalizeFileHashText(text);
113
- const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff;
114
- return low16.toString(16).padStart(4, "0");
115
- }
116
-
117
- /** Format a hashline section header for a file path and file hash. */
118
- export function formatHashlineHeader(filePath: string, fileHash: string): string {
119
- return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
120
- }
121
-
122
- /** Formats a single numbered line as `LINE:TEXT`. */
123
- export function formatNumberedLine(lineNumber: number, line: string): string {
124
- return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
125
- }
126
-
127
- /** Format file text with hashline-mode line-number prefixes for display. */
128
- export function formatNumberedLines(text: string, startLine = 1): string {
129
- const lines = text.split("\n");
130
- return lines.map((line, i) => formatNumberedLine(startLine + i, line)).join("\n");
131
- }
@@ -1,14 +0,0 @@
1
- export * from "./anchors";
2
- export * from "./apply";
3
- export * from "./constants";
4
- export * from "./diff";
5
- export * from "./diff-preview";
6
- export * from "./execute";
7
- export * from "./executor";
8
- export * from "./hash";
9
- export * from "./input";
10
- export * from "./prefixes";
11
- export * from "./recovery";
12
- export * from "./stream";
13
- export * from "./tokenizer";
14
- export * from "./types";
@@ -1,137 +0,0 @@
1
- import * as path from "node:path";
2
- import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash";
3
- import { HashlineTokenizer } from "./tokenizer";
4
- import type { HashlineInputSection, SplitHashlineOptions } from "./types";
5
-
6
- // Pure classification — single shared tokenizer is safe.
7
- const TOKENIZER = new HashlineTokenizer();
8
-
9
- function unquoteHashlinePath(pathText: string): string {
10
- if (pathText.length < 2) return pathText;
11
- const first = pathText[0];
12
- const last = pathText[pathText.length - 1];
13
- if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
14
- return pathText;
15
- }
16
-
17
- function normalizeHashlinePath(rawPath: string, cwd?: string): string {
18
- const unquoted = unquoteHashlinePath(rawPath.trim());
19
- if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
20
- const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
21
- const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
22
- return isWithinCwd ? relative || "." : unquoted;
23
- }
24
-
25
- /**
26
- * Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do not
27
- * begin with the `¶` prefix; throws the existing "Input header must be …"
28
- * error when a `¶`-prefixed line fails the strict shape (so malformed paths
29
- * surface immediately instead of being silently re-classified as payload).
30
- */
31
- function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
32
- const trimmed = line.trimEnd();
33
- if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
34
-
35
- const token = TOKENIZER.tokenize(trimmed);
36
- if (token.kind !== "header") {
37
- throw new Error(
38
- `Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH with a 4-hex file hash; got ${JSON.stringify(trimmed)}.`,
39
- );
40
- }
41
-
42
- const parsedPath = normalizeHashlinePath(token.path, cwd);
43
- if (parsedPath.length === 0) {
44
- throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
45
- }
46
- return token.fileHash !== undefined
47
- ? { path: parsedPath, fileHash: token.fileHash, diff: "" }
48
- : { path: parsedPath, diff: "" };
49
- }
50
-
51
- function stripLeadingBlankLines(input: string): string {
52
- const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
53
- const lines = stripped.split("\n");
54
- while (lines.length > 0) {
55
- const head = lines[0].replace(/\r$/, "");
56
- if (head.trim().length === 0 || TOKENIZER.tokenize(head).kind === "envelope-begin") {
57
- lines.shift();
58
- continue;
59
- }
60
- break;
61
- }
62
- return lines.join("\n");
63
- }
64
-
65
- export function containsRecognizableHashlineOperations(input: string): boolean {
66
- for (const line of input.split(/\r?\n/)) {
67
- if (TOKENIZER.isOp(line)) return true;
68
- }
69
- return false;
70
- }
71
-
72
- function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
73
- const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
74
- const hasExplicitHeader = stripped
75
- .split(/\r?\n/)
76
- .some(rawLine => parseHashlineHeaderLine(rawLine, options.cwd) !== null);
77
- if (hasExplicitHeader) return input;
78
-
79
- if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
80
- const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
81
- if (fallbackPath.length === 0) return input;
82
- return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
83
- }
84
-
85
- export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): HashlineInputSection {
86
- const [section] = splitHashlineInputs(input, options);
87
- return section;
88
- }
89
-
90
- export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
91
- const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
92
- const lines = stripped.split(/\r?\n/);
93
- const firstLine = lines[0] ?? "";
94
-
95
- if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
96
- const preview = JSON.stringify(firstLine.slice(0, 120));
97
- throw new Error(
98
- `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
99
- `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b" then edit ops.`,
100
- );
101
- }
102
-
103
- const sections: HashlineInputSection[] = [];
104
- let current: HashlineInputSection | undefined;
105
- let currentLines: string[] = [];
106
-
107
- const flush = () => {
108
- if (!current) return;
109
- const hasOps = currentLines.some(line => line.trim().length > 0);
110
- if (hasOps) sections.push({ ...current, diff: currentLines.join("\n") });
111
- currentLines = [];
112
- };
113
-
114
- for (const line of lines) {
115
- const trimmed = line.trimEnd();
116
- const token = TOKENIZER.tokenize(line);
117
- if (token.kind === "envelope-end" || token.kind === "abort") break;
118
- if (token.kind === "envelope-begin") continue;
119
-
120
- // Route every `¶`-prefixed line through parseHashlineHeaderLine so
121
- // malformed headers still raise the strict "Input header must be …"
122
- // diagnostic (the tokenizer alone would silently classify them as
123
- // payload).
124
- if (trimmed.startsWith(HL_FILE_PREFIX)) {
125
- const header = parseHashlineHeaderLine(line, options.cwd);
126
- if (header !== null) {
127
- flush();
128
- current = header;
129
- currentLines = [];
130
- continue;
131
- }
132
- }
133
- currentLines.push(line);
134
- }
135
- flush();
136
- return sections;
137
- }