@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. package/src/edit/modes/hashline.ts +0 -2039
@@ -1,2039 +0,0 @@
1
- /**
2
- * Hashline edit mode.
3
- *
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 `|`.
7
- *
8
- * The module is organized into the following sections:
9
- *
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)
24
- */
25
-
26
- // ───────────────────────────────────────────────────────────────────────────
27
- // 1. Imports
28
- // ───────────────────────────────────────────────────────────────────────────
29
-
30
- import * as path from "node:path";
31
- import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
32
- import { isEnoent } from "@oh-my-pi/pi-utils";
33
- import { type Static, Type } from "@sinclair/typebox";
34
- import * as Diff from "diff";
35
- import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
36
- import type { ToolSession } from "../../tools";
37
- import { assertEditableFileContent } from "../../tools/auto-generated-guard";
38
- import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
39
- import { outputMeta } from "../../tools/output-meta";
40
- import { resolveToCwd } from "../../tools/path-utils";
41
- import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
42
- import { formatCodeFrameLine } from "../../tools/render-utils";
43
- import { generateDiffString } from "../diff";
44
- import { type FileReadCache, getFileReadCache } from "../file-read-cache";
45
- import {
46
- computeLineHash,
47
- describeAnchorExamples,
48
- formatHashLine,
49
- HL_ANCHOR_RE_RAW,
50
- HL_BODY_SEP,
51
- HL_BODY_SEP_RE_RAW,
52
- HL_EDIT_SEP,
53
- HL_EDIT_SEP_RE_RAW,
54
- HL_HASH_CAPTURE_RE_RAW,
55
- } from "../line-hash";
56
- import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
57
- import { readEditFileText, serializeEditFileText } from "../read-file";
58
- import type { EditToolDetails, LspBatchRequest } from "../renderer";
59
-
60
- // ───────────────────────────────────────────────────────────────────────────
61
- // 2. Public types & schemas
62
- // ───────────────────────────────────────────────────────────────────────────
63
-
64
- export interface HashMismatch {
65
- line: number;
66
- expected: string;
67
- actual: string;
68
- }
69
-
70
- export type Anchor = {
71
- line: number;
72
- hash: string;
73
- contentHint?: string;
74
- };
75
-
76
- type HashlineCursor =
77
- | { kind: "bof" }
78
- | { kind: "eof" }
79
- | { kind: "before_anchor"; anchor: Anchor }
80
- | { kind: "after_anchor"; anchor: Anchor };
81
-
82
- export type HashlineEdit =
83
- | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
84
- | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
85
- | { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number };
86
-
87
- export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
88
- export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
89
-
90
- export interface HashlineStreamOptions {
91
- /** First line number to use when formatting (1-indexed). */
92
- startLine?: number;
93
- /** Maximum formatted lines per yielded chunk (default: 200). */
94
- maxChunkLines?: number;
95
- /** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */
96
- maxChunkBytes?: number;
97
- }
98
-
99
- export interface CompactHashlineDiffPreview {
100
- preview: string;
101
- addedLines: number;
102
- removedLines: number;
103
- }
104
-
105
- export interface CompactHashlineDiffOptions {
106
- /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
107
- maxUnchangedRun?: number;
108
- }
109
- export interface HashlineApplyOptions {
110
- autoDropPureInsertDuplicates?: boolean;
111
- }
112
-
113
- export interface SplitHashlineOptions {
114
- cwd?: string;
115
- path?: string;
116
- }
117
-
118
- export interface ExecuteHashlineSingleOptions {
119
- session: ToolSession;
120
- input: string;
121
- path?: string;
122
- signal?: AbortSignal;
123
- batchRequest?: LspBatchRequest;
124
- writethrough: WritethroughCallback;
125
- beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
126
- }
127
-
128
- // ───────────────────────────────────────────────────────────────────────────
129
- // 3. Constants & shared regexes
130
- // ───────────────────────────────────────────────────────────────────────────
131
-
132
- /** How far either side of an anchor we'll search when auto-rebasing on hash match. */
133
- export const ANCHOR_REBASE_WINDOW = 5;
134
-
135
- /** Lines of context shown either side of a hash mismatch. */
136
- const MISMATCH_CONTEXT = 2;
137
-
138
- /** Filler hash used for the interior of a multi-line range; not validated. */
139
- const RANGE_INTERIOR_HASH = "**";
140
-
141
- /** Header marker introducing a new file section in multi-section input. */
142
- const FILE_HEADER_PREFIX = "@";
143
-
144
- const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW;
145
- const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
146
- const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
147
- const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
148
- const DIFF_PLUS_RE = /^[+](?![+])/;
149
- const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
150
-
151
- const HL_HASH_HINT_RE = /^[a-z]{2}$/i;
152
- const HL_ANCHOR_EXAMPLES = describeAnchorExamples("160");
153
-
154
- const PARSE_TAG_RE = new RegExp(`^${HL_ANCHOR_RE_RAW}`);
155
- const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
156
-
157
- // ───────────────────────────────────────────────────────────────────────────
158
- // 4. Small string utilities
159
- // ───────────────────────────────────────────────────────────────────────────
160
-
161
- function stripTrailingCarriageReturn(line: string): string {
162
- return line.endsWith("\r") ? line.slice(0, -1) : line;
163
- }
164
-
165
- function stripLeadingHashlinePrefixes(line: string): string {
166
- let result = line;
167
- let previous: string;
168
- do {
169
- previous = result;
170
- result = result.replace(HL_PREFIX_RE, "");
171
- } while (result !== previous);
172
- return result;
173
- }
174
-
175
- // ───────────────────────────────────────────────────────────────────────────
176
- // 5. Read-output prefix stripping
177
- //
178
- // When a model echoes back content from a `read` or `search` response, every
179
- // line is prefixed with either a hashline tag (`123ab|`) or, for diff-style
180
- // echoes, a leading `+`. These helpers detect that and recover the raw text.
181
- // ───────────────────────────────────────────────────────────────────────────
182
-
183
- type LinePrefixStats = {
184
- nonEmpty: number;
185
- hashPrefixCount: number;
186
- diffPlusHashPrefixCount: number;
187
- diffPlusCount: number;
188
- truncationNoticeCount: number;
189
- };
190
-
191
- function collectLinePrefixStats(lines: string[]): LinePrefixStats {
192
- const stats: LinePrefixStats = {
193
- nonEmpty: 0,
194
- hashPrefixCount: 0,
195
- diffPlusHashPrefixCount: 0,
196
- diffPlusCount: 0,
197
- truncationNoticeCount: 0,
198
- };
199
-
200
- for (const line of lines) {
201
- if (line.length === 0) continue;
202
- if (READ_TRUNCATION_NOTICE_RE.test(line)) {
203
- stats.truncationNoticeCount++;
204
- continue;
205
- }
206
- stats.nonEmpty++;
207
- if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
208
- if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
209
- if (DIFF_PLUS_RE.test(line)) stats.diffPlusCount++;
210
- }
211
- return stats;
212
- }
213
-
214
- export function stripNewLinePrefixes(lines: string[]): string[] {
215
- const stats = collectLinePrefixStats(lines);
216
- if (stats.nonEmpty === 0) return lines;
217
-
218
- const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty;
219
- const stripPlus =
220
- !stripHash &&
221
- stats.diffPlusHashPrefixCount === 0 &&
222
- stats.diffPlusCount > 0 &&
223
- stats.diffPlusCount >= stats.nonEmpty * 0.5;
224
-
225
- if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
226
-
227
- return lines
228
- .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
229
- .map(line => {
230
- if (stripHash) return stripLeadingHashlinePrefixes(line);
231
- if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
232
- if (stats.diffPlusHashPrefixCount > 0 && HL_PREFIX_PLUS_RE.test(line)) {
233
- return line.replace(HL_PREFIX_RE, "");
234
- }
235
- return line;
236
- });
237
- }
238
-
239
- export function stripHashlinePrefixes(lines: string[]): string[] {
240
- const stats = collectLinePrefixStats(lines);
241
- if (stats.nonEmpty === 0) return lines;
242
- if (stats.hashPrefixCount !== stats.nonEmpty) return lines;
243
- return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
244
- }
245
-
246
- /**
247
- * Normalize line payloads by stripping read/search line prefixes. `null` /
248
- * `undefined` yield `[]`; a single multiline string is split on `\n`.
249
- */
250
- export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
251
- if (edit == null) return [];
252
- if (typeof edit === "string") {
253
- const trimmed = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
254
- edit = trimmed.replaceAll("\r", "").split("\n");
255
- }
256
- return stripNewLinePrefixes(edit);
257
- }
258
-
259
- // ───────────────────────────────────────────────────────────────────────────
260
- // 6. Hashline streaming
261
- //
262
- // Convert a UTF-8 byte stream into a sequence of formatted hashline chunks,
263
- // each capped by line count and byte size.
264
- // ───────────────────────────────────────────────────────────────────────────
265
-
266
- interface ResolvedHashlineStreamOptions {
267
- startLine: number;
268
- maxChunkLines: number;
269
- maxChunkBytes: number;
270
- }
271
-
272
- function resolveHashlineStreamOptions(options: HashlineStreamOptions): ResolvedHashlineStreamOptions {
273
- return {
274
- startLine: options.startLine ?? 1,
275
- maxChunkLines: options.maxChunkLines ?? 200,
276
- maxChunkBytes: options.maxChunkBytes ?? 64 * 1024,
277
- };
278
- }
279
-
280
- interface HashlineChunkEmitter {
281
- pushLine: (line: string) => string[];
282
- flush: () => string | undefined;
283
- }
284
-
285
- function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): HashlineChunkEmitter {
286
- let lineNumber = options.startLine;
287
- let outLines: string[] = [];
288
- let outBytes = 0;
289
-
290
- const flush = (): string | undefined => {
291
- if (outLines.length === 0) return undefined;
292
- const chunk = outLines.join("\n");
293
- outLines = [];
294
- outBytes = 0;
295
- return chunk;
296
- };
297
-
298
- const pushLine = (line: string): string[] => {
299
- const formatted = formatHashLine(lineNumber, line);
300
- lineNumber++;
301
-
302
- const chunks: string[] = [];
303
- const sepBytes = outLines.length === 0 ? 0 : 1;
304
- const lineBytes = Buffer.byteLength(formatted, "utf-8");
305
- const wouldOverflow =
306
- outLines.length >= options.maxChunkLines || outBytes + sepBytes + lineBytes > options.maxChunkBytes;
307
-
308
- if (outLines.length > 0 && wouldOverflow) {
309
- const flushed = flush();
310
- if (flushed) chunks.push(flushed);
311
- }
312
-
313
- outLines.push(formatted);
314
- outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes;
315
-
316
- if (outLines.length >= options.maxChunkLines || outBytes >= options.maxChunkBytes) {
317
- const flushed = flush();
318
- if (flushed) chunks.push(flushed);
319
- }
320
- return chunks;
321
- };
322
-
323
- return { pushLine, flush };
324
- }
325
-
326
- function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
327
- return (
328
- typeof value === "object" &&
329
- value !== null &&
330
- "getReader" in value &&
331
- typeof (value as { getReader?: unknown }).getReader === "function"
332
- );
333
- }
334
-
335
- async function* bytesFromReadableStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
336
- const reader = stream.getReader();
337
- try {
338
- while (true) {
339
- const { done, value } = await reader.read();
340
- if (done) return;
341
- if (value) yield value;
342
- }
343
- } finally {
344
- reader.releaseLock();
345
- }
346
- }
347
-
348
- export async function* streamHashLinesFromUtf8(
349
- source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
350
- options: HashlineStreamOptions = {},
351
- ): AsyncGenerator<string> {
352
- const resolved = resolveHashlineStreamOptions(options);
353
- const decoder = new TextDecoder("utf-8");
354
- const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source;
355
- const emitter = createHashlineChunkEmitter(resolved);
356
-
357
- let pending = "";
358
- let sawAnyLine = false;
359
-
360
- for await (const chunk of chunks) {
361
- pending += decoder.decode(chunk, { stream: true });
362
- let nl = pending.indexOf("\n");
363
- while (nl !== -1) {
364
- const raw = pending.slice(0, nl);
365
- const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
366
- sawAnyLine = true;
367
- for (const out of emitter.pushLine(line)) yield out;
368
- pending = pending.slice(nl + 1);
369
- nl = pending.indexOf("\n");
370
- }
371
- }
372
-
373
- pending += decoder.decode();
374
- if (pending.length > 0) {
375
- sawAnyLine = true;
376
- const tail = pending.endsWith("\r") ? pending.slice(0, -1) : pending;
377
- for (const out of emitter.pushLine(tail)) yield out;
378
- }
379
- if (!sawAnyLine) {
380
- for (const out of emitter.pushLine("")) yield out;
381
- }
382
-
383
- const last = emitter.flush();
384
- if (last) yield last;
385
- }
386
-
387
- // ───────────────────────────────────────────────────────────────────────────
388
- // 7. Anchor parsing & validation
389
- // ───────────────────────────────────────────────────────────────────────────
390
-
391
- export function formatFullAnchorRequirement(raw?: string): string {
392
- const suffix = typeof raw === "string" ? raw.trim() : "";
393
- const hashOnlyHint = HL_HASH_HINT_RE.test(suffix)
394
- ? ` It looks like you supplied only the hash suffix (${JSON.stringify(suffix)}). ` +
395
- `Copy the full anchor exactly as shown (for example, "160${suffix}").`
396
- : "";
397
- const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
398
- return (
399
- `the full anchor exactly as shown by read/search output ` +
400
- `(line number + hash, for example ${HL_ANCHOR_EXAMPLES})${received}${hashOnlyHint}`
401
- );
402
- }
403
-
404
- export function parseTag(ref: string): { line: number; hash: string } {
405
- const match = ref.match(PARSE_TAG_RE);
406
- if (!match) {
407
- throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
408
- }
409
- const line = Number.parseInt(match[1], 10);
410
- if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
411
- return { line, hash: match[2] };
412
- }
413
-
414
- function parseLid(raw: string, lineNum: number): Anchor {
415
- const match = LID_CAPTURE_RE.exec(raw);
416
- if (!match) {
417
- throw new Error(
418
- `line ${lineNum}: expected a full anchor such as ${describeAnchorExamples("119")}; ` +
419
- `got ${JSON.stringify(raw)}.`,
420
- );
421
- }
422
- return { line: Number.parseInt(match[1], 10), hash: match[2] };
423
- }
424
-
425
- interface ParsedRange {
426
- start: Anchor;
427
- end: Anchor;
428
- }
429
-
430
- function parseRange(raw: string, lineNum: number): ParsedRange {
431
- const [startRaw, endRaw] = raw.split("..");
432
- if (!startRaw) throw new Error(`line ${lineNum}: range is missing its first anchor.`);
433
- const start = parseLid(startRaw, lineNum);
434
- const end = endRaw === undefined ? { ...start } : parseLid(endRaw, lineNum);
435
- if (end.line < start.line) {
436
- throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`);
437
- }
438
- if (end.line === start.line && end.hash !== start.hash) {
439
- throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} uses two different hashes for the same line.`);
440
- }
441
- return { start, end };
442
- }
443
-
444
- function expandRange(range: ParsedRange): Anchor[] {
445
- const anchors: Anchor[] = [];
446
- for (let line = range.start.line; line <= range.end.line; line++) {
447
- const hash =
448
- line === range.start.line ? range.start.hash : line === range.end.line ? range.end.hash : RANGE_INTERIOR_HASH;
449
- anchors.push({ line, hash });
450
- }
451
- return anchors;
452
- }
453
-
454
- function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor {
455
- if (raw === "BOF") return { kind: "bof" };
456
- if (raw === "EOF") return { kind: "eof" };
457
- const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
458
- return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
459
- }
460
-
461
- export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
462
- if (ref.line < 1 || ref.line > fileLines.length) {
463
- throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
464
- }
465
- const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1] ?? "");
466
- if (actualHash !== ref.hash) {
467
- throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
468
- }
469
- }
470
-
471
- // ───────────────────────────────────────────────────────────────────────────
472
- // 8. Mismatch error & rebase
473
- // ───────────────────────────────────────────────────────────────────────────
474
-
475
- function getMismatchDisplayLines(mismatches: HashMismatch[], fileLines: string[]): number[] {
476
- const displayLines = new Set<number>();
477
- for (const mismatch of mismatches) {
478
- const lo = Math.max(1, mismatch.line - MISMATCH_CONTEXT);
479
- const hi = Math.min(fileLines.length, mismatch.line + MISMATCH_CONTEXT);
480
- for (let lineNum = lo; lineNum <= hi; lineNum++) displayLines.add(lineNum);
481
- }
482
- return [...displayLines].sort((a, b) => a - b);
483
- }
484
-
485
- export class HashlineMismatchError extends Error {
486
- readonly remaps: ReadonlyMap<string, string>;
487
-
488
- constructor(
489
- public readonly mismatches: HashMismatch[],
490
- public readonly fileLines: string[],
491
- ) {
492
- super(HashlineMismatchError.formatMessage(mismatches, fileLines));
493
- this.name = "HashlineMismatchError";
494
-
495
- const remaps = new Map<string, string>();
496
- for (const mismatch of mismatches) {
497
- const actual = computeLineHash(mismatch.line, fileLines[mismatch.line - 1] ?? "");
498
- remaps.set(`${mismatch.line}${mismatch.expected}`, `${mismatch.line}${actual}`);
499
- }
500
- this.remaps = remaps;
501
- }
502
-
503
- get displayMessage(): string {
504
- return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
505
- }
506
-
507
- private static rejectionHeader(mismatches: HashMismatch[]): string[] {
508
- const noun = mismatches.length > 1 ? "lines have" : "line has";
509
- return [
510
- `Edit rejected: ${mismatches.length} ${noun} changed since the last read (marked *).`,
511
- "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
512
- ];
513
- }
514
-
515
- static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
516
- const mismatchSet = new Set<number>(mismatches.map(m => m.line));
517
- const displayLines = getMismatchDisplayLines(mismatches, fileLines);
518
- const width = displayLines.reduce((cur, n) => Math.max(cur, String(n).length), 0);
519
-
520
- const out = [...HashlineMismatchError.rejectionHeader(mismatches), ""];
521
- let previous = -1;
522
- for (const lineNum of displayLines) {
523
- if (previous !== -1 && lineNum > previous + 1) out.push("...");
524
- previous = lineNum;
525
- const marker = mismatchSet.has(lineNum) ? "*" : " ";
526
- out.push(formatCodeFrameLine(marker, lineNum, fileLines[lineNum - 1] ?? "", width));
527
- }
528
- return out.join("\n");
529
- }
530
-
531
- static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
532
- const mismatchSet = new Set<number>(mismatches.map(m => m.line));
533
- const lines = HashlineMismatchError.rejectionHeader(mismatches);
534
- let previous = -1;
535
- for (const lineNum of getMismatchDisplayLines(mismatches, fileLines)) {
536
- if (previous !== -1 && lineNum > previous + 1) lines.push("...");
537
- previous = lineNum;
538
- const text = fileLines[lineNum - 1] ?? "";
539
- const hash = computeLineHash(lineNum, text);
540
- const marker = mismatchSet.has(lineNum) ? "*" : " ";
541
- lines.push(`${marker}${lineNum}${hash}${HL_BODY_SEP}${text}`);
542
- }
543
- return lines.join("\n");
544
- }
545
- }
546
-
547
- /**
548
- * Try to find a unique line within ±window where the file's actual hash
549
- * matches the anchor's expected hash. Returns the new line number, or `null`
550
- * if zero or multiple candidates were found.
551
- */
552
- export function tryRebaseAnchor(
553
- anchor: { line: number; hash: string },
554
- fileLines: string[],
555
- window: number = ANCHOR_REBASE_WINDOW,
556
- ): number | null {
557
- const lo = Math.max(1, anchor.line - window);
558
- const hi = Math.min(fileLines.length, anchor.line + window);
559
- let found: number | null = null;
560
- for (let lineNum = lo; lineNum <= hi; lineNum++) {
561
- if (computeLineHash(lineNum, fileLines[lineNum - 1] ?? "") !== anchor.hash) continue;
562
- if (found !== null) return null;
563
- found = lineNum;
564
- }
565
- return found;
566
- }
567
-
568
- // ───────────────────────────────────────────────────────────────────────────
569
- // 9. Compact diff preview
570
- // ───────────────────────────────────────────────────────────────────────────
571
-
572
- export function buildCompactHashlineDiffPreview(
573
- diff: string,
574
- _options: CompactHashlineDiffOptions = {},
575
- ): CompactHashlineDiffPreview {
576
- const lines = diff.length === 0 ? [] : diff.split("\n");
577
- let addedLines = 0;
578
- let removedLines = 0;
579
-
580
- // `generateDiffString` numbers `+` lines with the post-edit line number,
581
- // `-` lines with the pre-edit line number, and context lines with the
582
- // pre-edit line number. To emit fresh anchors usable for follow-up edits,
583
- // we convert context-line numbers to post-edit positions by tracking the
584
- // running offset (added so far - removed so far) as we walk the diff.
585
- const formatted = lines.map(line => {
586
- const kind = line[0];
587
- if (kind !== "+" && kind !== "-" && kind !== " ") return line;
588
-
589
- const body = line.slice(1);
590
- const sep = body.indexOf("|");
591
- if (sep === -1) return line;
592
-
593
- const lineNumber = Number.parseInt(body.slice(0, sep), 10);
594
- const content = body.slice(sep + 1);
595
-
596
- switch (kind) {
597
- case "+":
598
- addedLines++;
599
- return `+${lineNumber}${computeLineHash(lineNumber, content)}${HL_BODY_SEP}${content}`;
600
- case "-":
601
- removedLines++;
602
- return `-${lineNumber}--${HL_BODY_SEP}${content}`;
603
- default: {
604
- const newLineNumber = lineNumber + addedLines - removedLines;
605
- return ` ${newLineNumber}${computeLineHash(newLineNumber, content)}${HL_BODY_SEP}${content}`;
606
- }
607
- }
608
- });
609
-
610
- return { preview: formatted.join("\n"), addedLines, removedLines };
611
- }
612
-
613
- // ───────────────────────────────────────────────────────────────────────────
614
- // 10. Edit DSL parsing
615
- //
616
- // Grammar (one op per "block"):
617
- // "+ ANCHOR" followed by 1+ "<sep>TEXT" payload lines — insert
618
- // "- A..B" no payload — delete range
619
- // "= A..B" followed by 1+ "<sep>TEXT" payload lines — replace
620
- //
621
- // ANCHOR is `LINE<hash>`, e.g. `160ab`. BOF / EOF are also valid insert targets.
622
- // ───────────────────────────────────────────────────────────────────────────
623
-
624
- const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
625
- const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
626
- const DELETE_OP_RE = /^-\s*(\S+)$/;
627
- const REPLACE_OP_RE = /^=\s*(\S+)$/;
628
- const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
629
- const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
630
-
631
- function cloneCursor(cursor: HashlineCursor): HashlineCursor {
632
- if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
633
- if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
634
- return cursor;
635
- }
636
-
637
- function collectPayload(
638
- lines: string[],
639
- startIndex: number,
640
- opLineNum: number,
641
- requirePayload: boolean,
642
- ): { payload: string[]; nextIndex: number } {
643
- const payload: string[] = [];
644
- let index = startIndex;
645
- while (index < lines.length) {
646
- const line = stripTrailingCarriageReturn(lines[index]);
647
- if (!line.startsWith(HL_EDIT_SEP)) break;
648
- payload.push(line.slice(1));
649
- index++;
650
- }
651
- if (payload.length === 0 && requirePayload) {
652
- throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
653
- }
654
- return { payload, nextIndex: index };
655
- }
656
-
657
- export function parseHashline(diff: string): HashlineEdit[] {
658
- return parseHashlineWithWarnings(diff).edits;
659
- }
660
-
661
- export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
662
- const edits: HashlineEdit[] = [];
663
- const warnings: string[] = [];
664
- const lines = diff.split("\n");
665
- let editIndex = 0;
666
-
667
- const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
668
- edits.push({ kind: "insert", cursor: cloneCursor(cursor), text, lineNum, index: editIndex++ });
669
- };
670
-
671
- for (let i = 0; i < lines.length; ) {
672
- const lineNum = i + 1;
673
- const line = stripTrailingCarriageReturn(lines[i]);
674
-
675
- if (line.trim().length === 0) {
676
- i++;
677
- continue;
678
- }
679
- if (line.startsWith(HL_EDIT_SEP)) {
680
- throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
681
- }
682
-
683
- const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line);
684
- if (inlineBeforeMatch) {
685
- const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum);
686
- edits.push({
687
- kind: "modify",
688
- anchor,
689
- prefix: inlineBeforeMatch[3],
690
- suffix: "",
691
- lineNum,
692
- index: editIndex++,
693
- });
694
- const cursor: HashlineCursor = { kind: "before_anchor", anchor };
695
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
696
- for (const text of payload) pushInsert(cursor, text, lineNum);
697
- i = nextIndex;
698
- continue;
699
- }
700
-
701
- const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line);
702
- if (inlineAfterMatch) {
703
- const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum);
704
- edits.push({
705
- kind: "modify",
706
- anchor,
707
- prefix: "",
708
- suffix: inlineAfterMatch[3],
709
- lineNum,
710
- index: editIndex++,
711
- });
712
- const cursor: HashlineCursor = { kind: "after_anchor", anchor };
713
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
714
- for (const text of payload) pushInsert(cursor, text, lineNum);
715
- i = nextIndex;
716
- continue;
717
- }
718
-
719
- const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
720
- if (insertBeforeMatch) {
721
- const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
722
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
723
- for (const text of payload) pushInsert(cursor, text, lineNum);
724
- i = nextIndex;
725
- continue;
726
- }
727
-
728
- const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
729
- if (insertAfterMatch) {
730
- const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
731
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
732
- for (const text of payload) pushInsert(cursor, text, lineNum);
733
- i = nextIndex;
734
- continue;
735
- }
736
-
737
- const deleteMatch = DELETE_OP_RE.exec(line);
738
- if (deleteMatch) {
739
- for (const anchor of expandRange(parseRange(deleteMatch[1], lineNum))) {
740
- edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
741
- }
742
- i++;
743
- continue;
744
- }
745
-
746
- const replaceMatch = REPLACE_OP_RE.exec(line);
747
- if (replaceMatch) {
748
- const range = parseRange(replaceMatch[1], lineNum);
749
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
750
- // `= A..B` with no payload blanks the range to a single empty line.
751
- const replacement = payload.length === 0 ? [""] : payload;
752
- for (const text of replacement) {
753
- edits.push({
754
- kind: "insert",
755
- cursor: { kind: "before_anchor", anchor: { ...range.start } },
756
- text,
757
- lineNum,
758
- index: editIndex++,
759
- });
760
- }
761
- for (const anchor of expandRange(range)) {
762
- edits.push({ kind: "delete", anchor, lineNum, index: editIndex++ });
763
- }
764
- i = nextIndex;
765
- continue;
766
- }
767
-
768
- throw new Error(
769
- `line ${lineNum}: unrecognized op. Use < ANCHOR (insert before), + ANCHOR (insert after), - A..B (delete), = A..B (replace), or "${HL_EDIT_SEP}TEXT" payload lines. ` +
770
- `Got ${JSON.stringify(line)}.`,
771
- );
772
- }
773
-
774
- return { edits, warnings };
775
- }
776
-
777
- // ───────────────────────────────────────────────────────────────────────────
778
- // 11. Edit application
779
- // ───────────────────────────────────────────────────────────────────────────
780
-
781
- interface HashlineApplyResult {
782
- lines: string;
783
- firstChangedLine?: number;
784
- warnings?: string[];
785
- noopEdits?: HashlineNoopEdit[];
786
- }
787
-
788
- interface HashlineNoopEdit {
789
- editIndex: number;
790
- loc: string;
791
- reason: string;
792
- current: string;
793
- }
794
-
795
- type HashlineLineOrigin = "original" | "insert" | "replacement";
796
-
797
- interface IndexedEdit {
798
- edit: HashlineEdit;
799
- idx: number;
800
- }
801
-
802
- type HashlineDeleteEdit = Extract<HashlineEdit, { kind: "delete" }>;
803
-
804
- interface HashlineReplacementGroup {
805
- startIndex: number;
806
- endIndex: number;
807
- sourceLineNum: number;
808
- replacement: string[];
809
- deletes: HashlineDeleteEdit[];
810
- }
811
-
812
- function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
813
- if (edit.kind === "delete") return [edit.anchor];
814
- if (edit.kind === "modify") return [edit.anchor];
815
- if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
816
- if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
817
- return [];
818
- }
819
-
820
- /**
821
- * Verify every anchor's hash, attempting a small ±window rebase before
822
- * reporting a mismatch. Mutates anchors in place when rebased. Also detects
823
- * ambiguous cases where two edits target the same line via different anchors,
824
- * one of which had to be rebased (treated as a mismatch).
825
- */
826
- function validateHashlineAnchors(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
827
- const mismatches: HashMismatch[] = [];
828
- const rebasedAnchors = new Map<Anchor, HashMismatch>();
829
- const emittedRebaseKeys = new Set<string>();
830
-
831
- for (const edit of edits) {
832
- for (const anchor of getHashlineEditAnchors(edit)) {
833
- if (anchor.line < 1 || anchor.line > fileLines.length) {
834
- throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
835
- }
836
- if (anchor.hash === RANGE_INTERIOR_HASH) continue;
837
-
838
- const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1] ?? "");
839
- if (actualHash === anchor.hash) continue;
840
-
841
- const rebased = tryRebaseAnchor(anchor, fileLines);
842
- if (rebased !== null) {
843
- const original = `${anchor.line}${anchor.hash}`;
844
- rebasedAnchors.set(anchor, { line: anchor.line, expected: anchor.hash, actual: actualHash });
845
- anchor.line = rebased;
846
- const rebaseKey = `${original}→${rebased}${anchor.hash}`;
847
- if (!emittedRebaseKeys.has(rebaseKey)) {
848
- emittedRebaseKeys.add(rebaseKey);
849
- warnings.push(
850
- `Auto-rebased anchor ${original} → ${rebased}${anchor.hash} ` +
851
- `(line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
852
- );
853
- }
854
- continue;
855
- }
856
- mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
857
- }
858
- }
859
-
860
- // Detect collisions: two delete edits resolving to the same line, where at
861
- // least one had to be rebased — that's likely the rebase landing on the
862
- // wrong row, so surface the original mismatch.
863
- const seenLines = new Map<number, Anchor>();
864
- for (const edit of edits) {
865
- if (edit.kind !== "delete") continue;
866
- const existing = seenLines.get(edit.anchor.line);
867
- if (existing) {
868
- const rebasedA = rebasedAnchors.get(edit.anchor);
869
- const rebasedB = rebasedAnchors.get(existing);
870
- if (rebasedA) mismatches.push(rebasedA);
871
- else if (rebasedB) mismatches.push(rebasedB);
872
- continue;
873
- }
874
- seenLines.set(edit.anchor.line, edit.anchor);
875
- }
876
-
877
- return mismatches;
878
- }
879
-
880
- function insertAtStart(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): void {
881
- if (lines.length === 0) return;
882
- const origins = lines.map((): HashlineLineOrigin => "insert");
883
- if (fileLines.length === 1 && fileLines[0] === "") {
884
- fileLines.splice(0, 1, ...lines);
885
- lineOrigins.splice(0, 1, ...origins);
886
- return;
887
- }
888
- fileLines.splice(0, 0, ...lines);
889
- lineOrigins.splice(0, 0, ...origins);
890
- }
891
-
892
- function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lines: string[]): number | undefined {
893
- if (lines.length === 0) return undefined;
894
- const origins = lines.map((): HashlineLineOrigin => "insert");
895
- if (fileLines.length === 1 && fileLines[0] === "") {
896
- fileLines.splice(0, 1, ...lines);
897
- lineOrigins.splice(0, 1, ...origins);
898
- return 1;
899
- }
900
- const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
901
- const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
902
- fileLines.splice(insertIndex, 0, ...lines);
903
- lineOrigins.splice(insertIndex, 0, ...origins);
904
- return insertIndex + 1;
905
- }
906
-
907
- /** Bucket edits by the line they target so we can apply each line's group in one splice. */
908
-
909
- function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
910
- if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line;
911
- if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
912
- return undefined;
913
- }
914
-
915
- function collectAnchorTargetLines(edits: HashlineEdit[]): Set<number> {
916
- const lines = new Set<number>();
917
- for (const edit of edits) {
918
- const line = getAnchorTargetLine(edit);
919
- if (line !== undefined) lines.add(line);
920
- }
921
- return lines;
922
- }
923
-
924
- function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined {
925
- const first = edits[startIndex];
926
- if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
927
-
928
- const sourceLineNum = first.lineNum;
929
- const replacement: string[] = [];
930
- let index = startIndex;
931
- while (index < edits.length) {
932
- const edit = edits[index];
933
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
934
- replacement.push(edit.text);
935
- index++;
936
- }
937
-
938
- const deletes: HashlineDeleteEdit[] = [];
939
- while (index < edits.length) {
940
- const edit = edits[index];
941
- if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
942
- deletes.push(edit);
943
- index++;
944
- }
945
- if (deletes.length === 0) return undefined;
946
-
947
- const startLine = deletes[0].anchor.line;
948
- for (let offset = 0; offset < deletes.length; offset++) {
949
- if (deletes[offset].anchor.line !== startLine + offset) return undefined;
950
- }
951
- const cursorLine = first.cursor.anchor.line;
952
- if (cursorLine !== startLine) return undefined;
953
-
954
- return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
955
- }
956
-
957
- function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
958
- const max = Math.min(replacement.length, startLine - 1);
959
- for (let count = max; count >= 2; count--) {
960
- let matches = true;
961
- for (let offset = 0; offset < count; offset++) {
962
- if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
963
- matches = false;
964
- break;
965
- }
966
- }
967
- if (matches) return count;
968
- }
969
- return 0;
970
- }
971
-
972
- function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
973
- const max = Math.min(replacement.length, fileLines.length - endLine);
974
- for (let count = max; count >= 2; count--) {
975
- let matches = true;
976
- for (let offset = 0; offset < count; offset++) {
977
- if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
978
- matches = false;
979
- break;
980
- }
981
- }
982
- if (matches) return count;
983
- }
984
- return 0;
985
- }
986
-
987
- // Single-line duplicate absorption is limited to structural closing delimiters.
988
- // General one-line context is too easy to delete incorrectly, but duplicated
989
- // `};` / `)` / `]` boundaries usually indicate a replacement range stopped one
990
- // line early and would otherwise produce a syntax error.
991
- const STRUCTURAL_CLOSING_BOUNDARY_RE = /^\s*[\])}]+[;,]?\s*$/;
992
-
993
- function isStructuralClosingBoundaryLine(line: string): boolean {
994
- return STRUCTURAL_CLOSING_BOUNDARY_RE.test(line);
995
- }
996
-
997
- interface DelimiterBalance {
998
- paren: number;
999
- bracket: number;
1000
- brace: number;
1001
- }
1002
-
1003
- const ZERO_DELIMITER_BALANCE: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
1004
-
1005
- /**
1006
- * Naive bracket counter — does NOT skip string/template/comment contents. The
1007
- * single-line structural absorb relies on this being safe-by-asymmetry: the
1008
- * candidate boundary line is constrained by `STRUCTURAL_CLOSING_BOUNDARY_RE`
1009
- * to be pure delimiters, so noise in deleted lines or non-boundary kept payload
1010
- * tends to push `expected !== kept` and biases the heuristic toward NOT
1011
- * absorbing (the safe direction). If we ever extend this to opening boundaries
1012
- * or non-structural single lines, swap this for a real tokenizer.
1013
- */
1014
- function computeDelimiterBalance(lines: string[]): DelimiterBalance {
1015
- const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
1016
- for (const line of lines) {
1017
- for (const char of line) {
1018
- switch (char) {
1019
- case "(":
1020
- balance.paren++;
1021
- break;
1022
- case ")":
1023
- balance.paren--;
1024
- break;
1025
- case "[":
1026
- balance.bracket++;
1027
- break;
1028
- case "]":
1029
- balance.bracket--;
1030
- break;
1031
- case "{":
1032
- balance.brace++;
1033
- break;
1034
- case "}":
1035
- balance.brace--;
1036
- break;
1037
- }
1038
- }
1039
- }
1040
- return balance;
1041
- }
1042
-
1043
- function delimiterBalancesEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
1044
- return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
1045
- }
1046
-
1047
- /**
1048
- * Decides whether the structural-boundary candidate should be dropped: the
1049
- * `keptPayload` (full payload with the boundary line removed) must restore the
1050
- * caller's `expectedBalance`, while the `fullPayload` (boundary line still
1051
- * present) must NOT. For replacements `expectedBalance` is the deleted
1052
- * region's net delimiter balance; for pure inserts it is zero.
1053
- */
1054
- function shouldDropSingleStructuralBoundary(
1055
- fullPayload: string[],
1056
- keptPayload: string[],
1057
- expectedBalance: DelimiterBalance,
1058
- ): boolean {
1059
- return (
1060
- delimiterBalancesEqual(computeDelimiterBalance(keptPayload), expectedBalance) &&
1061
- !delimiterBalancesEqual(computeDelimiterBalance(fullPayload), expectedBalance)
1062
- );
1063
- }
1064
-
1065
- function countMatchingSingleStructuralPrefixBoundary(
1066
- fileLines: string[],
1067
- startLine: number,
1068
- replacement: string[],
1069
- expectedBalance: DelimiterBalance,
1070
- ): number {
1071
- if (replacement.length === 0 || startLine <= 1) return 0;
1072
- const line = replacement[0];
1073
- if (!isStructuralClosingBoundaryLine(line)) return 0;
1074
- if (fileLines[startLine - 2] !== line) return 0;
1075
- return shouldDropSingleStructuralBoundary(replacement, replacement.slice(1), expectedBalance) ? 1 : 0;
1076
- }
1077
-
1078
- function countMatchingSingleStructuralSuffixBoundary(
1079
- fileLines: string[],
1080
- endLine: number,
1081
- replacement: string[],
1082
- expectedBalance: DelimiterBalance,
1083
- ): number {
1084
- if (replacement.length === 0 || endLine >= fileLines.length) return 0;
1085
- const line = replacement[replacement.length - 1];
1086
- if (!isStructuralClosingBoundaryLine(line)) return 0;
1087
- if (fileLines[endLine] !== line) return 0;
1088
- return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
1089
- }
1090
-
1091
- function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
1092
- for (const line of lines) {
1093
- if (externalTargetLines.has(line)) return true;
1094
- }
1095
- return false;
1096
- }
1097
-
1098
- function contiguousRange(start: number, count: number): number[] {
1099
- return Array.from({ length: count }, (_, offset) => start + offset);
1100
- }
1101
-
1102
- function deleteEditForAutoAbsorbedLine(
1103
- line: number,
1104
- sourceLineNum: number,
1105
- index: number,
1106
- fileLines: string[],
1107
- ): HashlineEdit {
1108
- return {
1109
- kind: "delete",
1110
- anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
1111
- lineNum: sourceLineNum,
1112
- index,
1113
- };
1114
- }
1115
-
1116
- interface HashlinePureInsertGroup {
1117
- startIndex: number;
1118
- endIndex: number;
1119
- sourceLineNum: number;
1120
- cursor: HashlineCursor;
1121
- payload: string[];
1122
- }
1123
-
1124
- function cursorMatches(a: HashlineCursor, b: HashlineCursor): boolean {
1125
- if (a.kind !== b.kind) return false;
1126
- if (a.kind === "bof" || a.kind === "eof") return true;
1127
- const aAnchor = (a as { anchor: Anchor }).anchor;
1128
- const bAnchor = (b as { anchor: Anchor }).anchor;
1129
- return aAnchor.line === bAnchor.line && aAnchor.hash === bAnchor.hash;
1130
- }
1131
-
1132
- /**
1133
- * Collects a run of consecutive `insert` edits that all share the same
1134
- * `lineNum` and `cursor`, IFF that run is not immediately followed by a
1135
- * `delete` at the same `lineNum` (which would make it a replacement group
1136
- * instead). Returns the contiguous payload so we can check it for boundary
1137
- * duplicates against the file.
1138
- */
1139
- function findPureInsertGroup(edits: HashlineEdit[], startIndex: number): HashlinePureInsertGroup | undefined {
1140
- const first = edits[startIndex];
1141
- if (first?.kind !== "insert") return undefined;
1142
-
1143
- const sourceLineNum = first.lineNum;
1144
- const cursor = first.cursor;
1145
- const payload: string[] = [];
1146
- let index = startIndex;
1147
- while (index < edits.length) {
1148
- const edit = edits[index];
1149
- if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum) break;
1150
- if (!cursorMatches(edit.cursor, cursor)) break;
1151
- payload.push(edit.text);
1152
- index++;
1153
- }
1154
-
1155
- // If the run is followed by a delete at the same source lineNum, this is a
1156
- // replacement group (handled by absorbReplacement…). Decline.
1157
- if (index < edits.length && edits[index].kind === "delete" && edits[index].lineNum === sourceLineNum) {
1158
- return undefined;
1159
- }
1160
-
1161
- return { startIndex, endIndex: index - 1, sourceLineNum, cursor, payload };
1162
- }
1163
-
1164
- /**
1165
- * For a pure-insert group, locate the file region adjacent to the insertion
1166
- * point. Returns 0-indexed bounds:
1167
- * - `aboveEndIdx`: index of the last file line strictly above the insertion
1168
- * point (-1 if none).
1169
- * - `belowStartIdx`: index of the first file line strictly below the
1170
- * insertion point (`fileLines.length` if none).
1171
- */
1172
- function pureInsertNeighborhood(
1173
- cursor: HashlineCursor,
1174
- fileLines: string[],
1175
- ): { aboveEndIdx: number; belowStartIdx: number } {
1176
- if (cursor.kind === "bof") return { aboveEndIdx: -1, belowStartIdx: 0 };
1177
- if (cursor.kind === "eof") return { aboveEndIdx: fileLines.length - 1, belowStartIdx: fileLines.length };
1178
- if (cursor.kind === "before_anchor") {
1179
- return { aboveEndIdx: cursor.anchor.line - 2, belowStartIdx: cursor.anchor.line - 1 };
1180
- }
1181
- // after_anchor
1182
- return { aboveEndIdx: cursor.anchor.line - 1, belowStartIdx: cursor.anchor.line };
1183
- }
1184
-
1185
- interface PureInsertAbsorbResult {
1186
- keptPayload: string[];
1187
- absorbedLeading: number;
1188
- absorbedTrailing: number;
1189
- leadingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1190
- trailingFileRange?: { start: number; end: number }; // 1-indexed inclusive
1191
- }
1192
-
1193
- /**
1194
- * Mirror of replacement-absorb's prefix/suffix block check, but for pure
1195
- * inserts: drop payload lines that exactly duplicate the file lines
1196
- * immediately above (leading) or immediately below (trailing) the insertion
1197
- * point. Generic context echo absorption requires a minimum run of 2, but a
1198
- * single structural closing delimiter is absorbed because duplicated `}` /
1199
- * `});`-style boundaries almost always mean the insert included adjacent
1200
- * context.
1201
- */
1202
- function tryAbsorbPureInsertGroup(
1203
- group: HashlinePureInsertGroup,
1204
- fileLines: string[],
1205
- allowGenericBoundaryAbsorb: boolean,
1206
- ): PureInsertAbsorbResult {
1207
- const empty: PureInsertAbsorbResult = { keptPayload: group.payload, absorbedLeading: 0, absorbedTrailing: 0 };
1208
- if (group.payload.length === 0) return empty;
1209
-
1210
- const { aboveEndIdx, belowStartIdx } = pureInsertNeighborhood(group.cursor, fileLines);
1211
-
1212
- // Leading: payload[0..k-1] vs fileLines[aboveEndIdx-k+1 .. aboveEndIdx].
1213
- let absorbedLeading = 0;
1214
- if (allowGenericBoundaryAbsorb) {
1215
- const maxLead = Math.min(group.payload.length, aboveEndIdx + 1);
1216
- for (let count = maxLead; count >= 2; count--) {
1217
- let ok = true;
1218
- for (let offset = 0; offset < count; offset++) {
1219
- if (group.payload[offset] !== fileLines[aboveEndIdx - count + 1 + offset]) {
1220
- ok = false;
1221
- break;
1222
- }
1223
- }
1224
- if (ok) {
1225
- absorbedLeading = count;
1226
- break;
1227
- }
1228
- }
1229
- }
1230
- if (
1231
- absorbedLeading === 0 &&
1232
- group.payload.length > 0 &&
1233
- aboveEndIdx >= 0 &&
1234
- isStructuralClosingBoundaryLine(group.payload[0]) &&
1235
- group.payload[0] === fileLines[aboveEndIdx] &&
1236
- shouldDropSingleStructuralBoundary(group.payload, group.payload.slice(1), ZERO_DELIMITER_BALANCE)
1237
- ) {
1238
- absorbedLeading = 1;
1239
- }
1240
-
1241
- // Trailing: payload[len-k..len-1] vs fileLines[belowStartIdx..belowStartIdx+k-1].
1242
- // Don't double-count payload lines already absorbed as leading.
1243
- let absorbedTrailing = 0;
1244
- const remainingPayload = group.payload.slice(absorbedLeading);
1245
- const remaining = remainingPayload.length;
1246
- if (allowGenericBoundaryAbsorb) {
1247
- const maxTrail = Math.min(remaining, fileLines.length - belowStartIdx);
1248
- for (let count = maxTrail; count >= 2; count--) {
1249
- let ok = true;
1250
- for (let offset = 0; offset < count; offset++) {
1251
- if (group.payload[group.payload.length - count + offset] !== fileLines[belowStartIdx + offset]) {
1252
- ok = false;
1253
- break;
1254
- }
1255
- }
1256
- if (ok) {
1257
- absorbedTrailing = count;
1258
- break;
1259
- }
1260
- }
1261
- }
1262
- if (
1263
- absorbedTrailing === 0 &&
1264
- remaining > 0 &&
1265
- belowStartIdx < fileLines.length &&
1266
- isStructuralClosingBoundaryLine(remainingPayload[remainingPayload.length - 1]) &&
1267
- remainingPayload[remainingPayload.length - 1] === fileLines[belowStartIdx] &&
1268
- shouldDropSingleStructuralBoundary(remainingPayload, remainingPayload.slice(0, -1), ZERO_DELIMITER_BALANCE)
1269
- ) {
1270
- absorbedTrailing = 1;
1271
- }
1272
-
1273
- if (absorbedLeading === 0 && absorbedTrailing === 0) return empty;
1274
-
1275
- return {
1276
- keptPayload: group.payload.slice(absorbedLeading, group.payload.length - absorbedTrailing),
1277
- absorbedLeading,
1278
- absorbedTrailing,
1279
- leadingFileRange:
1280
- absorbedLeading > 0 ? { start: aboveEndIdx - absorbedLeading + 2, end: aboveEndIdx + 1 } : undefined,
1281
- trailingFileRange:
1282
- absorbedTrailing > 0 ? { start: belowStartIdx + 1, end: belowStartIdx + absorbedTrailing } : undefined,
1283
- };
1284
- }
1285
-
1286
- function absorbReplacementBoundaryDuplicates(
1287
- edits: HashlineEdit[],
1288
- fileLines: string[],
1289
- warnings: string[],
1290
- options: HashlineApplyOptions,
1291
- ): HashlineEdit[] {
1292
- let nextSyntheticIndex = edits.length;
1293
- const absorbed: HashlineEdit[] = [];
1294
-
1295
- // Anchor targets are stable across the loop because we only ever append
1296
- // synthetic deletes (never mutate originals). A line in this set that
1297
- // falls outside the current group's range is necessarily owned by another
1298
- // op, so absorbing it would silently steal its target.
1299
- const allTargetLines = collectAnchorTargetLines(edits);
1300
- const emittedAbsorbKeys = new Set<string>();
1301
-
1302
- for (let index = 0; index < edits.length; index++) {
1303
- const group = findReplacementGroup(edits, index);
1304
- if (!group) {
1305
- const pureInsert = findPureInsertGroup(edits, index);
1306
- if (pureInsert) {
1307
- const result = tryAbsorbPureInsertGroup(
1308
- pureInsert,
1309
- fileLines,
1310
- options.autoDropPureInsertDuplicates === true,
1311
- );
1312
- if (result.absorbedLeading > 0 || result.absorbedTrailing > 0) {
1313
- if (result.leadingFileRange) {
1314
- const { start, end } = result.leadingFileRange;
1315
- const key = `pure-insert-leading:${start}..${end}`;
1316
- if (!emittedAbsorbKeys.has(key)) {
1317
- emittedAbsorbKeys.add(key);
1318
- warnings.push(
1319
- `Auto-dropped ${result.absorbedLeading} duplicate line(s) at the start of insert at line ${pureInsert.sourceLineNum} ` +
1320
- `(file lines ${start}..${end} already match the payload's leading lines).`,
1321
- );
1322
- }
1323
- }
1324
- if (result.trailingFileRange) {
1325
- const { start, end } = result.trailingFileRange;
1326
- const key = `pure-insert-trailing:${start}..${end}`;
1327
- if (!emittedAbsorbKeys.has(key)) {
1328
- emittedAbsorbKeys.add(key);
1329
- warnings.push(
1330
- `Auto-dropped ${result.absorbedTrailing} duplicate line(s) at the end of insert at line ${pureInsert.sourceLineNum} ` +
1331
- `(file lines ${start}..${end} already match the payload's trailing lines).`,
1332
- );
1333
- }
1334
- }
1335
- for (const text of result.keptPayload) {
1336
- absorbed.push({
1337
- kind: "insert",
1338
- cursor: cloneCursor(pureInsert.cursor),
1339
- text,
1340
- lineNum: pureInsert.sourceLineNum,
1341
- index: nextSyntheticIndex++,
1342
- });
1343
- }
1344
- index = pureInsert.endIndex;
1345
- continue;
1346
- }
1347
- for (let groupIndex = pureInsert.startIndex; groupIndex <= pureInsert.endIndex; groupIndex++) {
1348
- absorbed.push(edits[groupIndex]);
1349
- }
1350
- index = pureInsert.endIndex;
1351
- continue;
1352
- }
1353
- absorbed.push(edits[index]);
1354
- continue;
1355
- }
1356
-
1357
- const startLine = group.deletes[0].anchor.line;
1358
- const endLine = group.deletes[group.deletes.length - 1].anchor.line;
1359
-
1360
- const deletedBalance = computeDelimiterBalance(
1361
- group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
1362
- );
1363
- const prefixCount =
1364
- countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
1365
- countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance);
1366
- const suffixCount =
1367
- countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
1368
- countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance);
1369
- const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
1370
- const suffixLines = contiguousRange(endLine + 1, suffixCount);
1371
- const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
1372
- const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
1373
-
1374
- if (safePrefixCount > 0) {
1375
- const absorbStart = startLine - safePrefixCount;
1376
- const key = `prefix:${absorbStart}..${startLine - 1}`;
1377
- if (!emittedAbsorbKeys.has(key)) {
1378
- emittedAbsorbKeys.add(key);
1379
- warnings.push(
1380
- `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
1381
- `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
1382
- `widened the deletion to absorb them).`,
1383
- );
1384
- }
1385
- }
1386
- if (safeSuffixCount > 0) {
1387
- const absorbEnd = endLine + safeSuffixCount;
1388
- const key = `suffix:${endLine + 1}..${absorbEnd}`;
1389
- if (!emittedAbsorbKeys.has(key)) {
1390
- emittedAbsorbKeys.add(key);
1391
- warnings.push(
1392
- `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
1393
- `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
1394
- `widened the deletion to absorb them).`,
1395
- );
1396
- }
1397
- }
1398
-
1399
- for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
1400
- absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
1401
- }
1402
- for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
1403
- absorbed.push(edits[groupIndex]);
1404
- }
1405
- for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
1406
- absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
1407
- }
1408
-
1409
- index = group.endIndex;
1410
- }
1411
-
1412
- return absorbed;
1413
- }
1414
-
1415
- function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
1416
- const byLine = new Map<number, IndexedEdit[]>();
1417
- for (const entry of edits) {
1418
- const line =
1419
- entry.edit.kind === "delete"
1420
- ? entry.edit.anchor.line
1421
- : entry.edit.kind === "modify"
1422
- ? entry.edit.anchor.line
1423
- : entry.edit.cursor.kind === "before_anchor"
1424
- ? entry.edit.cursor.anchor.line
1425
- : 0;
1426
- const bucket = byLine.get(line);
1427
- if (bucket) bucket.push(entry);
1428
- else byLine.set(line, [entry]);
1429
- }
1430
- return byLine;
1431
- }
1432
-
1433
- export function applyHashlineEdits(
1434
- text: string,
1435
- edits: HashlineEdit[],
1436
- options: HashlineApplyOptions = {},
1437
- ): HashlineApplyResult {
1438
- if (edits.length === 0) return { lines: text, firstChangedLine: undefined };
1439
-
1440
- const fileLines = text.split("\n");
1441
- const lineOrigins: HashlineLineOrigin[] = fileLines.map(() => "original");
1442
- const warnings: string[] = [];
1443
-
1444
- let firstChangedLine: number | undefined;
1445
- const trackFirstChanged = (line: number) => {
1446
- if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
1447
- };
1448
-
1449
- const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
1450
- if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
1451
-
1452
- const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings, options);
1453
-
1454
- // Normalize after_anchor inserts to before_anchor of the next line, or EOF
1455
- // when the anchor is the final line. This keeps the bucketing logic below
1456
- // (which only knows about before_anchor / bof / eof) untouched.
1457
- for (const edit of normalizedEdits) {
1458
- if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
1459
- const anchorLine = edit.cursor.anchor.line;
1460
- if (anchorLine >= fileLines.length) {
1461
- edit.cursor = { kind: "eof" };
1462
- continue;
1463
- }
1464
- const nextLineNum = anchorLine + 1;
1465
- const nextContent = fileLines[nextLineNum - 1] ?? "";
1466
- edit.cursor = {
1467
- kind: "before_anchor",
1468
- anchor: { line: nextLineNum, hash: computeLineHash(nextLineNum, nextContent) },
1469
- };
1470
- }
1471
-
1472
- // Partition edits into BOF, EOF, and anchor-targeted buckets.
1473
- const bofLines: string[] = [];
1474
- const eofLines: string[] = [];
1475
- const anchorEdits: IndexedEdit[] = [];
1476
- normalizedEdits.forEach((edit, idx) => {
1477
- if (edit.kind === "insert" && edit.cursor.kind === "bof") {
1478
- bofLines.push(edit.text);
1479
- } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
1480
- eofLines.push(edit.text);
1481
- } else {
1482
- anchorEdits.push({ edit, idx });
1483
- }
1484
- });
1485
-
1486
- // Apply per-line buckets bottom-up so earlier indices stay valid.
1487
- const byLine = bucketAnchorEditsByLine(anchorEdits);
1488
- for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
1489
- const bucket = byLine.get(line);
1490
- if (!bucket) continue;
1491
- bucket.sort((a, b) => a.idx - b.idx);
1492
-
1493
- const idx = line - 1;
1494
- const currentLine = fileLines[idx] ?? "";
1495
- const beforeLines: string[] = [];
1496
- let deleteLine = false;
1497
- let prefix = "";
1498
- let suffix = "";
1499
- let modified = false;
1500
-
1501
- for (const { edit } of bucket) {
1502
- if (edit.kind === "insert") {
1503
- beforeLines.push(edit.text);
1504
- } else if (edit.kind === "delete") {
1505
- deleteLine = true;
1506
- } else if (edit.kind === "modify") {
1507
- prefix = edit.prefix + prefix;
1508
- suffix = suffix + edit.suffix;
1509
- modified = true;
1510
- }
1511
- }
1512
- if (beforeLines.length === 0 && !deleteLine && !modified) continue;
1513
- if (deleteLine && modified) {
1514
- throw new Error(
1515
- `line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`,
1516
- );
1517
- }
1518
-
1519
- const effectiveLine = modified ? prefix + currentLine + suffix : currentLine;
1520
- const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine];
1521
- const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
1522
- if (!deleteLine) {
1523
- origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original");
1524
- }
1525
-
1526
- fileLines.splice(idx, 1, ...replacement);
1527
- lineOrigins.splice(idx, 1, ...origins);
1528
- trackFirstChanged(line);
1529
- }
1530
-
1531
- if (bofLines.length > 0) {
1532
- insertAtStart(fileLines, lineOrigins, bofLines);
1533
- trackFirstChanged(1);
1534
- }
1535
- const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
1536
- if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
1537
-
1538
- return {
1539
- lines: fileLines.join("\n"),
1540
- firstChangedLine,
1541
- ...(warnings.length > 0 ? { warnings } : {}),
1542
- };
1543
- }
1544
-
1545
- // ───────────────────────────────────────────────────────────────────────────
1546
- // 11b. Anchor-stale recovery via cached read snapshots
1547
- //
1548
- // When `applyHashlineEdits` rejects because some anchors no longer match the
1549
- // current on-disk content, the model may still have authored those anchors
1550
- // against a real, valid version of the file — one that was just rendered to
1551
- // it by the `read` or `search` tool, before something else (a subagent, a
1552
- // linter, the user) modified the file out-of-band.
1553
- //
1554
- // The cache in `file-read-cache.ts` keeps a small LRU snapshot of those
1555
- // rendered lines. We use it to reconstruct that "previous version", re-apply
1556
- // the edits against it, and then 3-way-merge the resulting diff back onto
1557
- // the live file. If the merge cleanly lands, that becomes our output. If
1558
- // it doesn't (or the cache doesn't even cover the failing anchors), we
1559
- // surface the original mismatch error so the model sees the truth.
1560
- // ───────────────────────────────────────────────────────────────────────────
1561
-
1562
- export interface HashlineRecoveryArgs {
1563
- cache: FileReadCache;
1564
- absolutePath: string;
1565
- currentText: string;
1566
- edits: HashlineEdit[];
1567
- options: HashlineApplyOptions;
1568
- }
1569
-
1570
- export interface HashlineRecoveryResult {
1571
- lines: string;
1572
- firstChangedLine: number | undefined;
1573
- warnings: string[];
1574
- }
1575
-
1576
- const HASHLINE_RECOVERY_FUZZ_FACTOR = 3;
1577
-
1578
- const HASHLINE_RECOVERY_WARNING =
1579
- "Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit).";
1580
-
1581
- /**
1582
- * Attempt to recover from a `HashlineMismatchError` by replaying the edits
1583
- * against a cached pre-edit snapshot of the file and 3-way-merging the result
1584
- * onto the current on-disk content. Returns `null` when no recovery is
1585
- * possible — callers should propagate the original mismatch error in that
1586
- * case.
1587
- */
1588
- export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null {
1589
- const { cache, absolutePath, currentText, edits, options } = args;
1590
- const snapshot = cache.get(absolutePath);
1591
- if (!snapshot || snapshot.lines.size === 0) return null;
1592
-
1593
- const overlaid = currentText.split("\n");
1594
- let maxCachedLine = 0;
1595
- for (const lineNum of snapshot.lines.keys()) {
1596
- if (lineNum > maxCachedLine) maxCachedLine = lineNum;
1597
- }
1598
- while (overlaid.length < maxCachedLine) overlaid.push("");
1599
- for (const [lineNum, content] of snapshot.lines) {
1600
- overlaid[lineNum - 1] = content;
1601
- }
1602
- const previousText = overlaid.join("\n");
1603
- if (previousText === currentText) return null;
1604
-
1605
- let applied: HashlineApplyResult;
1606
- try {
1607
- applied = applyHashlineEdits(previousText, edits, options);
1608
- } catch (err) {
1609
- if (err instanceof HashlineMismatchError) return null;
1610
- throw err;
1611
- }
1612
- if (applied.lines === previousText) return null;
1613
-
1614
- const patch = Diff.structuredPatch("file", "file", previousText, applied.lines, "", "", { context: 3 });
1615
- const merged = Diff.applyPatch(currentText, patch, { fuzzFactor: HASHLINE_RECOVERY_FUZZ_FACTOR });
1616
- if (typeof merged !== "string" || merged === currentText) return null;
1617
-
1618
- const mergedDiff = generateDiffString(currentText, merged);
1619
- const recoveryWarnings = [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])];
1620
-
1621
- return {
1622
- lines: merged,
1623
- firstChangedLine: mergedDiff.firstChangedLine ?? applied.firstChangedLine,
1624
- warnings: recoveryWarnings,
1625
- };
1626
- }
1627
-
1628
- // ───────────────────────────────────────────────────────────────────────────
1629
- // 12. Input splitting
1630
- //
1631
- // Hashline input may contain multiple file sections, each introduced by a
1632
- // header line of the form `@<path>`. If the input contains recognizable ops
1633
- // but no header, we synthesize one from the caller-supplied `path` option.
1634
- // ───────────────────────────────────────────────────────────────────────────
1635
-
1636
- export interface HashlineInputSection {
1637
- path: string;
1638
- diff: string;
1639
- }
1640
-
1641
- function unquoteHashlinePath(pathText: string): string {
1642
- if (pathText.length < 2) return pathText;
1643
- const first = pathText[0];
1644
- const last = pathText[pathText.length - 1];
1645
- if ((first === '"' || first === "'") && first === last) return pathText.slice(1, -1);
1646
- return pathText;
1647
- }
1648
-
1649
- function normalizeHashlinePath(rawPath: string, cwd?: string): string {
1650
- const unquoted = unquoteHashlinePath(rawPath.trim());
1651
- if (!cwd || !path.isAbsolute(unquoted)) return unquoted;
1652
- const relative = path.relative(path.resolve(cwd), path.resolve(unquoted));
1653
- const isWithinCwd = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
1654
- return isWithinCwd ? relative || "." : unquoted;
1655
- }
1656
-
1657
- function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
1658
- const trimmed = line.trimEnd();
1659
- if (trimmed === FILE_HEADER_PREFIX) {
1660
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
1661
- }
1662
- if (!trimmed.startsWith(FILE_HEADER_PREFIX)) return null;
1663
- const parsedPath = normalizeHashlinePath(trimmed.slice(1), cwd);
1664
- if (parsedPath.length === 0) {
1665
- throw new Error(`Input header "${FILE_HEADER_PREFIX}" is empty; provide a file path.`);
1666
- }
1667
- return { path: parsedPath, diff: "" };
1668
- }
1669
-
1670
- function stripLeadingBlankLines(input: string): string {
1671
- const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1672
- const lines = stripped.split("\n");
1673
- while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift();
1674
- return lines.join("\n");
1675
- }
1676
-
1677
- export function containsRecognizableHashlineOperations(input: string): boolean {
1678
- for (const rawLine of input.split("\n")) {
1679
- const line = stripTrailingCarriageReturn(rawLine);
1680
- if (/^[+<=-]\s+/.test(line) || line.startsWith(HL_EDIT_SEP)) return true;
1681
- }
1682
- return false;
1683
- }
1684
-
1685
- function normalizeFallbackInput(input: string, options: SplitHashlineOptions): string {
1686
- const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
1687
- const hasExplicitHeader = stripped
1688
- .split("\n")
1689
- .some(rawLine => parseHashlineHeaderLine(stripTrailingCarriageReturn(rawLine), options.cwd) !== null);
1690
- if (hasExplicitHeader) return input;
1691
-
1692
- if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
1693
- const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
1694
- if (fallbackPath.length === 0) return input;
1695
- return `${FILE_HEADER_PREFIX} ${fallbackPath}\n${input}`;
1696
- }
1697
-
1698
- export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
1699
- const [section] = splitHashlineInputs(input, options);
1700
- return section;
1701
- }
1702
-
1703
- export function splitHashlineInputs(input: string, options: SplitHashlineOptions = {}): HashlineInputSection[] {
1704
- const stripped = stripLeadingBlankLines(normalizeFallbackInput(input, options));
1705
- const lines = stripped.split("\n");
1706
- const firstLine = stripTrailingCarriageReturn(lines[0] ?? "");
1707
-
1708
- if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
1709
- const preview = JSON.stringify(firstLine.slice(0, 120));
1710
- throw new Error(
1711
- `input must begin with "@PATH" on the first non-blank line; got: ${preview}. ` +
1712
- `Example: "@src/foo.ts" then edit ops.`,
1713
- );
1714
- }
1715
-
1716
- const sections: HashlineInputSection[] = [];
1717
- let currentPath = "";
1718
- let currentLines: string[] = [];
1719
-
1720
- const flush = () => {
1721
- if (currentPath.length === 0) return;
1722
- sections.push({ path: currentPath, diff: currentLines.join("\n") });
1723
- currentLines = [];
1724
- };
1725
-
1726
- for (const rawLine of lines) {
1727
- const line = stripTrailingCarriageReturn(rawLine);
1728
- const header = parseHashlineHeaderLine(line, options.cwd);
1729
- if (header !== null) {
1730
- flush();
1731
- currentPath = header.path;
1732
- currentLines = [];
1733
- } else {
1734
- currentLines.push(rawLine);
1735
- }
1736
- }
1737
- flush();
1738
- return sections;
1739
- }
1740
-
1741
- // ───────────────────────────────────────────────────────────────────────────
1742
- // 13. Diff computation (for streaming preview)
1743
- // ───────────────────────────────────────────────────────────────────────────
1744
-
1745
- async function readHashlineFileText(
1746
- _file: { text(): Promise<string> },
1747
- absolutePath: string,
1748
- pathText: string,
1749
- ): Promise<string> {
1750
- try {
1751
- return await readEditFileText(absolutePath, pathText);
1752
- } catch (error) {
1753
- const message = error instanceof Error ? error.message : String(error);
1754
- throw new Error(message || `Unable to read ${pathText}`);
1755
- }
1756
- }
1757
-
1758
- export async function computeHashlineSectionDiff(
1759
- section: HashlineInputSection,
1760
- cwd: string,
1761
- options: HashlineApplyOptions = {},
1762
- ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
1763
- try {
1764
- const absolutePath = resolveToCwd(section.path, cwd);
1765
- const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
1766
- const { text: content } = stripBom(rawContent);
1767
- const normalized = normalizeToLF(content);
1768
- const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
1769
- if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
1770
- return generateDiffString(normalized, result.lines);
1771
- } catch (err) {
1772
- return { error: err instanceof Error ? err.message : String(err) };
1773
- }
1774
- }
1775
-
1776
- export async function computeHashlineDiff(
1777
- input: { input: string; path?: string },
1778
- cwd: string,
1779
- options: HashlineApplyOptions = {},
1780
- ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
1781
- let sections: HashlineInputSection[];
1782
- try {
1783
- sections = splitHashlineInputs(input.input, { cwd, path: input.path });
1784
- } catch (err) {
1785
- return { error: err instanceof Error ? err.message : String(err) };
1786
- }
1787
- if (sections.length !== 1) {
1788
- return { error: "Streaming diff preview supports exactly one hashline section." };
1789
- }
1790
- return computeHashlineSectionDiff(sections[0], cwd, options);
1791
- }
1792
-
1793
- // ───────────────────────────────────────────────────────────────────────────
1794
- // 14. Execution
1795
- // ───────────────────────────────────────────────────────────────────────────
1796
-
1797
- interface ReadHashlineFileResult {
1798
- exists: boolean;
1799
- rawContent: string;
1800
- }
1801
-
1802
- async function readHashlineFile(absolutePath: string, pathText: string): Promise<ReadHashlineFileResult> {
1803
- try {
1804
- return { exists: true, rawContent: await readEditFileText(absolutePath, pathText) };
1805
- } catch (error) {
1806
- if (isEnoent(error)) return { exists: false, rawContent: "" };
1807
- if (error instanceof Error && error.message === `File not found: ${pathText}`)
1808
- return { exists: false, rawContent: "" };
1809
- throw error;
1810
- }
1811
- }
1812
-
1813
- function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
1814
- return edits.some(edit => {
1815
- if (edit.kind === "delete") return true;
1816
- if (edit.kind === "modify") return true;
1817
- return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
1818
- });
1819
- }
1820
-
1821
- function formatNoChangeDiagnostic(pathText: string): string {
1822
- return `Edits to ${pathText} resulted in no changes being made.`;
1823
- }
1824
-
1825
- function getHashlineApplyOptions(session: ToolSession): HashlineApplyOptions {
1826
- return {
1827
- autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
1828
- };
1829
- }
1830
-
1831
- function getTextContent(result: AgentToolResult<EditToolDetails>): string {
1832
- return result.content.map(part => (part.type === "text" ? part.text : "")).join("\n");
1833
- }
1834
-
1835
- function getEditDetails(result: AgentToolResult<EditToolDetails>): EditToolDetails {
1836
- return result.details ?? { diff: "" };
1837
- }
1838
-
1839
- /**
1840
- * Apply hashline edits with anchor-stale recovery: on `HashlineMismatchError`,
1841
- * consult the read-snapshot cache for the file and 3-way-merge the edits onto
1842
- * the current text. If recovery succeeds, return the merged result with a
1843
- * synthetic warning. Otherwise re-throw the original mismatch error.
1844
- */
1845
- function applyHashlineEditsWithRecovery(
1846
- session: ToolSession,
1847
- absolutePath: string,
1848
- text: string,
1849
- edits: HashlineEdit[],
1850
- options: HashlineApplyOptions,
1851
- ): HashlineApplyResult {
1852
- try {
1853
- return applyHashlineEdits(text, edits, options);
1854
- } catch (err) {
1855
- if (!(err instanceof HashlineMismatchError)) throw err;
1856
- const recovered = tryRecoverHashlineWithCache({
1857
- cache: getFileReadCache(session),
1858
- absolutePath,
1859
- currentText: text,
1860
- edits,
1861
- options,
1862
- });
1863
- if (!recovered) throw err;
1864
- return {
1865
- lines: recovered.lines,
1866
- firstChangedLine: recovered.firstChangedLine,
1867
- warnings: recovered.warnings,
1868
- };
1869
- }
1870
- }
1871
-
1872
- /**
1873
- * Run all the front-end checks (notebook guard, parse, plan-mode check, file
1874
- * load, edit application) without writing. Used to fail fast before applying
1875
- * any changes in a multi-section batch.
1876
- */
1877
- async function preflightHashlineSection(options: ExecuteHashlineSingleOptions & HashlineInputSection): Promise<void> {
1878
- const { session, path: sectionPath, diff } = options;
1879
-
1880
- const absolutePath = resolvePlanPath(session, sectionPath);
1881
- const { edits } = parseHashlineWithWarnings(diff);
1882
- enforcePlanModeWrite(session, sectionPath, { op: "update" });
1883
-
1884
- const source = await readHashlineFile(absolutePath, sectionPath);
1885
- if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sectionPath}`);
1886
- if (source.exists) assertEditableFileContent(source.rawContent, sectionPath);
1887
-
1888
- const { text } = stripBom(source.rawContent);
1889
- const normalized = normalizeToLF(text);
1890
- const result = applyHashlineEditsWithRecovery(
1891
- session,
1892
- absolutePath,
1893
- normalized,
1894
- edits,
1895
- getHashlineApplyOptions(session),
1896
- );
1897
- if (normalized === result.lines) throw new Error(formatNoChangeDiagnostic(sectionPath));
1898
- }
1899
-
1900
- async function executeHashlineSection(
1901
- options: ExecuteHashlineSingleOptions & HashlineInputSection,
1902
- ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1903
- const {
1904
- session,
1905
- path: sourcePath,
1906
- diff,
1907
- signal,
1908
- batchRequest,
1909
- writethrough,
1910
- beginDeferredDiagnosticsForPath,
1911
- } = options;
1912
-
1913
- const absolutePath = resolvePlanPath(session, sourcePath);
1914
- const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
1915
- enforcePlanModeWrite(session, sourcePath, { op: "update" });
1916
-
1917
- const source = await readHashlineFile(absolutePath, sourcePath);
1918
- if (!source.exists && hasAnchorScopedEdit(edits)) throw new Error(`File not found: ${sourcePath}`);
1919
- if (source.exists) assertEditableFileContent(source.rawContent, sourcePath);
1920
-
1921
- const { bom, text } = stripBom(source.rawContent);
1922
- const originalEnding = detectLineEnding(text);
1923
- const originalNormalized = normalizeToLF(text);
1924
- const result = applyHashlineEditsWithRecovery(
1925
- session,
1926
- absolutePath,
1927
- originalNormalized,
1928
- edits,
1929
- getHashlineApplyOptions(session),
1930
- );
1931
-
1932
- if (originalNormalized === result.lines) {
1933
- return {
1934
- content: [{ type: "text", text: formatNoChangeDiagnostic(sourcePath) }],
1935
- details: { diff: "", op: "update", meta: outputMeta().get() },
1936
- };
1937
- }
1938
-
1939
- const finalContent = await serializeEditFileText(
1940
- absolutePath,
1941
- sourcePath,
1942
- bom + restoreLineEndings(result.lines, originalEnding),
1943
- );
1944
- const diagnostics = await writethrough(
1945
- absolutePath,
1946
- finalContent,
1947
- signal,
1948
- Bun.file(absolutePath),
1949
- batchRequest,
1950
- dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
1951
- );
1952
- invalidateFsScanAfterWrite(absolutePath);
1953
- // The post-edit content is the freshest, most authoritative "model view"
1954
- // of the file: the model just received it back as the diff/preview. Cache
1955
- // it so a follow-up edit anchored against this state can still recover
1956
- // if the file is touched out-of-band before the next edit lands.
1957
- getFileReadCache(session).recordContiguous(absolutePath, 1, result.lines.split("\n"));
1958
-
1959
- const diffResult = generateDiffString(originalNormalized, result.lines);
1960
- const meta = outputMeta()
1961
- .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
1962
- .get();
1963
- const preview = buildCompactHashlineDiffPreview(diffResult.diff);
1964
-
1965
- const warnings = [...parseWarnings, ...(result.warnings ?? [])];
1966
- const warningsBlock = warnings.length > 0 ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
1967
- const previewBlock = preview.preview ? `\n${preview.preview}` : "";
1968
- const headline = preview.preview
1969
- ? `${sourcePath}:`
1970
- : source.exists
1971
- ? `Updated ${sourcePath}`
1972
- : `Created ${sourcePath}`;
1973
-
1974
- return {
1975
- content: [{ type: "text", text: `${headline}${previewBlock}${warningsBlock}` }],
1976
- details: {
1977
- diff: diffResult.diff,
1978
- firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
1979
- diagnostics,
1980
- op: source.exists ? "update" : "create",
1981
- meta,
1982
- },
1983
- };
1984
- }
1985
-
1986
- export async function executeHashlineSingle(
1987
- options: ExecuteHashlineSingleOptions,
1988
- ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
1989
- const sections = mergeSamePathSections(
1990
- splitHashlineInputs(options.input, { cwd: options.session.cwd, path: options.path }),
1991
- );
1992
-
1993
- // Fast path: a single section needs no preflight pass.
1994
- if (sections.length === 1) return executeHashlineSection({ ...options, ...sections[0] });
1995
-
1996
- // Multi-section: validate everything up front so we don't apply a partial batch.
1997
- for (const section of sections) await preflightHashlineSection({ ...options, ...section });
1998
-
1999
- const results = [];
2000
- for (const section of sections) {
2001
- results.push({ path: section.path, result: await executeHashlineSection({ ...options, ...section }) });
2002
- }
2003
-
2004
- return {
2005
- content: [{ type: "text", text: results.map(({ result }) => getTextContent(result)).join("\n\n") }],
2006
- details: {
2007
- diff: results.map(({ result }) => getEditDetails(result).diff).join("\n"),
2008
- perFileResults: results.map(({ path: resultPath, result }) => {
2009
- const details = getEditDetails(result);
2010
- return {
2011
- path: resultPath,
2012
- diff: details.diff,
2013
- firstChangedLine: details.firstChangedLine,
2014
- diagnostics: details.diagnostics,
2015
- op: details.op,
2016
- move: details.move,
2017
- meta: details.meta,
2018
- };
2019
- }),
2020
- },
2021
- };
2022
- }
2023
-
2024
- /**
2025
- * Collapse consecutive or interleaved sections targeting the same path into a
2026
- * single section with concatenated diffs. Anchors authored against the same
2027
- * file snapshot must be applied as one batch; otherwise the first sub-edit
2028
- * shifts line numbers out from under the second's anchors and rebase fails.
2029
- * Path order is preserved by first occurrence.
2030
- */
2031
- function mergeSamePathSections(sections: HashlineInputSection[]): HashlineInputSection[] {
2032
- const byPath = new Map<string, string[]>();
2033
- for (const section of sections) {
2034
- const existing = byPath.get(section.path);
2035
- if (existing) existing.push(section.diff);
2036
- else byPath.set(section.path, [section.diff]);
2037
- }
2038
- return Array.from(byPath, ([path, diffs]) => ({ path, diff: diffs.join("\n") }));
2039
- }