@oh-my-pi/pi-coding-agent 14.5.14 → 14.6.0

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 (70) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/config/settings-schema.ts +1 -1
  18. package/src/config/settings.ts +20 -1
  19. package/src/cursor.ts +1 -1
  20. package/src/edit/index.ts +9 -31
  21. package/src/edit/line-hash.ts +70 -43
  22. package/src/edit/modes/hashline.lark +26 -0
  23. package/src/edit/modes/hashline.ts +898 -1099
  24. package/src/edit/modes/patch.ts +0 -7
  25. package/src/edit/modes/replace.ts +0 -4
  26. package/src/edit/renderer.ts +22 -20
  27. package/src/edit/streaming.ts +8 -28
  28. package/src/eval/eval.lark +24 -30
  29. package/src/eval/js/context-manager.ts +5 -162
  30. package/src/eval/js/prelude.txt +0 -12
  31. package/src/eval/parse.ts +129 -129
  32. package/src/eval/py/prelude.py +1 -219
  33. package/src/export/html/template.generated.ts +1 -1
  34. package/src/export/html/template.js +2 -2
  35. package/src/internal-urls/docs-index.generated.ts +1 -1
  36. package/src/modes/components/session-observer-overlay.ts +5 -2
  37. package/src/modes/components/status-line/segments.ts +1 -1
  38. package/src/modes/components/status-line.ts +3 -5
  39. package/src/modes/components/tree-selector.ts +4 -5
  40. package/src/modes/components/welcome.ts +11 -1
  41. package/src/modes/controllers/command-controller.ts +2 -6
  42. package/src/modes/controllers/event-controller.ts +1 -2
  43. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  44. package/src/modes/controllers/input-controller.ts +0 -1
  45. package/src/modes/controllers/selector-controller.ts +1 -1
  46. package/src/modes/interactive-mode.ts +5 -7
  47. package/src/prompts/system/system-prompt.md +14 -38
  48. package/src/prompts/tools/ast-edit.md +8 -8
  49. package/src/prompts/tools/ast-grep.md +10 -10
  50. package/src/prompts/tools/eval.md +13 -31
  51. package/src/prompts/tools/find.md +2 -1
  52. package/src/prompts/tools/hashline.md +66 -57
  53. package/src/prompts/tools/search.md +2 -2
  54. package/src/session/session-manager.ts +17 -13
  55. package/src/tools/ast-edit.ts +141 -44
  56. package/src/tools/ast-grep.ts +112 -36
  57. package/src/tools/eval.ts +2 -53
  58. package/src/tools/find.ts +16 -15
  59. package/src/tools/path-utils.ts +36 -196
  60. package/src/tools/search.ts +56 -35
  61. package/src/utils/edit-mode.ts +2 -11
  62. package/src/utils/file-display-mode.ts +1 -1
  63. package/src/utils/git.ts +17 -0
  64. package/src/utils/session-color.ts +0 -12
  65. package/src/utils/title-generator.ts +22 -38
  66. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  67. package/src/autoresearch/contract.ts +0 -288
  68. package/src/edit/modes/atom.lark +0 -29
  69. package/src/edit/modes/atom.ts +0 -1773
  70. package/src/prompts/tools/atom.md +0 -150
@@ -1,24 +1,36 @@
1
1
  /**
2
- * Hashline edit mode — a line-addressable edit format using text hashes.
2
+ * Hashline edit mode.
3
3
  *
4
- * Each line in a file is identified by its 1-indexed line number and a short
5
- * BPE-bigram hash derived from the normalized line text (xxHash32 mod 647,
6
- * mapped through HASHLINE_BIGRAMS).
7
- * The combined `LINE+ID` reference acts as both an address and a staleness check:
8
- * if the file has changed since the caller last read it, hash mismatches are caught
9
- * before any mutation occurs.
4
+ * A compact, line-anchored wire format for file edits. Each section starts
5
+ * with `@PATH`. Edit ops are explicit blocks (`+ ANCHOR`, `- A..B`, `= A..B`)
6
+ * with payload lines prefixed by `|`.
10
7
  *
11
- * Displayed format: `LINE+ID|TEXT`
12
- * Reference format: `"LINE+ID"` (e.g. `"1ab"`)
8
+ * The module is organized into the following sections:
13
9
  *
14
- * In tool JSON, each edit's `content` is `string[]` (one string per logical line) or
15
- * `null` to delete the targeted range.
10
+ * 1. Imports
11
+ * 2. Public types & schemas
12
+ * 3. Constants & shared regexes
13
+ * 4. Small string utilities
14
+ * 5. Read-output prefix stripping (stripNewLinePrefixes, hashlineParseText)
15
+ * 6. Hashline streaming (streamHashLinesFromUtf8)
16
+ * 7. Anchor parsing & validation (parseTag, parseLid, parseRange, ...)
17
+ * 8. Mismatch error & rebase (HashlineMismatchError, tryRebaseAnchor)
18
+ * 9. Compact diff preview (buildCompactHashlineDiffPreview)
19
+ * 10. Edit DSL parsing (parseHashline, parseHashlineWithWarnings)
20
+ * 11. Edit application (applyHashlineEdits)
21
+ * 12. Input splitting (splitHashlineInput, splitHashlineInputs)
22
+ * 13. Diff computation (computeHashlineDiff)
23
+ * 14. Execution (executeHashlineSingle)
16
24
  */
17
25
 
26
+ // ───────────────────────────────────────────────────────────────────────────
27
+ // 1. Imports
28
+ // ───────────────────────────────────────────────────────────────────────────
29
+
30
+ import * as path from "node:path";
18
31
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
19
32
  import { isEnoent } from "@oh-my-pi/pi-utils";
20
33
  import { type Static, Type } from "@sinclair/typebox";
21
- import type { BunFile } from "bun";
22
34
  import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
23
35
  import type { ToolSession } from "../../tools";
24
36
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
@@ -28,39 +40,135 @@ import { resolveToCwd } from "../../tools/path-utils";
28
40
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
29
41
  import { formatCodeFrameLine } from "../../tools/render-utils";
30
42
  import { generateDiffString } from "../diff";
31
- import { computeLineHash, formatHashLine, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
43
+ import {
44
+ computeLineHash,
45
+ describeAnchorExamples,
46
+ formatHashLine,
47
+ HASHLINE_ANCHOR_RE_SRC,
48
+ HASHLINE_CONTENT_SEPARATOR,
49
+ HASHLINE_LID_CAPTURE_RE_SRC,
50
+ } from "../line-hash";
32
51
  import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
33
52
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
34
53
 
54
+ // ───────────────────────────────────────────────────────────────────────────
55
+ // 2. Public types & schemas
56
+ // ───────────────────────────────────────────────────────────────────────────
57
+
35
58
  export interface HashMismatch {
36
59
  line: number;
37
60
  expected: string;
38
61
  actual: string;
39
62
  }
40
63
 
41
- export type Anchor = { line: number; hash: string; contentHint?: string };
64
+ export type Anchor = {
65
+ line: number;
66
+ hash: string;
67
+ contentHint?: string;
68
+ };
69
+
70
+ type HashlineCursor =
71
+ | { kind: "bof" }
72
+ | { kind: "eof" }
73
+ | { kind: "before_anchor"; anchor: Anchor }
74
+ | { kind: "after_anchor"; anchor: Anchor };
75
+
42
76
  export type HashlineEdit =
43
- | { op: "replace_line"; pos: Anchor; lines: string[] }
44
- | { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
45
- | { op: "append_at"; pos: Anchor; lines: string[] }
46
- | { op: "prepend_at"; pos: Anchor; lines: string[] }
47
- | { op: "append_file"; lines: string[] }
48
- | { op: "prepend_file"; lines: string[] };
49
-
50
- // Tight prefix matchers for the new format `LINE+ID|content`. The pipe is the
51
- // canonical separator; legacy reads using `:` are tolerated for back-compat.
52
- // Line-number digits are mandatory.
53
- // Accept both `|` (canonical) and `:` (legacy) so re-reads of older outputs still parse.
77
+ | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
78
+ | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
79
+
80
+ export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
81
+ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
82
+
83
+ export interface HashlineStreamOptions {
84
+ /** First line number to use when formatting (1-indexed). */
85
+ startLine?: number;
86
+ /** Maximum formatted lines per yielded chunk (default: 200). */
87
+ maxChunkLines?: number;
88
+ /** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
89
+ maxChunkBytes?: number;
90
+ }
91
+
92
+ export interface CompactHashlineDiffPreview {
93
+ preview: string;
94
+ addedLines: number;
95
+ removedLines: number;
96
+ }
97
+
98
+ export interface CompactHashlineDiffOptions {
99
+ /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
100
+ maxUnchangedRun?: number;
101
+ }
102
+
103
+ export interface SplitHashlineOptions {
104
+ cwd?: string;
105
+ path?: string;
106
+ }
107
+
108
+ export interface ExecuteHashlineSingleOptions {
109
+ session: ToolSession;
110
+ input: string;
111
+ path?: string;
112
+ signal?: AbortSignal;
113
+ batchRequest?: LspBatchRequest;
114
+ writethrough: WritethroughCallback;
115
+ beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
116
+ }
117
+
118
+ // ───────────────────────────────────────────────────────────────────────────
119
+ // 3. Constants & shared regexes
120
+ // ───────────────────────────────────────────────────────────────────────────
121
+
122
+ /** How far either side of an anchor we'll search when auto-rebasing on hash match. */
123
+ export const ANCHOR_REBASE_WINDOW = 5;
124
+
125
+ /** Lines of context shown either side of a hash mismatch. */
126
+ const MISMATCH_CONTEXT = 2;
127
+
128
+ /** Filler hash used for the interior of a multi-line range; not validated. */
129
+ const RANGE_INTERIOR_HASH = "**";
130
+
131
+ /** Header marker introducing a new file section in multi-section input. */
132
+ const FILE_HEADER_PREFIX = "@";
133
+
54
134
  const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
55
- const HASHLINE_PREFIX_RE = new RegExp(
56
- `^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
57
- );
58
- const HASHLINE_PREFIX_PLUS_RE = new RegExp(
59
- `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
60
- );
135
+ const HASHLINE_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
136
+ const HASHLINE_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HASHLINE_CONTENT_SEPARATOR_RE}`);
61
137
  const DIFF_PLUS_RE = /^[+](?![+])/;
62
138
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L?\d+/;
63
139
 
140
+ const HASHLINE_HASH_HINT_RE = /^[a-z]{2}$/i;
141
+ const HASHLINE_ANCHOR_EXAMPLES = describeAnchorExamples("160");
142
+
143
+ const PARSE_TAG_RE = new RegExp(`^${HASHLINE_ANCHOR_RE_SRC}`);
144
+ const LID_CAPTURE_RE = new RegExp(`^${HASHLINE_LID_CAPTURE_RE_SRC}$`);
145
+
146
+ // ───────────────────────────────────────────────────────────────────────────
147
+ // 4. Small string utilities
148
+ // ───────────────────────────────────────────────────────────────────────────
149
+
150
+ function stripTrailingCarriageReturn(line: string): string {
151
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
152
+ }
153
+
154
+ function stripLeadingHashlinePrefixes(line: string): string {
155
+ let result = line;
156
+ let previous: string;
157
+ do {
158
+ previous = result;
159
+ result = result.replace(HASHLINE_PREFIX_RE, "");
160
+ } while (result !== previous);
161
+ return result;
162
+ }
163
+
164
+ // ───────────────────────────────────────────────────────────────────────────
165
+ // 5. Read-output prefix stripping
166
+ //
167
+ // When a model echoes back content from a `read` or `search` response, every
168
+ // line is prefixed with either a hashline tag (`123ab|`) or, for diff-style
169
+ // echoes, a leading `+`. These helpers detect that and recover the raw text.
170
+ // ───────────────────────────────────────────────────────────────────────────
171
+
64
172
  type LinePrefixStats = {
65
173
  nonEmpty: number;
66
174
  hashPrefixCount: number;
@@ -89,222 +197,60 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
89
197
  if (HASHLINE_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
90
198
  if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
91
199
  }
92
-
93
200
  return stats;
94
201
  }
95
202
 
96
- function stripLeadingHashlinePrefixes(line: string): string {
97
- let result = line;
98
- let prev: string;
99
- do {
100
- prev = result;
101
- result = result.replace(HASHLINE_PREFIX_RE, "");
102
- } while (result !== prev);
103
- return result;
104
- }
105
-
106
- function _filterTruncationNotices(lines: string[]): string[] {
107
- return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line));
108
- }
109
-
110
203
  export function stripNewLinePrefixes(lines: string[]): string[] {
111
- const { nonEmpty, hashPrefixCount, diffPlusHashPrefixCount, diffPlusCount } = collectLinePrefixStats(lines);
112
- if (nonEmpty === 0) return lines;
204
+ const stats = collectLinePrefixStats(lines);
205
+ if (stats.nonEmpty === 0) return lines;
113
206
 
114
- const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
207
+ const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty;
115
208
  const stripPlus =
116
- !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
117
- if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
209
+ !stripHash &&
210
+ stats.diffPlusHashPrefixCount === 0 &&
211
+ stats.diffPlusCount > 0 &&
212
+ stats.diffPlusCount >= stats.nonEmpty * 0.5;
213
+
214
+ if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
118
215
 
119
- const mapped = lines
216
+ return lines
120
217
  .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
121
218
  .map(line => {
122
219
  if (stripHash) return stripLeadingHashlinePrefixes(line);
123
220
  if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
124
- if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
221
+ if (stats.diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(line)) {
125
222
  return line.replace(HASHLINE_PREFIX_RE, "");
126
223
  }
127
224
  return line;
128
225
  });
129
- return mapped;
130
226
  }
131
227
 
132
228
  export function stripHashlinePrefixes(lines: string[]): string[] {
133
- const { nonEmpty, hashPrefixCount } = collectLinePrefixStats(lines);
134
- if (nonEmpty === 0) return lines;
135
- if (hashPrefixCount !== nonEmpty) return lines;
229
+ const stats = collectLinePrefixStats(lines);
230
+ if (stats.nonEmpty === 0) return lines;
231
+ if (stats.hashPrefixCount !== stats.nonEmpty) return lines;
136
232
  return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
137
233
  }
138
234
 
139
- const linesSchema = Type.Union([Type.Array(Type.String()), Type.Null()]);
140
-
141
- const locSchema = Type.Union(
142
- [
143
- Type.Literal("append"),
144
- Type.Literal("prepend"),
145
- Type.Object({ append: Type.String({ description: "anchor" }) }),
146
- Type.Object({ prepend: Type.String({ description: "anchor" }) }),
147
- Type.Object({
148
- range: Type.Object({
149
- pos: Type.String({ description: "first line to edit (inclusive)" }),
150
- end: Type.String({ description: "last line to edit (inclusive)" }),
151
- }),
152
- }),
153
- ],
154
- { description: "insert location" },
155
- );
156
-
157
- export const hashlineEditSchema = Type.Object(
158
- {
159
- loc: Type.Optional(locSchema),
160
- content: Type.Optional(linesSchema),
161
- },
162
- { additionalProperties: false },
163
- );
164
-
165
- export const hashlineEditParamsSchema = Type.Object(
166
- {
167
- path: Type.String({ description: "file path for edits" }),
168
- edits: Type.Array(hashlineEditSchema, { description: "edits" }),
169
- },
170
- { additionalProperties: false },
171
- );
172
-
173
- export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
174
- export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
175
-
176
- export interface ExecuteHashlineSingleOptions {
177
- session: ToolSession;
178
- path: string;
179
- edits: HashlineToolEdit[];
180
- signal?: AbortSignal;
181
- batchRequest?: LspBatchRequest;
182
- writethrough: WritethroughCallback;
183
- beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
184
- }
185
-
186
235
  /**
187
- * Normalize line payloads for apply: strip read/grep line prefixes. The tool schema
188
- * supplies `string[]` (one element per line). `null` / `undefined` yield `[]`.
189
- * A single multiline `string` is still split on `\n` for the same normalization path.
236
+ * Normalize line payloads by stripping read/search line prefixes. `null` /
237
+ * `undefined` yield `[]`; a single multiline string is split on `\n`.
190
238
  */
191
239
  export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
192
240
  if (edit == null) return [];
193
241
  if (typeof edit === "string") {
194
- const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
195
- edit = normalizedEdit.replaceAll("\r", "").split("\n");
242
+ const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
243
+ edit = trimmed.replaceAll("\r", "").split("\n");
196
244
  }
197
245
  return stripNewLinePrefixes(edit);
198
246
  }
199
247
 
200
- function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
201
- return edits.map(resolveEditAnchor);
202
- }
203
-
204
- type HashlineEditInput = HashlineToolEdit | HashlineEdit;
205
-
206
- function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[] {
207
- return edits.map((edit, editIndex) => {
208
- if (!edit || typeof edit !== "object") {
209
- throw new Error(`Invalid hashline edit at index ${editIndex}: expected object.`);
210
- }
211
-
212
- if ("op" in edit) {
213
- return edit;
214
- }
215
-
216
- if ("loc" in edit) {
217
- return resolveEditAnchor(edit);
218
- }
219
-
220
- throw new Error(`Invalid hashline edit at index ${editIndex}: expected op/loc payload.`);
221
- });
222
- }
223
-
224
- export function formatFullAnchorRequirement(raw?: string): string {
225
- const suffix = typeof raw === "string" ? raw.trim() : "";
226
- const hashOnlyHint = /^[A-Za-z]{2}$/.test(suffix)
227
- ? ` It looks like you supplied only the 2-letter suffix (${JSON.stringify(suffix)}). Copy the full anchor exactly as shown (for example, "160${suffix}").`
228
- : "";
229
- const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
230
- return `the full anchor exactly as shown by read/grep (line number + 2-letter suffix, for example "160sr")${received}${hashOnlyHint}`;
231
- }
232
-
233
- function tryParseTag(raw: string): Anchor | undefined {
234
- try {
235
- return parseTag(raw);
236
- } catch {
237
- return undefined;
238
- }
239
- }
240
-
241
- function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
242
- const anchor = tryParseTag(raw);
243
- if (!anchor) throw new Error(`${op} requires ${formatFullAnchorRequirement(raw)}.`);
244
- return anchor;
245
- }
246
-
247
- function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
248
- const pos = tryParseTag(range.pos);
249
- const end = tryParseTag(range.end);
250
- if (!pos || !end) {
251
- const invalid = [
252
- !pos ? `pos=${JSON.stringify(range.pos)}` : null,
253
- !end ? `end=${JSON.stringify(range.end)}` : null,
254
- ]
255
- .filter(Boolean)
256
- .join(", ");
257
- throw new Error(
258
- `range requires valid pos and end anchors. Use ${formatFullAnchorRequirement()}. Invalid: ${invalid}.`,
259
- );
260
- }
261
- return { pos, end };
262
- }
263
-
264
- function resolveEditAnchor(edit: HashlineToolEdit): HashlineEdit {
265
- const lines = hashlineParseText(edit.content);
266
- const loc = edit.loc;
267
-
268
- if (loc === "append") {
269
- return { op: "append_file", lines };
270
- }
271
-
272
- if (loc === "prepend") {
273
- return { op: "prepend_file", lines };
274
- }
275
-
276
- if (typeof loc !== "object") {
277
- throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
278
- }
279
-
280
- if ("append" in loc) {
281
- return { op: "append_at", pos: requireParsedAnchor(loc.append, "append"), lines };
282
- }
283
-
284
- if ("prepend" in loc) {
285
- return { op: "prepend_at", pos: requireParsedAnchor(loc.prepend, "prepend"), lines };
286
- }
287
-
288
- if ("range" in loc) {
289
- const { pos, end } = requireParsedRange(loc.range);
290
- return { op: "replace_range", pos, end, lines };
291
- }
292
-
293
- throw new Error("Unknown loc shape. Expected append, prepend, or range.");
294
- }
295
-
296
- // ═══════════════════════════════════════════════════════════════════════════
297
- // Hashline streaming formatter
298
- // ═══════════════════════════════════════════════════════════════════════════
299
-
300
- export interface HashlineStreamOptions {
301
- /** First line number to use when formatting (1-indexed). */
302
- startLine?: number;
303
- /** Maximum formatted lines per yielded chunk (default: 200). */
304
- maxChunkLines?: number;
305
- /** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
306
- maxChunkBytes?: number;
307
- }
248
+ // ───────────────────────────────────────────────────────────────────────────
249
+ // 6. Hashline streaming
250
+ //
251
+ // Convert a UTF-8 byte stream into a sequence of formatted hashline chunks,
252
+ // each capped by line count and byte size.
253
+ // ───────────────────────────────────────────────────────────────────────────
308
254
 
309
255
  interface ResolvedHashlineStreamOptions {
310
256
  startLine: number;
@@ -312,11 +258,6 @@ interface ResolvedHashlineStreamOptions {
312
258
  maxChunkBytes: number;
313
259
  }
314
260
 
315
- interface HashlineChunkEmitter {
316
- pushLine: (line: string) => string[];
317
- flush: () => string | undefined;
318
- }
319
-
320
261
  function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
321
262
  return {
322
263
  startLine: options.startLine ?? 1,
@@ -325,10 +266,12 @@ function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedH
325
266
  };
326
267
  }
327
268
 
328
- function createHashlineChunkEmitter(
329
- options: ResolvedHashlineStreamOptions,
330
- formatLine = formatHashLine,
331
- ): HashlineChunkEmitter {
269
+ interface HashlineChunkEmitter {
270
+ pushLine: (line: string) => string[];
271
+ flush: () => string | undefined;
272
+ }
273
+
274
+ function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): HashlineChunkEmitter {
332
275
  let lineNumber = options.startLine;
333
276
  let outLines: string[] = [];
334
277
  let outBytes = 0;
@@ -342,19 +285,18 @@ function createHashlineChunkEmitter(
342
285
  };
343
286
 
344
287
  const pushLine = (line: string): string[] => {
345
- const formatted = formatLine(lineNumber, line);
288
+ const formatted = formatHashLine(lineNumber, line);
346
289
  lineNumber++;
347
290
 
348
- const chunksToYield: string[] = [];
291
+ const chunks: string[] = [];
349
292
  const sepBytes = outLines.length === 0 ? 0 : 1;
350
293
  const lineBytes = Buffer.byteLength(formatted, "utf-8");
294
+ const wouldOverflow =
295
+ outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes;
351
296
 
352
- if (
353
- outLines.length > 0 &&
354
- (outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes)
355
- ) {
297
+ if (outLines.length > 0 && wouldOverflow) {
356
298
  const flushed = flush();
357
- if (flushed) chunksToYield.push(flushed);
299
+ if (flushed) chunks.push(flushed);
358
300
  }
359
301
 
360
302
  outLines.push(formatted);
@@ -362,10 +304,9 @@ function createHashlineChunkEmitter(
362
304
 
363
305
  if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
364
306
  const flushed = flush();
365
- if (flushed) chunksToYield.push(flushed);
307
+ if (flushed) chunks.push(flushed);
366
308
  }
367
-
368
- return chunksToYield;
309
+ return chunks;
369
310
  };
370
311
 
371
312
  return { pushLine, flush };
@@ -393,1040 +334,863 @@ async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): Asy
393
334
  }
394
335
  }
395
336
 
396
- /**
397
- * Stream hashline-formatted output from a UTF-8 byte source.
398
- *
399
- * This is intended for large files where callers want incremental output
400
- * (e.g. while reading from a file handle) rather than allocating a single
401
- * large string.
402
- */
403
337
  export async function* streamHashLinesFromUtf8(
404
338
  source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
405
339
  options: HashlineStreamOptions = {},
406
340
  ): AsyncGenerator<string> {
407
- const resolvedOptions = resolveHashlineStreamOptions(options);
341
+ const resolved = resolveHashlineStreamOptions(options);
408
342
  const decoder = new TextDecoder("utf-8");
409
343
  const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
344
+ const emitter = createHashlineChunkEmitter(resolved);
345
+
410
346
  let pending = "";
411
- let sawAnyText = false;
412
- let endedWithNewline = false;
413
- const emitter = createHashlineChunkEmitter(resolvedOptions);
414
-
415
- const consumeText = (text: string): string[] => {
416
- if (text.length === 0) return [];
417
- sawAnyText = true;
418
- pending += text;
419
- const chunksToYield: string[] = [];
420
- while (true) {
421
- const idx = pending.indexOf("\n");
422
- if (idx === -1) break;
423
- const line = pending.slice(0, idx);
424
- pending = pending.slice(idx + 1);
425
- endedWithNewline = true;
426
- chunksToYield.push(...emitter.pushLine(line));
427
- }
428
- if (pending.length > 0) endedWithNewline = false;
429
- return chunksToYield;
430
- };
347
+ let sawAnyLine = false;
348
+
431
349
  for await (const chunk of chunks) {
432
- for (const out of consumeText(decoder.decode(chunk, { stream: true }))) {
433
- yield out;
350
+ pending += decoder.decode(chunk, { stream: true });
351
+ let nl = pending.indexOf("\n");
352
+ while (nl !== -1) {
353
+ const raw = pending.slice(0, nl);
354
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
355
+ sawAnyLine = true;
356
+ for (const out of emitter.pushLine(line)) yield out;
357
+ pending = pending.slice(nl + 1);
358
+ nl = pending.indexOf("\n");
434
359
  }
435
360
  }
436
361
 
437
- for (const out of consumeText(decoder.decode())) {
438
- yield out;
362
+ pending += decoder.decode();
363
+ if (pending.length > 0) {
364
+ sawAnyLine = true;
365
+ const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
366
+ for (const out of emitter.pushLine(tail)) yield out;
439
367
  }
440
- if (!sawAnyText) {
441
- // Mirror `"".split("\n")` behavior: one empty line.
442
- for (const out of emitter.pushLine("")) {
443
- yield out;
444
- }
445
- } else if (pending.length > 0 || endedWithNewline) {
446
- // Emit the final line (may be empty if the file ended with a newline).
447
- for (const out of emitter.pushLine(pending)) {
448
- yield out;
449
- }
368
+ if (!sawAnyLine) {
369
+ for (const out of emitter.pushLine("")) yield out;
450
370
  }
451
371
 
452
372
  const last = emitter.flush();
453
373
  if (last) yield last;
454
374
  }
455
375
 
456
- /**
457
- * Stream hashline-formatted output from an (async) iterable of lines.
458
- *
459
- * Each yielded chunk is a `\n`-joined string of one or more formatted lines.
460
- */
461
- export async function* streamHashLinesFromLines(
462
- lines: Iterable<string> | AsyncIterable<string>,
463
- options: HashlineStreamOptions = {},
464
- ): AsyncGenerator<string> {
465
- const resolvedOptions = resolveHashlineStreamOptions(options);
466
- const emitter = createHashlineChunkEmitter(resolvedOptions);
467
- let sawAnyLine = false;
468
-
469
- const asyncIterator = (lines as AsyncIterable<string>)[Symbol.asyncIterator];
470
- if (typeof asyncIterator === "function") {
471
- for await (const line of lines as AsyncIterable<string>) {
472
- sawAnyLine = true;
473
- for (const out of emitter.pushLine(line)) {
474
- yield out;
475
- }
476
- }
477
- } else {
478
- for (const line of lines as Iterable<string>) {
479
- sawAnyLine = true;
480
- for (const out of emitter.pushLine(line)) {
481
- yield out;
482
- }
483
- }
484
- }
485
- if (!sawAnyLine) {
486
- // Mirror `"".split("\n")` behavior: one empty line.
487
- for (const out of emitter.pushLine("")) {
488
- yield out;
489
- }
490
- }
376
+ // ───────────────────────────────────────────────────────────────────────────
377
+ // 7. Anchor parsing & validation
378
+ // ───────────────────────────────────────────────────────────────────────────
491
379
 
492
- const last = emitter.flush();
493
- if (last) yield last;
380
+ export function formatFullAnchorRequirement(raw?: string): string {
381
+ const suffix = typeof raw === "string" ? raw.trim() : "";
382
+ const hashOnlyHint = HASHLINE_HASH_HINT_RE.test(suffix)
383
+ ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
384
+ `Copy the full anchor exactly as shown (for example, "160${suffix}").`
385
+ : "";
386
+ const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
387
+ return (
388
+ `the full anchor exactly as shown by read/search output ` +
389
+ `(line number + hash, for example ${HASHLINE_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
390
+ );
494
391
  }
495
392
 
496
- /**
497
- * Parse a line reference string like `"5th"` into structured form.
498
- *
499
- * @throws Error if the format is invalid (not `NUMBERBIGRAM`)
500
- */
501
393
  export function parseTag(ref: string): { line: number; hash: string } {
502
- // Captures:
503
- // 1. optional leading ">+-" markers and whitespace
504
- // 2. line number (1+ digits)
505
- // 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
506
- const match = ref.match(new RegExp(`^\\s*[>+\\-*]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
394
+ const match = ref.match(PARSE_TAG_RE);
507
395
  if (!match) {
508
396
  throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
509
397
  }
510
398
  const line = Number.parseInt(match[1], 10);
511
- if (line < 1) {
512
- throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
513
- }
399
+ if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
514
400
  return { line, hash: match[2] };
515
401
  }
516
402
 
517
- // ═══════════════════════════════════════════════════════════════════════════
518
- // Hash Mismatch Error
519
- // ═══════════════════════════════════════════════════════════════════════════
403
+ function parseLid(raw: string, lineNum: number): Anchor {
404
+ const match = LID_CAPTURE_RE.exec(raw);
405
+ if (!match) {
406
+ throw new Error(
407
+ `line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` +
408
+ `got ${JSON.stringify(raw)}.`,
409
+ );
410
+ }
411
+ return { line: Number.parseInt(match[1], 10), hash: match[2] };
412
+ }
413
+
414
+ interface ParsedRange {
415
+ start: Anchor;
416
+ end: Anchor;
417
+ }
520
418
 
521
- /** Number of context lines shown above/below each mismatched line */
522
- const MISMATCH_CONTEXT = 2;
419
+ function parseRange(raw: string, lineNum: number): ParsedRange {
420
+ const [startRaw, endRaw] = raw.split("..");
421
+ if (!startRaw) throw new Error(`line ${lineNum}: range is missing its first anchor.`);
422
+ const start = parseLid(startRaw, lineNum);
423
+ const end = endRaw === undefined ? { ...start } : parseLid(endRaw, lineNum);
424
+ if (end.line < start.line) {
425
+ throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`);
426
+ }
427
+ if (end.line === start.line && end.hash !== start.hash) {
428
+ throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`);
429
+ }
430
+ return { start, end };
431
+ }
432
+
433
+ function expandRange(range: ParsedRange): Anchor[] {
434
+ const anchors: Anchor[] = [];
435
+ for (let line = range.start.line; line <= range.end.line; line++) {
436
+ const hash =
437
+ line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH;
438
+ anchors.push({ line, hash });
439
+ }
440
+ return anchors;
441
+ }
442
+
443
+ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor {
444
+ if (raw === "BOF") return { kind: "bof" };
445
+ if (raw === "EOF") return { kind: "eof" };
446
+ const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
447
+ return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
448
+ }
449
+
450
+ export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
451
+ if (ref.line < 1 || ref.line > fileLines.length) {
452
+ throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
453
+ }
454
+ const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
455
+ if (actualHash !== ref.hash) {
456
+ throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
457
+ }
458
+ }
459
+
460
+ // ───────────────────────────────────────────────────────────────────────────
461
+ // 8. Mismatch error & rebase
462
+ // ───────────────────────────────────────────────────────────────────────────
463
+
464
+ function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
465
+ const displayLines = new Set<number>();
466
+ for (const mismatch of mismatches) {
467
+ const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
468
+ const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
469
+ for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
470
+ }
471
+ return [...displayLines].sort((a, b) => a - b);
472
+ }
523
473
 
524
- /**
525
- * Error thrown when one or more hashline references have stale hashes.
526
- *
527
- * Displays grep-style output with `*` marker on mismatched lines and a leading space on
528
- * surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
529
- */
530
474
  export class HashlineMismatchError extends Error {
531
475
  readonly remaps: ReadonlyMap<string, string>;
476
+
532
477
  constructor(
533
478
  public readonly mismatches: HashMismatch[],
534
479
  public readonly fileLines: string[],
535
480
  ) {
536
481
  super(HashlineMismatchError.formatMessage(mismatches, fileLines));
537
482
  this.name = "HashlineMismatchError";
483
+
538
484
  const remaps = new Map<string, string>();
539
- for (const m of mismatches) {
540
- const actual = computeLineHash(m.line, fileLines[m.line - 1]);
541
- remaps.set(`${m.line}${m.expected}`, `${m.line}${actual}`);
485
+ for (const mismatch of mismatches) {
486
+ const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
487
+ remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
542
488
  }
543
489
  this.remaps = remaps;
544
490
  }
545
491
 
546
- /**
547
- * User-visible variant of {@link formatMessage} — omits the bigram fingerprint
548
- * and uses a `│` gutter so TUI rendering is clean. The model still receives
549
- * the full `LINE+ID|content` form via {@link Error.message}.
550
- */
551
492
  get displayMessage(): string {
552
493
  return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
553
494
  }
554
495
 
555
- static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
556
- const mismatchSet = new Set<number>();
557
- for (const m of mismatches) mismatchSet.add(m.line);
558
-
559
- const displayLines = new Set<number>();
560
- for (const m of mismatches) {
561
- const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
562
- const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
563
- for (let i = lo; i <= hi; i++) displayLines.add(i);
564
- }
565
-
566
- const sorted = [...displayLines].sort((a, b) => a - b);
567
- const out: string[] = [
568
- `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
496
+ private static rejectionHeader(mismatches: HashMismatch[]): string[] {
497
+ const noun = mismatches.length > 1 ? "lines have" : "line has";
498
+ return [
499
+ `Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`,
569
500
  "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
570
- "",
571
501
  ];
502
+ }
572
503
 
573
- const lineNumberWidth = sorted.reduce((width, lineNum) => Math.max(width, String(lineNum).length), 0);
574
- let prevLine = -1;
575
- for (const lineNum of sorted) {
576
- if (prevLine !== -1 && lineNum > prevLine + 1) out.push("...");
577
- prevLine = lineNum;
578
- const text = fileLines[lineNum - 1];
504
+ static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
505
+ const mismatchSet = new Set<number>(mismatches.map(m => m.line));
506
+ const displayLines = getMismatchDisplayLines(mismatches, fileLines);
507
+ const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
508
+
509
+ const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
510
+ let previous = -1;
511
+ for (const lineNum of displayLines) {
512
+ if (previous !== -1 && lineNum > previous + 1) out.push("...");
513
+ previous = lineNum;
579
514
  const marker = mismatchSet.has(lineNum) ? "*" : " ";
580
- out.push(formatCodeFrameLine(marker, lineNum, text ?? "", lineNumberWidth));
515
+ out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
581
516
  }
582
517
  return out.join("\n");
583
518
  }
584
519
 
585
520
  static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
586
- const mismatchSet = new Map<number, HashMismatch>();
587
- for (const m of mismatches) {
588
- mismatchSet.set(m.line, m);
589
- }
590
-
591
- // Collect line ranges to display (mismatch lines + context)
592
- const displayLines = new Set<number>();
593
- for (const m of mismatches) {
594
- const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
595
- const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
596
- for (let i = lo; i <= hi; i++) {
597
- displayLines.add(i);
598
- }
599
- }
600
-
601
- const sorted = [...displayLines].sort((a, b) => a - b);
602
- const lines: string[] = [];
603
-
604
- lines.push(
605
- `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
606
- "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
607
- );
608
-
609
- let prevLine = -1;
610
- for (const lineNum of sorted) {
611
- // Gap separator between non-contiguous regions
612
- if (prevLine !== -1 && lineNum > prevLine + 1) {
613
- lines.push("...");
614
- }
615
- prevLine = lineNum;
616
-
617
- const text = fileLines[lineNum - 1];
521
+ const mismatchSet = new Set<number>(mismatches.map(m => m.line));
522
+ const lines = HashlineMismatchError.rejectionHeader(mismatches);
523
+ let previous = -1;
524
+ for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
525
+ if (previous !== -1 && lineNum > previous + 1) lines.push("...");
526
+ previous = lineNum;
527
+ const text = fileLines[lineNum - 1] ?? "";
618
528
  const hash = computeLineHash(lineNum, text);
619
- const prefix = `${lineNum}${hash}`;
620
-
621
- if (mismatchSet.has(lineNum)) {
622
- lines.push(`*${prefix}|${text}`);
623
- } else {
624
- lines.push(` ${prefix}|${text}`);
625
- }
529
+ const marker = mismatchSet.has(lineNum) ? "*" : " ";
530
+ lines.push(`${marker}${lineNum}${hash}${HASHLINE_CONTENT_SEPARATOR}${text}`);
626
531
  }
627
532
  return lines.join("\n");
628
533
  }
629
534
  }
630
535
 
631
536
  /**
632
- * Validate that a line reference points to an existing line with a matching hash.
633
- *
634
- * @param ref - Parsed line reference (1-indexed line number + expected hash)
635
- * @param fileLines - Array of file lines (0-indexed)
636
- * @throws HashlineMismatchError if the hash doesn't match (includes correct hashes in context)
637
- * @throws Error if the line is out of range
537
+ * Try to find a unique line within ±window where the file's actual hash
538
+ * matches the anchor's expected hash. Returns the new line number, or `null`
539
+ * if zero or multiple candidates were found.
638
540
  */
639
- export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
640
- if (ref.line < 1 || ref.line > fileLines.length) {
641
- throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
642
- }
643
- const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
644
- if (actualHash !== ref.hash) {
645
- throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
541
+ export function tryRebaseAnchor(
542
+ anchor: { line: number; hash: string },
543
+ fileLines: string[],
544
+ window: number = ANCHOR_REBASE_WINDOW,
545
+ ): number | null {
546
+ const lo = Math.max(1, anchor.line - window);
547
+ const hi = Math.min(fileLines.length, anchor.line + window);
548
+ let found: number | null = null;
549
+ for (let lineNum = lo; lineNum <= hi; lineNum++) {
550
+ if (computeLineHash(lineNum, fileLines[lineNum - 1] ?? "") !== anchor.hash) continue;
551
+ if (found !== null) return null;
552
+ found = lineNum;
646
553
  }
554
+ return found;
647
555
  }
648
556
 
649
- /**
650
- * Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
651
- */
652
- export const ANCHOR_REBASE_WINDOW = 5;
557
+ // ───────────────────────────────────────────────────────────────────────────
558
+ // 9. Compact diff preview
559
+ // ───────────────────────────────────────────────────────────────────────────
653
560
 
654
- /**
655
- * Look for the requested hash within ±`window` lines of `anchor.line`.
656
- *
657
- * Returns the new line number when exactly one nearby line matches the hash;
658
- * otherwise `null` (genuine mismatch or ambiguous). The caller is expected to
659
- * mutate `anchor.line` in place and surface a warning so the model knows the
660
- * edit was retargeted.
661
- *
662
- * The exact-position match (anchor.line itself) is intentionally skipped: the
663
- * caller has already determined the requested line's hash does not match.
664
- */
665
- export function tryRebaseAnchor(
666
- anchor: { line: number; hash: string },
667
- fileLines: string[],
668
- window: number = ANCHOR_REBASE_WINDOW,
669
- ): number | null {
670
- const lo = Math.max(1, anchor.line - window);
671
- const hi = Math.min(fileLines.length, anchor.line + window);
672
- let found: number | null = null;
673
- for (let line = lo; line <= hi; line++) {
674
- if (line === anchor.line) continue;
675
- if (computeLineHash(line, fileLines[line - 1]) !== anchor.hash) continue;
676
- if (found !== null) return null; // ambiguous: more than one match in window
677
- found = line;
678
- }
679
- return found;
680
- }
561
+ export function buildCompactHashlineDiffPreview(
562
+ diff: string,
563
+ _options: CompactHashlineDiffOptions = {},
564
+ ): CompactHashlineDiffPreview {
565
+ const lines = diff.length === 0 ? [] : diff.split("\n");
566
+ let addedLines = 0;
567
+ let removedLines = 0;
681
568
 
682
- function ensureHashlineEditHasContent(edit: HashlineEdit): void {
683
- if (edit.lines.length === 0) {
684
- edit.lines = [""];
685
- }
686
- }
569
+ // `generateDiffString` numbers `+` lines with the post-edit line number,
570
+ // `-` lines with the pre-edit line number, and context lines with the
571
+ // pre-edit line number. To emit fresh anchors usable for follow-up edits,
572
+ // we convert context-line numbers to post-edit positions by tracking the
573
+ // running offset (added so far - removed so far) as we walk the diff.
574
+ const formatted = lines.map(line => {
575
+ const kind = line[0];
576
+ if (kind !== "+" && kind !== "-" && kind !== " ") return line;
687
577
 
688
- function collectBoundaryDuplicationWarning(edit: HashlineEdit, originalFileLines: string[], warnings: string[]): void {
689
- let endLine: number;
690
- switch (edit.op) {
691
- case "replace_line":
692
- endLine = edit.pos.line;
693
- break;
694
- case "replace_range":
695
- endLine = edit.end.line;
696
- break;
697
- default:
698
- return;
699
- }
578
+ const body = line.slice(1);
579
+ const sep = body.indexOf("|");
580
+ if (sep === -1) return line;
700
581
 
701
- if (edit.lines.length === 0) return;
702
- const nextSurvivingIdx = endLine;
703
- if (nextSurvivingIdx >= originalFileLines.length) return;
704
- const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
705
- const lastInsertedLine = edit.lines[edit.lines.length - 1];
706
- const trimmedNext = nextSurvivingLine.trim();
707
- const trimmedLast = lastInsertedLine.trim();
708
- if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
709
- const tag = formatHashLine(endLine + 1, nextSurvivingLine);
710
- warnings.push(
711
- `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
712
- `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
713
- );
714
- }
715
- }
582
+ const lineNumber = Number.parseInt(body.slice(0, sep), 10);
583
+ const content = body.slice(sep + 1);
716
584
 
717
- function dedupeHashlineEdits(edits: HashlineEdit[]): void {
718
- const seenEditKeys = new Map<string, number>();
719
- const dedupIndices = new Set<number>();
720
- for (let i = 0; i < edits.length; i++) {
721
- const edit = edits[i];
722
- let lineKey: string;
723
- switch (edit.op) {
724
- case "replace_line":
725
- lineKey = `s:${edit.pos.line}`;
726
- break;
727
- case "replace_range":
728
- lineKey = `r:${edit.pos.line}:${edit.end.line}`;
729
- break;
730
- case "append_at":
731
- lineKey = `i:${edit.pos.line}`;
732
- break;
733
- case "prepend_at":
734
- lineKey = `ib:${edit.pos.line}`;
735
- break;
736
- case "append_file":
737
- lineKey = "ieof";
738
- break;
739
- case "prepend_file":
740
- lineKey = "ibef";
741
- break;
742
- }
743
- const dstKey = `${lineKey}:${edit.lines.join("\n")}`;
744
- if (seenEditKeys.has(dstKey)) {
745
- dedupIndices.add(i);
746
- } else {
747
- seenEditKeys.set(dstKey, i);
585
+ switch (kind) {
586
+ case "+":
587
+ addedLines++;
588
+ return `+${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
589
+ case "-":
590
+ removedLines++;
591
+ return `-${lineNumber}--${HASHLINE_CONTENT_SEPARATOR}${content}`;
592
+ default: {
593
+ const newLineNumber = lineNumber + addedLines - removedLines;
594
+ return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
595
+ }
748
596
  }
597
+ });
598
+
599
+ return { preview: formatted.join("\n"), addedLines, removedLines };
600
+ }
601
+
602
+ // ───────────────────────────────────────────────────────────────────────────
603
+ // 10. Edit DSL parsing
604
+ //
605
+ // Grammar (one op per "block"):
606
+ // "+ ANCHOR" followed by 1+ "|TEXT" payload lines — insert
607
+ // "- A..B" no payload — delete range
608
+ // "= A..B" followed by 1+ "|TEXT" payload lines — replace
609
+ //
610
+ // ANCHOR is `LINE<hash>`, e.g. `160ab`. BOF / EOF are also valid insert targets.
611
+ // ───────────────────────────────────────────────────────────────────────────
612
+
613
+ const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
614
+ const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
615
+ const DELETE_OP_RE = /^-\s*(\S+)$/;
616
+ const REPLACE_OP_RE = /^=\s*(\S+)$/;
617
+
618
+ function cloneCursor(cursor: HashlineCursor): HashlineCursor {
619
+ if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
620
+ if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
621
+ return cursor;
622
+ }
623
+
624
+ function collectPayload(
625
+ lines: string[],
626
+ startIndex: number,
627
+ opLineNum: number,
628
+ requirePayload: boolean,
629
+ ): { payload: string[]; nextIndex: number } {
630
+ const payload: string[] = [];
631
+ let index = startIndex;
632
+ while (index < lines.length) {
633
+ const line = stripTrailingCarriageReturn(lines[index]);
634
+ if (!line.startsWith("|")) break;
635
+ payload.push(line.slice(1));
636
+ index++;
749
637
  }
750
- if (dedupIndices.size === 0) return;
751
- for (let i = edits.length - 1; i >= 0; i--) {
752
- if (dedupIndices.has(i)) edits.splice(i, 1);
638
+ if (payload.length === 0 && requirePayload) {
639
+ throw new Error(`line ${opLineNum}: + and < operations require at least one |TEXT payload line.`);
753
640
  }
641
+ return { payload, nextIndex: index };
754
642
  }
755
643
 
756
- function getHashlineEditSortKey(edit: HashlineEdit, fileLineCount: number): { sortLine: number; precedence: number } {
757
- switch (edit.op) {
758
- case "replace_line":
759
- return { sortLine: edit.pos.line, precedence: 0 };
760
- case "replace_range":
761
- return { sortLine: edit.end.line, precedence: 0 };
762
- case "append_at":
763
- return { sortLine: edit.pos.line, precedence: 1 };
764
- case "prepend_at":
765
- return { sortLine: edit.pos.line, precedence: 2 };
766
- case "append_file":
767
- return { sortLine: fileLineCount + 1, precedence: 1 };
768
- case "prepend_file":
769
- return { sortLine: 0, precedence: 2 };
770
- }
644
+ export function parseHashline(diff: string): HashlineEdit[] {
645
+ return parseHashlineWithWarnings(diff).edits;
771
646
  }
772
647
 
773
- function applyHashlineEditToLines(
774
- edit: HashlineEdit,
775
- fileLines: string[],
776
- originalFileLines: string[],
777
- editIndex: number,
778
- noopEdits: Array<{ editIndex: number; loc: string; current: string }>,
779
- trackFirstChanged: (line: number) => void,
780
- ): void {
781
- switch (edit.op) {
782
- case "replace_line": {
783
- const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
784
- const newLines = edit.lines;
785
- if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
786
- noopEdits.push({
787
- editIndex,
788
- loc: `${edit.pos.line}${edit.pos.hash}`,
789
- current: origLines.join("\n"),
790
- });
791
- break;
792
- }
793
- fileLines.splice(edit.pos.line - 1, 1, ...newLines);
794
- trackFirstChanged(edit.pos.line);
795
- break;
648
+ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
649
+ const edits: HashlineEdit[] = [];
650
+ const lines = diff.split("\n");
651
+ let editIndex = 0;
652
+
653
+ const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
654
+ edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ });
655
+ };
656
+
657
+ for (let i = 0; i < lines.length; ) {
658
+ const lineNum = i + 1;
659
+ const line = stripTrailingCarriageReturn(lines[i]);
660
+
661
+ if (line.trim().length === 0) {
662
+ i++;
663
+ continue;
796
664
  }
797
- case "replace_range": {
798
- const count = edit.end.line - edit.pos.line + 1;
799
- const origRange = originalFileLines.slice(edit.pos.line - 1, edit.pos.line - 1 + count);
800
- if (count === edit.lines.length && origRange.every((line, i) => line === edit.lines[i])) {
801
- noopEdits.push({
802
- editIndex,
803
- loc: `${edit.pos.line}${edit.pos.hash}-${edit.end.line}${edit.end.hash}`,
804
- current: origRange.join("\n"),
805
- });
806
- break;
807
- }
808
- fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
809
- trackFirstChanged(edit.pos.line);
810
- break;
665
+ if (line.startsWith("|")) {
666
+ throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
811
667
  }
812
- case "append_at": {
813
- const inserted = edit.lines;
814
- if (inserted.length === 0) {
815
- noopEdits.push({
816
- editIndex,
817
- loc: `${edit.pos.line}${edit.pos.hash}`,
818
- current: originalFileLines[edit.pos.line - 1],
819
- });
820
- break;
821
- }
822
- fileLines.splice(edit.pos.line, 0, ...inserted);
823
- trackFirstChanged(edit.pos.line + 1);
824
- break;
668
+
669
+ const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
670
+ if (insertBeforeMatch) {
671
+ const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
672
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
673
+ for (const text of payload) pushInsert(cursor, text, lineNum);
674
+ i = nextIndex;
675
+ continue;
825
676
  }
826
- case "prepend_at": {
827
- const inserted = edit.lines;
828
- if (inserted.length === 0) {
829
- noopEdits.push({
830
- editIndex,
831
- loc: `${edit.pos.line}${edit.pos.hash}`,
832
- current: originalFileLines[edit.pos.line - 1],
833
- });
834
- break;
835
- }
836
- fileLines.splice(edit.pos.line - 1, 0, ...inserted);
837
- trackFirstChanged(edit.pos.line);
838
- break;
677
+
678
+ const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
679
+ if (insertAfterMatch) {
680
+ const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
681
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
682
+ for (const text of payload) pushInsert(cursor, text, lineNum);
683
+ i = nextIndex;
684
+ continue;
839
685
  }
840
- case "append_file": {
841
- const inserted = edit.lines;
842
- if (inserted.length === 0) {
843
- noopEdits.push({ editIndex, loc: "EOF", current: "" });
844
- break;
845
- }
846
- if (fileLines.length === 1 && fileLines[0] === "") {
847
- fileLines.splice(0, 1, ...inserted);
848
- trackFirstChanged(1);
849
- } else {
850
- fileLines.splice(fileLines.length, 0, ...inserted);
851
- trackFirstChanged(fileLines.length - inserted.length + 1);
686
+
687
+ const deleteMatch = DELETE_OP_RE.exec(line);
688
+ if (deleteMatch) {
689
+ for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
690
+ edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
852
691
  }
853
- break;
692
+ i++;
693
+ continue;
854
694
  }
855
- case "prepend_file": {
856
- const inserted = edit.lines;
857
- if (inserted.length === 0) {
858
- noopEdits.push({ editIndex, loc: "BOF", current: "" });
859
- break;
695
+
696
+ const replaceMatch = REPLACE_OP_RE.exec(line);
697
+ if (replaceMatch) {
698
+ const range = parseRange(replaceMatch[1], lineNum);
699
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
700
+ // `= A..B` with no payload blanks the range to a single empty line.
701
+ const replacement = payload.length === 0 ? [""] : payload;
702
+ for (const text of replacement) {
703
+ edits.push({
704
+ kind: "insert",
705
+ cursor: { kind: "before_anchor", anchor: { ...range.start } },
706
+ text,
707
+ lineNum,
708
+ index: editIndex++,
709
+ });
860
710
  }
861
- if (fileLines.length === 1 && fileLines[0] === "") {
862
- fileLines.splice(0, 1, ...inserted);
863
- } else {
864
- fileLines.splice(0, 0, ...inserted);
711
+ for (const anchor of expandRange(range)) {
712
+ edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
865
713
  }
866
- trackFirstChanged(1);
867
- break;
714
+ i = nextIndex;
715
+ continue;
868
716
  }
717
+
718
+ throw new Error(
719
+ `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or |TEXT payload lines. ` +
720
+ `Got ${JSON.stringify(line)}.`,
721
+ );
869
722
  }
723
+
724
+ return { edits, warnings: [] };
870
725
  }
871
726
 
872
- function buildHashlineEditResult(params: {
873
- fileLines: string[];
874
- firstChangedLine: number | undefined;
875
- warnings: string[];
876
- noopEdits: Array<{ editIndex: number; loc: string; current: string }>;
877
- }): {
727
+ // ───────────────────────────────────────────────────────────────────────────
728
+ // 11. Edit application
729
+ // ───────────────────────────────────────────────────────────────────────────
730
+
731
+ interface HashlineApplyResult {
878
732
  lines: string;
879
- firstChangedLine: number | undefined;
733
+ firstChangedLine?: number;
880
734
  warnings?: string[];
881
- noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
882
- } {
883
- const { fileLines, firstChangedLine, warnings, noopEdits } = params;
884
- return {
885
- lines: fileLines.join("\n"),
886
- firstChangedLine,
887
- ...(warnings.length > 0 ? { warnings } : {}),
888
- ...(noopEdits.length > 0 ? { noopEdits } : {}),
889
- };
735
+ noopEdits?: HashlineNoopEdit[];
890
736
  }
891
737
 
892
- function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
893
- const mismatches: HashMismatch[] = [];
894
- for (const edit of edits) {
895
- switch (edit.op) {
896
- case "replace_line":
897
- validateHashlineRef(edit.pos);
898
- break;
899
- case "replace_range":
900
- validateHashlineRef(edit.pos);
901
- validateHashlineRef(edit.end);
902
- if (edit.pos.line > edit.end.line) {
903
- throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
904
- }
905
- break;
906
- case "append_at":
907
- case "prepend_at":
908
- validateHashlineRef(edit.pos);
909
- ensureHashlineEditHasContent(edit);
910
- break;
911
- case "append_file":
912
- case "prepend_file":
913
- ensureHashlineEditHasContent(edit);
914
- break;
915
- }
916
- }
917
- return mismatches;
738
+ interface HashlineNoopEdit {
739
+ editIndex: number;
740
+ loc: string;
741
+ reason: string;
742
+ current: string;
743
+ }
918
744
 
919
- function validateHashlineRef(ref: { line: number; hash: string }): void {
920
- if (ref.line < 1 || ref.line > fileLines.length) {
921
- throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
922
- }
923
- const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
924
- if (actualHash === ref.hash) {
925
- return;
926
- }
927
- const rebased = tryRebaseAnchor(ref, fileLines);
928
- if (rebased !== null) {
929
- const original = `${ref.line}${ref.hash}`;
930
- ref.line = rebased;
931
- warnings.push(
932
- `Auto-rebased anchor ${original} → ${rebased}${ref.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
933
- );
934
- return;
935
- }
936
- mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
937
- }
745
+ type HashlineLineOrigin = "original" | "insert" | "replacement";
746
+
747
+ interface IndexedEdit {
748
+ edit: HashlineEdit;
749
+ idx: number;
750
+ }
751
+
752
+ function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
753
+ if (edit.kind === "delete") return [edit.anchor];
754
+ if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
755
+ if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
756
+ return [];
938
757
  }
939
- // ═══════════════════════════════════════════════════════════════════════════
940
- // Edit Application
941
- // ═══════════════════════════════════════════════════════════════════════════
942
758
 
943
759
  /**
944
- * Apply an array of hashline edits to file content.
945
- *
946
- * Each edit operation identifies target lines directly (`replace`,
947
- * `append`, `prepend`). Line references are resolved via {@link parseTag}
948
- * and hashes validated before any mutation.
949
- *
950
- * Edits are sorted bottom-up (highest effective line first) so earlier
951
- * splices don't invalidate later line numbers.
952
- *
953
- * @returns The modified content and the 1-indexed first changed line number
760
+ * Verify every anchor's hash, attempting a small ±window rebase before
761
+ * reporting a mismatch. Mutates anchors in place when rebased. Also detects
762
+ * ambiguous cases where two edits target the same line via different anchors,
763
+ * one of which had to be rebased (treated as a mismatch).
954
764
  */
955
- export function applyHashlineEdits(
956
- text: string,
957
- edits: HashlineEdit[],
958
- ): {
959
- lines: string;
960
- firstChangedLine: number | undefined;
961
- warnings?: string[];
962
- noopEdits?: Array<{ editIndex: number; loc: string; current: string }>;
963
- } {
964
- if (edits.length === 0) {
965
- return { lines: text, firstChangedLine: undefined };
966
- }
967
-
968
- const fileLines = text.split("\n");
969
- const originalFileLines = [...fileLines];
970
- let firstChangedLine: number | undefined;
971
- const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
972
- const warnings: string[] = [];
765
+ function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
766
+ const mismatches: HashMismatch[] = [];
767
+ const rebasedAnchors = new Map<Anchor, HashMismatch>();
768
+ const emittedRebaseKeys = new Set<string>();
973
769
 
974
- const mismatches = validateHashlineEditRefs(edits, fileLines, warnings);
975
- if (mismatches.length > 0) {
976
- throw new HashlineMismatchError(mismatches, fileLines);
977
- }
978
770
  for (const edit of edits) {
979
- collectBoundaryDuplicationWarning(edit, originalFileLines, warnings);
980
- }
981
- dedupeHashlineEdits(edits);
982
-
983
- const annotated = edits
984
- .map((edit, idx) => {
985
- const { sortLine, precedence } = getHashlineEditSortKey(edit, fileLines.length);
986
- return { edit, idx, sortLine, precedence };
987
- })
988
- .sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
989
-
990
- for (const { edit, idx } of annotated) {
991
- applyHashlineEditToLines(edit, fileLines, originalFileLines, idx, noopEdits, trackFirstChanged);
771
+ for (const anchor of getHashlineEditAnchors(edit)) {
772
+ if (anchor.line < 1 || anchor.line > fileLines.length) {
773
+ throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
774
+ }
775
+ if (anchor.hash === RANGE_INTERIOR_HASH) continue;
776
+
777
+ const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
778
+ if (actualHash === anchor.hash) continue;
779
+
780
+ const rebased = tryRebaseAnchor(anchor, fileLines);
781
+ if (rebased !== null) {
782
+ const original = `${anchor.line}${anchor.hash}`;
783
+ rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
784
+ anchor.line = rebased;
785
+ const rebaseKey = `${original}→${rebased}${anchor.hash}`;
786
+ if (!emittedRebaseKeys.has(rebaseKey)) {
787
+ emittedRebaseKeys.add(rebaseKey);
788
+ warnings.push(
789
+ `Auto-rebased anchor ${original} → ${rebased}${anchor.hash} ` +
790
+ `(line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
791
+ );
792
+ }
793
+ continue;
794
+ }
795
+ mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
796
+ }
992
797
  }
993
798
 
994
- return buildHashlineEditResult({ fileLines, firstChangedLine, warnings, noopEdits });
995
-
996
- function trackFirstChanged(line: number): void {
997
- if (firstChangedLine === undefined || line < firstChangedLine) {
998
- firstChangedLine = line;
799
+ // Detect collisions: two delete edits resolving to the same line, where at
800
+ // least one had to be rebased — that's likely the rebase landing on the
801
+ // wrong row, so surface the original mismatch.
802
+ const seenLines = new Map<number, Anchor>();
803
+ for (const edit of edits) {
804
+ if (edit.kind !== "delete") continue;
805
+ const existing = seenLines.get(edit.anchor.line);
806
+ if (existing) {
807
+ const rebasedA = rebasedAnchors.get(edit.anchor);
808
+ const rebasedB = rebasedAnchors.get(existing);
809
+ if (rebasedA) mismatches.push(rebasedA);
810
+ else if (rebasedB) mismatches.push(rebasedB);
811
+ continue;
999
812
  }
813
+ seenLines.set(edit.anchor.line, edit.anchor);
1000
814
  }
1001
- }
1002
815
 
1003
- export interface CompactHashlineDiffPreview {
1004
- preview: string;
1005
- addedLines: number;
1006
- removedLines: number;
816
+ return mismatches;
1007
817
  }
1008
818
 
1009
- export interface CompactHashlineDiffOptions {
1010
- /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
1011
- maxUnchangedRun?: number;
819
+ function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
820
+ if (lines.length === 0) return;
821
+ const origins = lines.map((): HashlineLineOrigin => "insert");
822
+ if (fileLines.length === 1 && fileLines[0] === "") {
823
+ fileLines.splice(0, 1, ...lines);
824
+ lineOrigins.splice(0, 1, ...origins);
825
+ return;
826
+ }
827
+ fileLines.splice(0, 0, ...lines);
828
+ lineOrigins.splice(0, 0, ...origins);
1012
829
  }
1013
830
 
1014
- const NUMBERED_DIFF_LINE_RE = /^([ +-])(\s*\d+)\|(.*)$/;
1015
- const HASHLINE_PREVIEW_PLACEHOLDER = " ";
1016
- const ELLIPSIS = "...";
1017
-
1018
- type DiffEntryKind = " " | "+" | "-" | "*";
1019
- type RunKind = DiffEntryKind | "meta";
1020
-
1021
- interface DiffEntry {
1022
- kind: DiffEntryKind;
1023
- oldLine: number;
1024
- newLine: number;
1025
- content: string;
831
+ function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined {
832
+ if (lines.length === 0) return undefined;
833
+ const origins = lines.map((): HashlineLineOrigin => "insert");
834
+ if (fileLines.length === 1 && fileLines[0] === "") {
835
+ fileLines.splice(0, 1, ...lines);
836
+ lineOrigins.splice(0, 1, ...origins);
837
+ return 1;
838
+ }
839
+ const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
840
+ const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
841
+ fileLines.splice(insertIndex, 0, ...lines);
842
+ lineOrigins.splice(insertIndex, 0, ...origins);
843
+ return insertIndex + 1;
1026
844
  }
1027
845
 
1028
- interface MetaEntry {
1029
- kind: "meta";
1030
- raw: string;
846
+ /** Bucket edits by the line they target so we can apply each line's group in one splice. */
847
+ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
848
+ const byLine = new Map<number, IndexedEdit[]>();
849
+ for (const entry of edits) {
850
+ const line =
851
+ entry.edit.kind === "delete"
852
+ ? entry.edit.anchor.line
853
+ : entry.edit.cursor.kind === "before_anchor"
854
+ ? entry.edit.cursor.anchor.line
855
+ : 0;
856
+ const bucket = byLine.get(line);
857
+ if (bucket) bucket.push(entry);
858
+ else byLine.set(line, [entry]);
859
+ }
860
+ return byLine;
1031
861
  }
1032
862
 
1033
- type Entry = DiffEntry | MetaEntry;
863
+ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): HashlineApplyResult {
864
+ if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
1034
865
 
1035
- interface Run {
1036
- kind: RunKind;
1037
- entries: Entry[];
1038
- }
866
+ const fileLines = text.split("\n");
867
+ const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original");
868
+ const warnings: string[] = [];
1039
869
 
1040
- interface ParsedNumberedDiffLine {
1041
- kind: " " | "+" | "-";
1042
- lineNumber: number;
1043
- content: string;
1044
- }
870
+ let firstChangedLine: number | undefined;
871
+ const trackFirstChanged = (line: number) => {
872
+ if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
873
+ };
1045
874
 
1046
- interface CompactPreviewCounters {
1047
- oldLine?: number;
1048
- newLine?: number;
1049
- }
875
+ const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
876
+ if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
1050
877
 
1051
- function parseNumberedDiffLine(line: string): ParsedNumberedDiffLine | undefined {
1052
- const match = NUMBERED_DIFF_LINE_RE.exec(line);
1053
- if (!match) return undefined;
878
+ // Normalize after_anchor inserts to before_anchor of the next line, or EOF
879
+ // when the anchor is the final line. This keeps the bucketing logic below
880
+ // (which only knows about before_anchor / bof / eof) untouched.
881
+ for (const edit of edits) {
882
+ if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
883
+ const anchorLine = edit.cursor.anchor.line;
884
+ if (anchorLine >= fileLines.length) {
885
+ edit.cursor = { kind: "eof" };
886
+ continue;
887
+ }
888
+ const nextLineNum = anchorLine + 1;
889
+ const nextContent = fileLines[nextLineNum - 1] ?? "";
890
+ edit.cursor = {
891
+ kind: "before_anchor",
892
+ anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
893
+ };
894
+ }
1054
895
 
1055
- const kind = match[1];
1056
- if (kind !== " " && kind !== "+" && kind !== "-") return undefined;
896
+ // Partition edits into BOF, EOF, and anchor-targeted buckets.
897
+ const bofLines: string[] = [];
898
+ const eofLines: string[] = [];
899
+ const anchorEdits: IndexedEdit[] = [];
900
+ edits.forEach((edit, idx) => {
901
+ if (edit.kind === "insert" && edit.cursor.kind === "bof") {
902
+ bofLines.push(edit.text);
903
+ } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
904
+ eofLines.push(edit.text);
905
+ } else {
906
+ anchorEdits.push({ edit, idx });
907
+ }
908
+ });
1057
909
 
1058
- const lineNumber = Number(match[2].trim());
1059
- if (!Number.isInteger(lineNumber)) return undefined;
910
+ // Apply per-line buckets bottom-up so earlier indices stay valid.
911
+ const byLine = bucketAnchorEditsByLine(anchorEdits);
912
+ for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
913
+ const bucket = byLine.get(line);
914
+ if (!bucket) continue;
915
+ bucket.sort((a, b) => a.idx - b.idx);
916
+
917
+ const idx = line - 1;
918
+ const currentLine = fileLines[idx] ?? "";
919
+ const beforeLines: string[] = [];
920
+ let deleteLine = false;
921
+
922
+ for (const { edit } of bucket) {
923
+ if (edit.kind === "insert") beforeLines.push(edit.text);
924
+ else deleteLine = true;
925
+ }
926
+ if (beforeLines.length === 0 && !deleteLine) continue;
1060
927
 
1061
- return { kind, lineNumber, content: match[3] };
1062
- }
928
+ const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
929
+ const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
930
+ if (!deleteLine) origins[origins.length - 1] = lineOrigins[idx] ?? "original";
1063
931
 
1064
- function syncOldLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
1065
- if (counters.oldLine === undefined || counters.newLine === undefined) {
1066
- counters.oldLine = lineNumber;
1067
- counters.newLine = lineNumber;
1068
- return;
932
+ fileLines.splice(idx, 1, ...replacement);
933
+ lineOrigins.splice(idx, 1, ...origins);
934
+ trackFirstChanged(line);
1069
935
  }
1070
936
 
1071
- const delta = lineNumber - counters.oldLine;
1072
- counters.oldLine = lineNumber;
1073
- counters.newLine += delta;
1074
- }
1075
-
1076
- function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
1077
- if (counters.oldLine === undefined || counters.newLine === undefined) {
1078
- counters.oldLine = lineNumber;
1079
- counters.newLine = lineNumber;
1080
- return;
937
+ if (bofLines.length > 0) {
938
+ insertAtStart(fileLines, lineOrigins, bofLines);
939
+ trackFirstChanged(1);
1081
940
  }
941
+ const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
942
+ if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
1082
943
 
1083
- const delta = lineNumber - counters.newLine;
1084
- counters.oldLine += delta;
1085
- counters.newLine = lineNumber;
944
+ return {
945
+ lines: fileLines.join("\n"),
946
+ firstChangedLine,
947
+ ...(warnings.length > 0 ? { warnings } : {}),
948
+ };
1086
949
  }
1087
950
 
1088
- /**
1089
- * Parse a unified-diff-with-line-numbers blob into structured entries while
1090
- * tracking both old- and new-file line numbers. `...` markers (emitted by
1091
- * {@link generateDiffString} for collapsed context) sync counters but are
1092
- * preserved as passthrough entries so the original ellipsis remains visible.
1093
- */
1094
- function parseDiffEntries(lines: string[]): Entry[] {
1095
- const entries: Entry[] = [];
1096
- const counters: CompactPreviewCounters = {};
951
+ // ───────────────────────────────────────────────────────────────────────────
952
+ // 12. Input splitting
953
+ //
954
+ // Hashline input may contain multiple file sections, each introduced by a
955
+ // header line of the form `@<path>`. If the input contains recognizable ops
956
+ // but no header, we synthesize one from the caller-supplied `path` option.
957
+ // ───────────────────────────────────────────────────────────────────────────
1097
958
 
1098
- for (const line of lines) {
1099
- const parsed = parseNumberedDiffLine(line);
1100
- if (!parsed) {
1101
- entries.push({ kind: "meta", raw: line });
1102
- continue;
1103
- }
1104
-
1105
- const isEllipsis = parsed.content === ELLIPSIS;
959
+ interface HashlineInputSection {
960
+ path: string;
961
+ diff: string;
962
+ }
1106
963
 
1107
- if (parsed.kind === "+") {
1108
- syncNewLineCounters(counters, parsed.lineNumber);
1109
- const newLine = counters.newLine ?? parsed.lineNumber;
1110
- const oldLine = counters.oldLine ?? parsed.lineNumber;
1111
- entries.push({ kind: "+", oldLine, newLine, content: parsed.content });
1112
- if (!isEllipsis) counters.newLine = newLine + 1;
1113
- continue;
1114
- }
964
+ function unquoteHashlinePath(pathText: string): string {
965
+ if (pathText.length < 2) return pathText;
966
+ const first = pathText[0];
967
+ const last = pathText[pathText.length - 1];
968
+ if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
969
+ return pathText;
970
+ }
1115
971
 
1116
- if (parsed.kind === "-") {
1117
- syncOldLineCounters(counters, parsed.lineNumber);
1118
- const oldLine = parsed.lineNumber;
1119
- const newLine = counters.newLine ?? parsed.lineNumber;
1120
- entries.push({ kind: "-", oldLine, newLine, content: parsed.content });
1121
- if (!isEllipsis) counters.oldLine = oldLine + 1;
1122
- continue;
1123
- }
972
+ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
973
+ const unquoted = unquoteHashlinePath(rawPath.trim());
974
+ if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
975
+ const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
976
+ const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
977
+ return isWithinCwd ? relative || "." : unquoted;
978
+ }
1124
979
 
1125
- // Context line.
1126
- syncOldLineCounters(counters, parsed.lineNumber);
1127
- const oldLine = parsed.lineNumber;
1128
- const newLine = counters.newLine ?? parsed.lineNumber;
1129
- entries.push({ kind: " ", oldLine, newLine, content: parsed.content });
1130
- if (!isEllipsis) {
1131
- counters.oldLine = oldLine + 1;
1132
- counters.newLine = newLine + 1;
1133
- }
980
+ function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
981
+ const trimmed = line.trimEnd();
982
+ if (trimmed === FILE_HEADER_PREFIX) {
983
+ throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
1134
984
  }
985
+ if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
986
+ const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd);
987
+ if (parsedPath.length === 0) {
988
+ throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
989
+ }
990
+ return { path: parsedPath, diff: "" };
991
+ }
1135
992
 
1136
- return entries;
993
+ function stripLeadingBlankLines(input: string): string {
994
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
995
+ const lines = stripped.split("\n");
996
+ while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift();
997
+ return lines.join("\n");
1137
998
  }
1138
999
 
1139
- function groupRuns(entries: Entry[]): Run[] {
1140
- const runs: Run[] = [];
1141
- for (const entry of entries) {
1142
- const prev = runs[runs.length - 1];
1143
- if (prev && prev.kind === entry.kind) {
1144
- prev.entries.push(entry);
1145
- continue;
1146
- }
1147
- runs.push({ kind: entry.kind, entries: [entry] });
1000
+ function containsRecognizableHashlineOperations(input: string): boolean {
1001
+ for (const rawLine of input.split("\n")) {
1002
+ const line = stripTrailingCarriageReturn(rawLine);
1003
+ if (/^[+<=-]\s+/.test(line) || line.startsWith("|")) return true;
1148
1004
  }
1149
- return runs;
1005
+ return false;
1150
1006
  }
1151
1007
 
1152
- /**
1153
- * Collapse adjacent `(-, +)` runs into a single `*` run for paired
1154
- * modifications. The i-th removed line pairs with the i-th added line — in
1155
- * unified-diff convention they replaced each other in place — and is shown as
1156
- * `*<newLine><hash>|<newContent>` instead of two lines `-<old>` + `+<new>`.
1157
- * Surplus removals or additions remain as their own runs after the paired
1158
- * block, preserving the unified-diff `del-then-add` ordering.
1159
- */
1160
- function pairModifications(runs: Run[]): Run[] {
1161
- const isPairable = (entry: Entry): entry is DiffEntry => entry.kind !== "meta" && entry.content !== ELLIPSIS;
1162
-
1163
- const out: Run[] = [];
1164
- for (let i = 0; i < runs.length; i++) {
1165
- const run = runs[i];
1166
- const next = runs[i + 1];
1167
- if (run.kind !== "-" || !next || next.kind !== "+") {
1168
- out.push(run);
1169
- continue;
1170
- }
1171
-
1172
- const dels = run.entries.filter(isPairable);
1173
- const adds = next.entries.filter(isPairable);
1174
- const pairCount = Math.min(dels.length, adds.length);
1175
- if (pairCount === 0) {
1176
- out.push(run);
1177
- continue;
1178
- }
1008
+ function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
1009
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1010
+ const hasExplicitHeader = stripped
1011
+ .split("\n")
1012
+ .some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null);
1013
+ if (hasExplicitHeader) return input;
1014
+
1015
+ if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
1016
+ const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
1017
+ if (fallbackPath.length === 0) return input;
1018
+ return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
1019
+ }
1179
1020
 
1180
- const mods: Entry[] = [];
1181
- for (let p = 0; p < pairCount; p++) {
1182
- mods.push({
1183
- kind: "*",
1184
- oldLine: dels[p].oldLine,
1185
- newLine: adds[p].newLine,
1186
- content: adds[p].content,
1187
- });
1188
- }
1189
- out.push({ kind: "*", entries: mods });
1021
+ export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
1022
+ const [section] = splitHashlineInputs(input, options);
1023
+ return section;
1024
+ }
1190
1025
 
1191
- if (dels.length > pairCount) {
1192
- out.push({ kind: "-", entries: dels.slice(pairCount) });
1193
- }
1194
- if (adds.length > pairCount) {
1195
- out.push({ kind: "+", entries: adds.slice(pairCount) });
1196
- }
1026
+ export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
1027
+ const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
1028
+ const lines = stripped.split("\n");
1029
+ const firstLine = stripTrailingCarriageReturn(lines[0] ?? "");
1197
1030
 
1198
- i++; // consume the `+` run
1031
+ if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
1032
+ const preview = JSON.stringify(firstLine.slice(0, 120));
1033
+ throw new Error(
1034
+ `input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` +
1035
+ `Example: "@src/foo.ts" then edit ops.`,
1036
+ );
1199
1037
  }
1200
- return out;
1201
- }
1202
1038
 
1203
- function formatEntry(entry: Entry): string {
1204
- if (entry.kind === "meta") return entry.raw;
1039
+ const sections: HashlineInputSection[] = [];
1040
+ let currentPath = "";
1041
+ let currentLines: string[] = [];
1205
1042
 
1206
- if (entry.content === ELLIPSIS) {
1207
- // Preserve the `... <line>|...` ellipsis marker emitted by generateDiffString.
1208
- const lineNum = entry.kind === "+" || entry.kind === "*" ? entry.newLine : entry.oldLine;
1209
- const prefix = entry.kind === "*" ? "+" : entry.kind;
1210
- return `${prefix}${lineNum}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${ELLIPSIS}`;
1211
- }
1043
+ const flush = () => {
1044
+ if (currentPath.length === 0) return;
1045
+ sections.push({ path: currentPath, diff: currentLines.join("\n") });
1046
+ currentLines = [];
1047
+ };
1212
1048
 
1213
- switch (entry.kind) {
1214
- case "+":
1215
- return `+${entry.newLine}${computeLineHash(entry.newLine, entry.content)}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1216
- case "-":
1217
- return `-${entry.oldLine}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1218
- case " ":
1219
- return ` ${entry.newLine}${computeLineHash(entry.newLine, entry.content)}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1220
- case "*":
1221
- return `*${entry.newLine}${computeLineHash(entry.newLine, entry.content)}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1049
+ for (const rawLine of lines) {
1050
+ const line = stripTrailingCarriageReturn(rawLine);
1051
+ const header = parseHashlineHeaderLine(line, options.cwd);
1052
+ if (header !== null) {
1053
+ flush();
1054
+ currentPath = header.path;
1055
+ currentLines = [];
1056
+ } else {
1057
+ currentLines.push(rawLine);
1058
+ }
1222
1059
  }
1060
+ flush();
1061
+ return sections;
1223
1062
  }
1224
1063
 
1225
- function collapseUnchangedMiddle(entries: Entry[], maxRun: number): string[] {
1226
- if (entries.length <= maxRun * 2) return entries.map(formatEntry);
1227
- const hidden = entries.length - maxRun * 2;
1228
- return [
1229
- ...entries.slice(0, maxRun).map(formatEntry),
1230
- ` ... ${hidden} more unchanged lines`,
1231
- ...entries.slice(-maxRun).map(formatEntry),
1232
- ];
1233
- }
1064
+ // ───────────────────────────────────────────────────────────────────────────
1065
+ // 13. Diff computation (for streaming preview)
1066
+ // ───────────────────────────────────────────────────────────────────────────
1234
1067
 
1235
- /**
1236
- * Build a compact diff preview suitable for model-visible tool responses.
1237
- *
1238
- * Every changed line — added, removed, or modified — is shown in full. Only
1239
- * unchanged context blocks between or around changes get truncated. Adjacent
1240
- * `-`/`+` pairs are folded into single `*` modification lines so the common
1241
- * 1:1 line-replacement case stays compact.
1242
- */
1243
- export function buildCompactHashlineDiffPreview(
1244
- diff: string,
1245
- options: CompactHashlineDiffOptions = {},
1246
- ): CompactHashlineDiffPreview {
1247
- const maxUnchangedRun = options.maxUnchangedRun ?? 2;
1248
-
1249
- const inputLines = diff.length === 0 ? [] : diff.split("\n");
1250
- const runs = pairModifications(groupRuns(parseDiffEntries(inputLines)));
1251
-
1252
- const out: string[] = [];
1253
- let addedLines = 0;
1254
- let removedLines = 0;
1255
-
1256
- for (let runIndex = 0; runIndex < runs.length; runIndex++) {
1257
- const run = runs[runIndex];
1258
- switch (run.kind) {
1259
- case "meta":
1260
- for (const entry of run.entries) out.push(formatEntry(entry));
1261
- break;
1262
- case "+":
1263
- for (const entry of run.entries) {
1264
- if (entry.kind !== "meta" && entry.content !== ELLIPSIS) addedLines++;
1265
- out.push(formatEntry(entry));
1266
- }
1267
- break;
1268
- case "-":
1269
- for (const entry of run.entries) {
1270
- if (entry.kind !== "meta" && entry.content !== ELLIPSIS) removedLines++;
1271
- out.push(formatEntry(entry));
1272
- }
1273
- break;
1274
- case "*":
1275
- for (const entry of run.entries) {
1276
- addedLines++;
1277
- removedLines++;
1278
- out.push(formatEntry(entry));
1279
- }
1280
- break;
1281
- case " ":
1282
- if (runIndex === 0) {
1283
- out.push(...run.entries.slice(-maxUnchangedRun).map(formatEntry));
1284
- break;
1285
- }
1286
- if (runIndex === runs.length - 1) {
1287
- out.push(...run.entries.slice(0, maxUnchangedRun).map(formatEntry));
1288
- break;
1289
- }
1290
- out.push(...collapseUnchangedMiddle(run.entries, maxUnchangedRun));
1291
- break;
1292
- }
1068
+ async function readHashlineFileText(file: { text(): Promise<string> }, pathText: string): Promise<string> {
1069
+ try {
1070
+ return await file.text();
1071
+ } catch (error) {
1072
+ if (isEnoent(error)) throw new Error(`File not found: ${pathText}`);
1073
+ const message = error instanceof Error ? error.message : String(error);
1074
+ throw new Error(message || `Unable to read ${pathText}`);
1293
1075
  }
1294
-
1295
- return { preview: out.join("\n"), addedLines, removedLines };
1296
1076
  }
1297
1077
 
1298
1078
  export async function computeHashlineDiff(
1299
- input: { path: string; edits: HashlineEditInput[] },
1079
+ input: { input: string; path?: string },
1300
1080
  cwd: string,
1301
- ): Promise<
1302
- | {
1303
- diff: string;
1304
- firstChangedLine: number | undefined;
1305
- }
1306
- | {
1307
- error: string;
1308
- }
1309
- > {
1310
- const { path, edits } = input;
1311
-
1081
+ ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
1312
1082
  try {
1313
- const absolutePath = resolveToCwd(path, cwd);
1314
- const resolvedEdits = resolveHashlineEditsForDiff(edits);
1315
- const file = Bun.file(absolutePath);
1316
-
1317
- const rawContent = await readHashlineFileText(file, path);
1318
-
1319
- const { text: content } = stripBom(rawContent);
1320
- const normalizedContent = normalizeToLF(content);
1321
- const result = applyHashlineEdits(normalizedContent, resolvedEdits);
1322
- if (normalizedContent === result.lines) {
1323
- return { error: `No changes would be made to ${path}. The edits produce identical content.` };
1083
+ const sections = splitHashlineInputs(input.input, { cwd, path: input.path });
1084
+ if (sections.length !== 1) {
1085
+ return { error: "Streaming diff preview supports exactly one hashline section." };
1324
1086
  }
1087
+ const [section] = sections;
1325
1088
 
1326
- return generateDiffString(normalizedContent, result.lines);
1089
+ const absolutePath = resolveToCwd(section.path, cwd);
1090
+ const rawContent = await readHashlineFileText(Bun.file(absolutePath), section.path);
1091
+ const { text: content } = stripBom(rawContent);
1092
+ const normalized = normalizeToLF(content);
1093
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff));
1094
+ if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
1095
+ return generateDiffString(normalized, result.lines);
1327
1096
  } catch (err) {
1328
1097
  return { error: err instanceof Error ? err.message : String(err) };
1329
1098
  }
1330
1099
  }
1331
1100
 
1332
- async function readHashlineFileText(file: BunFile, path: string): Promise<string> {
1101
+ // ───────────────────────────────────────────────────────────────────────────
1102
+ // 14. Execution
1103
+ // ───────────────────────────────────────────────────────────────────────────
1104
+
1105
+ interface ReadHashlineFileResult {
1106
+ exists: boolean;
1107
+ rawContent: string;
1108
+ }
1109
+
1110
+ async function readHashlineFile(absolutePath: string): Promise<ReadHashlineFileResult> {
1333
1111
  try {
1334
- return await file.text();
1112
+ return { exists: true, rawContent: await Bun.file(absolutePath).text() };
1335
1113
  } catch (error) {
1336
- if (isEnoent(error)) {
1337
- throw new Error(`File not found: ${path}`);
1338
- }
1339
- const message = error instanceof Error ? error.message : String(error);
1340
- throw new Error(message || `Unable to read ${path}`);
1114
+ if (isEnoent(error)) return { exists: false, rawContent: "" };
1115
+ throw error;
1341
1116
  }
1342
1117
  }
1343
1118
 
1344
- export async function executeHashlineSingle(
1345
- options: ExecuteHashlineSingleOptions,
1346
- ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1347
- const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
1119
+ function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
1120
+ return edits.some(edit => {
1121
+ if (edit.kind === "delete") return true;
1122
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
1123
+ });
1124
+ }
1348
1125
 
1349
- const contentEdits = edits.filter(e => e.loc != null);
1126
+ function formatNoChangeDiagnostic(pathText: string): string {
1127
+ return `Edits to ${pathText} resulted in no changes being made.`;
1128
+ }
1350
1129
 
1351
- enforcePlanModeWrite(session, path, { op: "update" });
1130
+ function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1131
+ return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1132
+ }
1352
1133
 
1353
- if (path.endsWith(".ipynb") && contentEdits.length > 0) {
1354
- throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
1355
- }
1134
+ function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
1135
+ return result.details ?? { diff: "" };
1136
+ }
1356
1137
 
1357
- const absolutePath = resolvePlanPath(session, path);
1138
+ /**
1139
+ * Run all the front-end checks (notebook guard, parse, plan-mode check, file
1140
+ * load, edit application) without writing. Used to fail fast before applying
1141
+ * any changes in a multi-section batch.
1142
+ */
1143
+ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
1144
+ const { session, path: sectionPath, diff } = options;
1358
1145
 
1359
- const sourceFile = Bun.file(absolutePath);
1360
- const sourceExists = await sourceFile.exists();
1146
+ const absolutePath = resolvePlanPath(session, sectionPath);
1147
+ const { edits } = parseHashlineWithWarnings(diff);
1148
+ enforcePlanModeWrite(session, sectionPath, { op: "update" });
1361
1149
 
1362
- if (!sourceExists) {
1363
- const lines: string[] = [];
1364
- for (const edit of contentEdits) {
1365
- if (edit.loc === "append") {
1366
- lines.push(...hashlineParseText(edit.content));
1367
- } else if (edit.loc === "prepend") {
1368
- lines.unshift(...hashlineParseText(edit.content));
1369
- } else {
1370
- throw new Error(`File not found: ${path}`);
1371
- }
1372
- }
1150
+ const source = await readHashlineFile(absolutePath);
1151
+ if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
1152
+ if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
1373
1153
 
1374
- await Bun.write(absolutePath, lines.join("\n"));
1375
- invalidateFsScanAfterWrite(absolutePath);
1376
- return {
1377
- content: [{ type: "text", text: `Created ${path}` }],
1378
- details: {
1379
- diff: "",
1380
- op: "create",
1381
- meta: outputMeta().get(),
1382
- },
1383
- };
1384
- }
1154
+ const { text } = stripBom(source.rawContent);
1155
+ const normalized = normalizeToLF(text);
1156
+ const result = applyHashlineEdits(normalized, edits);
1157
+ if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
1158
+ }
1159
+
1160
+ async function executeHashlineSection(
1161
+ options: ExecuteHashlineSingleOptions & HashlineInputSection,
1162
+ ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1163
+ const {
1164
+ session,
1165
+ path: sourcePath,
1166
+ diff,
1167
+ signal,
1168
+ batchRequest,
1169
+ writethrough,
1170
+ beginDeferredDiagnosticsForPath,
1171
+ } = options;
1172
+
1173
+ const absolutePath = resolvePlanPath(session, sourcePath);
1174
+ const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
1175
+ enforcePlanModeWrite(session, sourcePath, { op: "update" });
1385
1176
 
1386
- const anchorEdits = resolveEditAnchors(contentEdits);
1387
- const rawContent = await sourceFile.text();
1388
- assertEditableFileContent(rawContent, path);
1177
+ const source = await readHashlineFile(absolutePath);
1178
+ if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
1179
+ if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
1389
1180
 
1390
- const { bom, text } = stripBom(rawContent);
1181
+ const { bom, text } = stripBom(source.rawContent);
1391
1182
  const originalEnding = detectLineEnding(text);
1392
1183
  const originalNormalized = normalizeToLF(text);
1393
- let normalizedText = originalNormalized;
1184
+ const result = applyHashlineEdits(originalNormalized, edits);
1394
1185
 
1395
- const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
1396
- normalizedText = anchorResult.lines;
1397
-
1398
- const result = {
1399
- text: normalizedText,
1400
- firstChangedLine: anchorResult.firstChangedLine,
1401
- warnings: anchorResult.warnings,
1402
- noopEdits: anchorResult.noopEdits,
1403
- };
1404
- if (originalNormalized === result.text) {
1405
- let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
1406
- if (result.noopEdits && result.noopEdits.length > 0) {
1407
- const details = result.noopEdits
1408
- .map(
1409
- edit =>
1410
- `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${edit.current}`,
1411
- )
1412
- .join("\n");
1413
- diagnostic += `\n${details}`;
1414
- if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) {
1415
- const preview = result.noopEdits[0].current.trimEnd();
1416
- if (preview.length > 0) {
1417
- diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`;
1418
- }
1419
- }
1420
- if (result.noopEdits.some(e => e.loc.includes("-"))) {
1421
- diagnostic +=
1422
- "\nHint: a `range` loc replaces the entire span inclusive of both endpoints. " +
1423
- "If your replacement repeats the existing content, narrow the range or change the replacement.";
1424
- }
1425
- }
1426
- throw new Error(diagnostic);
1186
+ if (originalNormalized === result.lines) {
1187
+ return {
1188
+ content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }],
1189
+ details: { diff: "", op: "update", meta: outputMeta().get() },
1190
+ };
1427
1191
  }
1428
1192
 
1429
- const finalContent = bom + restoreLineEndings(result.text, originalEnding);
1193
+ const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
1430
1194
  const diagnostics = await writethrough(
1431
1195
  absolutePath,
1432
1196
  finalContent,
@@ -1437,30 +1201,65 @@ export async function executeHashlineSingle(
1437
1201
  );
1438
1202
  invalidateFsScanAfterWrite(absolutePath);
1439
1203
 
1440
- const diffResult = generateDiffString(originalNormalized, result.text);
1204
+ const diffResult = generateDiffString(originalNormalized, result.lines);
1441
1205
  const meta = outputMeta()
1442
1206
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1443
1207
  .get();
1444
-
1445
- const resultText = `Updated ${path}`;
1446
1208
  const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1447
- const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
1448
- const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
1449
- const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
1209
+
1210
+ const warnings = [...parseWarnings, ...(result.warnings ?? [])];
1211
+ const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
1212
+ const previewBlock = preview.preview ? `\n${preview.preview}` : "";
1213
+ const headline = preview.preview
1214
+ ? `${sourcePath}:`
1215
+ : source.exists
1216
+ ? `Updated ${sourcePath}`
1217
+ : `Created ${sourcePath}`;
1450
1218
 
1451
1219
  return {
1452
- content: [
1453
- {
1454
- type: "text",
1455
- text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
1456
- },
1457
- ],
1220
+ content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
1458
1221
  details: {
1459
1222
  diff: diffResult.diff,
1460
1223
  firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1461
1224
  diagnostics,
1462
- op: "update",
1225
+ op: source.exists ? "update" : "create",
1463
1226
  meta,
1464
1227
  },
1465
1228
  };
1466
1229
  }
1230
+
1231
+ export async function executeHashlineSingle(
1232
+ options: ExecuteHashlineSingleOptions,
1233
+ ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1234
+ const sections = splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path });
1235
+
1236
+ // Fast path: a single section needs no preflight pass.
1237
+ if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
1238
+
1239
+ // Multi-section: validate everything up front so we don't apply a partial batch.
1240
+ for (const section of sections) await preflightHashlineSection({ ...options, ...section });
1241
+
1242
+ const results = [];
1243
+ for (const section of sections) {
1244
+ results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) });
1245
+ }
1246
+
1247
+ return {
1248
+ content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }],
1249
+ details: {
1250
+ diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
1251
+ perFileResults: results.map(({ path: resultPath, result }) => {
1252
+ const details = getEditDetails(result);
1253
+ return {
1254
+ path: resultPath,
1255
+ diff: details.diff,
1256
+ firstChangedLine: details.firstChangedLine,
1257
+ diagnostics: details.diagnostics,
1258
+ op: details.op,
1259
+ move: details.move,
1260
+ meta: details.meta,
1261
+ };
1262
+ }),
1263
+ },
1264
+ };
1265
+ }