opencodekit 0.17.7 → 0.17.8

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.
@@ -1,757 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import type { Plugin } from "@opencode-ai/plugin";
4
- import { tool } from "@opencode-ai/plugin/tool";
5
-
6
- const z = tool.schema;
7
-
8
- /**
9
- * Max line length — must match OpenCode's read tool truncation.
10
- * Lines longer than this are displayed as `line.substring(0, 2000) + "..."`.
11
- * We must hash the truncated form so computeFileHashes matches the read hook.
12
- */
13
- const MAX_LINE_LENGTH = 2000;
14
-
15
- /**
16
- * djb2 hash of trimmed line content, truncated to 3 hex chars.
17
- * 3 hex chars = 4096 values. Collisions are rare and disambiguated by line number.
18
- */
19
- function hashLine(content: string): string {
20
- const trimmed = content.trimEnd();
21
- let h = 5381;
22
- for (let i = 0; i < trimmed.length; i++) {
23
- h = ((h << 5) + h + trimmed.charCodeAt(i)) | 0;
24
- }
25
- return (h >>> 0).toString(16).slice(-3).padStart(3, "0");
26
- }
27
-
28
- /**
29
- * Normalize a hash reference from model output.
30
- * Handles: extra whitespace, trailing "|", spaces around ":"
31
- * e.g. " 141:d81 " → "141:d81", "141:d81|" → "141:d81", "141: d81" → "141:d81"
32
- */
33
- function normalizeHashRef(ref: string): string {
34
- ref = ref.trim();
35
- if (ref.endsWith("|")) ref = ref.slice(0, -1).trimEnd();
36
- const colonIdx = ref.indexOf(":");
37
- if (colonIdx > 0) {
38
- const lineNum = ref.slice(0, colonIdx).trim();
39
- const hash = ref.slice(colonIdx + 1).trim();
40
- return `${lineNum}:${hash}`;
41
- }
42
- return ref;
43
- }
44
-
45
- /** Per-file mapping: hash ref (e.g. "42:a3f") → line content */
46
- const fileHashes = new Map<string, Map<string, string>>();
47
-
48
- /** Track file paths for apply_patch hash invalidation across before/after hooks */
49
- let pendingPatchFilePaths: string[] = [];
50
-
51
- interface HashlineEdit {
52
- filePath: string;
53
- startHash?: string;
54
- endHash?: string;
55
- afterHash?: string;
56
- content: string;
57
- }
58
-
59
- /** Normalize all hash refs in an edit object */
60
- function normalizeEdit(edit: HashlineEdit): HashlineEdit {
61
- return {
62
- ...edit,
63
- startHash: edit.startHash ? normalizeHashRef(edit.startHash) : undefined,
64
- endHash: edit.endHash ? normalizeHashRef(edit.endHash) : undefined,
65
- afterHash: edit.afterHash ? normalizeHashRef(edit.afterHash) : undefined,
66
- };
67
- }
68
-
69
- export const HashlinePlugin: Plugin = async ({ directory }) => {
70
- function resolvePath(filePath: string): string {
71
- if (path.isAbsolute(filePath)) return path.normalize(filePath);
72
- return path.resolve(directory, filePath);
73
- }
74
-
75
- /**
76
- * Read file from disk and compute fresh hashes.
77
- * Lines are truncated before hashing to match OpenCode's read tool output.
78
- * Full (untruncated) content is stored for building oldString/patchText.
79
- */
80
- function computeFileHashes(filePath: string): Map<string, string> {
81
- const content = fs.readFileSync(filePath, "utf-8");
82
- const lines = content.split("\n");
83
- const hashes = new Map<string, string>();
84
- for (let i = 0; i < lines.length; i++) {
85
- // Truncate to match OpenCode's read tool (read.ts MAX_LINE_LENGTH)
86
- const displayed =
87
- lines[i].length > MAX_LINE_LENGTH
88
- ? lines[i].substring(0, MAX_LINE_LENGTH) + "..."
89
- : lines[i];
90
- const hash = hashLine(displayed);
91
- hashes.set(`${i + 1}:${hash}`, lines[i]);
92
- }
93
- fileHashes.set(filePath, hashes);
94
- return hashes;
95
- }
96
-
97
- /** Get line content by line number from hash map */
98
- function getLineByNumber(
99
- hashes: Map<string, string>,
100
- lineNum: number,
101
- ): string | undefined {
102
- for (const [ref, content] of hashes) {
103
- if (ref.startsWith(`${lineNum}:`)) return content;
104
- }
105
- return undefined;
106
- }
107
-
108
- /** Find the actual hash ref for a line number (if it exists) */
109
- function getHashRefByLineNumber(
110
- hashes: Map<string, string>,
111
- lineNum: number,
112
- ): string | undefined {
113
- for (const ref of hashes.keys()) {
114
- if (ref.startsWith(`${lineNum}:`)) return ref;
115
- }
116
- return undefined;
117
- }
118
-
119
- /** Validate a hash reference exists, re-reading file once if stale */
120
- function validateHash(
121
- filePath: string,
122
- hashRef: string,
123
- hashes: Map<string, string>,
124
- ): Map<string, string> {
125
- if (hashes.has(hashRef)) return hashes;
126
-
127
- // Re-read file and recompute hashes
128
- try {
129
- hashes = computeFileHashes(filePath);
130
- } catch {
131
- throw new Error(
132
- `Cannot read file "${filePath}" to verify hash references.`,
133
- );
134
- }
135
- if (hashes.has(hashRef)) return hashes;
136
-
137
- // Hash not found — provide a diagnostic error message
138
- const lineNum = Number.parseInt(hashRef.split(":")[0], 10);
139
- const actualRef = getHashRefByLineNumber(hashes, lineNum);
140
-
141
- if (actualRef) {
142
- throw new Error(
143
- [
144
- `Hash reference "${hashRef}" not found.`,
145
- `Line ${lineNum} now has hash "${actualRef}".`,
146
- `The file has changed since last read. Please re-read the file.`,
147
- ].join(" "),
148
- );
149
- }
150
- throw new Error(
151
- [
152
- `Hash reference "${hashRef}" not found.`,
153
- `Line ${lineNum} does not exist in the file (${hashes.size} lines total).`,
154
- `Please re-read the file.`,
155
- ].join(" "),
156
- );
157
- }
158
-
159
- /** Ensure hashes exist for a file, computing them if needed */
160
- function ensureHashes(filePath: string): Map<string, string> | undefined {
161
- let hashes = fileHashes.get(filePath);
162
- if (!hashes) {
163
- try {
164
- hashes = computeFileHashes(filePath);
165
- } catch {
166
- return undefined;
167
- }
168
- }
169
- return hashes;
170
- }
171
-
172
- /** Collect old lines from a line range, throwing if any are missing */
173
- function collectRange(
174
- filePath: string,
175
- hashes: Map<string, string>,
176
- startLine: number,
177
- endLine: number,
178
- ): string[] {
179
- const lines: string[] = [];
180
- for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
181
- const content = getLineByNumber(hashes, lineNum);
182
- if (content === undefined) {
183
- fileHashes.delete(filePath);
184
- throw new Error(
185
- `No hash found for line ${lineNum} in range ${startLine}-${endLine}. The file may have changed. Please re-read the file.`,
186
- );
187
- }
188
- lines.push(content);
189
- }
190
- return lines;
191
- }
192
-
193
- /** Generate patch @@ chunk lines for a single hashline edit */
194
- function generatePatchChunk(
195
- filePath: string,
196
- edit: HashlineEdit,
197
- hashes: Map<string, string>,
198
- ): string[] {
199
- const chunk: string[] = [];
200
-
201
- if (edit.afterHash) {
202
- hashes = validateHash(filePath, edit.afterHash, hashes);
203
- const anchorContent = hashes.get(edit.afterHash)!;
204
- const anchorLine = Number.parseInt(edit.afterHash.split(":")[0], 10);
205
- const ctx =
206
- anchorLine > 1 ? (getLineByNumber(hashes, anchorLine - 1) ?? "") : "";
207
- chunk.push(`@@ ${ctx}`);
208
- chunk.push(` ${anchorContent}`);
209
- for (const line of edit.content.split("\n")) {
210
- chunk.push(`+${line}`);
211
- }
212
- } else if (edit.startHash) {
213
- hashes = validateHash(filePath, edit.startHash, hashes);
214
- if (edit.endHash) {
215
- hashes = validateHash(filePath, edit.endHash, hashes);
216
- }
217
-
218
- const startLine = Number.parseInt(edit.startHash.split(":")[0], 10);
219
- const endLine = edit.endHash
220
- ? Number.parseInt(edit.endHash.split(":")[0], 10)
221
- : startLine;
222
-
223
- if (endLine < startLine) {
224
- throw new Error(
225
- `endHash line (${endLine}) must be >= startHash line (${startLine})`,
226
- );
227
- }
228
-
229
- const ctx =
230
- startLine > 1 ? (getLineByNumber(hashes, startLine - 1) ?? "") : "";
231
- chunk.push(`@@ ${ctx}`);
232
-
233
- const oldLines = collectRange(filePath, hashes, startLine, endLine);
234
- for (const line of oldLines) {
235
- chunk.push(`-${line}`);
236
- }
237
- for (const line of edit.content.split("\n")) {
238
- chunk.push(`+${line}`);
239
- }
240
- }
241
-
242
- return chunk;
243
- }
244
-
245
- /**
246
- * Apply multiple hashline edits to a file's lines array (in-place).
247
- * Edits are sorted descending by line number and applied bottom-to-top
248
- * so earlier indices aren't affected by insertions/deletions.
249
- */
250
- function applyEditsToLines(
251
- filePath: string,
252
- edits: HashlineEdit[],
253
- lines: string[],
254
- hashes: Map<string, string>,
255
- ): void {
256
- // Sort descending by line number (apply from bottom to top)
257
- const sorted = [...edits].sort((a, b) => {
258
- const lineA = Number.parseInt(
259
- (a.startHash || a.afterHash || "0").split(":")[0],
260
- 10,
261
- );
262
- const lineB = Number.parseInt(
263
- (b.startHash || b.afterHash || "0").split(":")[0],
264
- 10,
265
- );
266
- return lineB - lineA;
267
- });
268
-
269
- for (const edit of sorted) {
270
- if (edit.afterHash) {
271
- hashes = validateHash(filePath, edit.afterHash, hashes);
272
- const anchorLine = Number.parseInt(edit.afterHash.split(":")[0], 10);
273
- const newLines = edit.content.split("\n");
274
- // Insert after anchorLine (1-indexed → splice at anchorLine)
275
- lines.splice(anchorLine, 0, ...newLines);
276
- } else if (edit.startHash) {
277
- hashes = validateHash(filePath, edit.startHash, hashes);
278
- if (edit.endHash) {
279
- hashes = validateHash(filePath, edit.endHash, hashes);
280
- }
281
- const startLine = Number.parseInt(edit.startHash.split(":")[0], 10);
282
- const endLine = edit.endHash
283
- ? Number.parseInt(edit.endHash.split(":")[0], 10)
284
- : startLine;
285
-
286
- if (endLine < startLine) {
287
- throw new Error(
288
- `endHash line (${endLine}) must be >= startHash line (${startLine})`,
289
- );
290
- }
291
-
292
- const newLines = edit.content.split("\n");
293
- // Replace lines startLine..endLine (1-indexed)
294
- lines.splice(startLine - 1, endLine - startLine + 1, ...newLines);
295
- }
296
- }
297
- }
298
-
299
- /** Hashline edit schema — shared shape for individual edits */
300
- const editShape = {
301
- filePath: z.string().describe("The absolute path to the file to modify"),
302
- startHash: z
303
- .string()
304
- .optional()
305
- .describe('Hash reference for the start line to replace (e.g. "42:a3f")'),
306
- endHash: z
307
- .string()
308
- .optional()
309
- .describe(
310
- "Hash reference for the end line (for multi-line range replacement)",
311
- ),
312
- afterHash: z
313
- .string()
314
- .optional()
315
- .describe("Hash reference for the line to insert after (no replacement)"),
316
- content: z.string().describe("The new content to insert or replace with"),
317
- };
318
-
319
- /** Schema for edit tool — edits array, single file per call */
320
- const editParams = z.object({
321
- edits: z
322
- .array(z.object(editShape))
323
- .describe(
324
- "Array of edits to apply. All edits must target the same file.",
325
- ),
326
- });
327
-
328
- /** Schema for apply_patch tool — edits array, multi-file supported */
329
- const patchParams = z.object({
330
- edits: z
331
- .array(z.object(editShape))
332
- .describe(
333
- "Array of edits to apply. Multiple files and multiple edits per file are supported.",
334
- ),
335
- });
336
-
337
- const editDescription = [
338
- "Edit a file using hashline references from the most recent read output.",
339
- "Each line is tagged as `<line>:<hash>| <content>`.",
340
- "Pass an `edits` array with one or more edits (all must target the same file).",
341
- "",
342
- "Three operations per edit:",
343
- "1. Replace line: startHash only → replaces that single line",
344
- "2. Replace range: startHash + endHash → replaces all lines in range",
345
- "3. Insert after: afterHash → inserts content after that line (no replacement)",
346
- ].join("\n");
347
-
348
- const patchDescription = [
349
- "Edit one or more files using hashline references from read output.",
350
- "Each line is tagged as `<line>:<hash>| <content>`.",
351
- "Pass an `edits` array — multiple files and multiple edits per file are supported.",
352
- "",
353
- "Three operations per edit:",
354
- "1. Replace line: startHash only → replaces that single line",
355
- "2. Replace range: startHash + endHash → replaces all lines in range",
356
- "3. Insert after: afterHash → inserts content after that line (no replacement)",
357
- ].join("\n");
358
-
359
- return {
360
- // ── Read: tag each line with its content hash ──────────────────────
361
- "tool.execute.after": async (input, output) => {
362
- if (input.tool === "edit") {
363
- // Recompute hashes from the edited file so subsequent edits
364
- // get diagnostic "line N now has hash X" errors instead of
365
- // a generic "not found" when using stale refs.
366
- const filePath = resolvePath(input.args.filePath);
367
- try {
368
- computeFileHashes(filePath);
369
- } catch {
370
- fileHashes.delete(filePath);
371
- }
372
- return;
373
- }
374
-
375
- if (input.tool === "apply_patch") {
376
- for (const fp of pendingPatchFilePaths) {
377
- try {
378
- computeFileHashes(fp);
379
- } catch {
380
- fileHashes.delete(fp);
381
- }
382
- }
383
- pendingPatchFilePaths = [];
384
- return;
385
- }
386
-
387
- if (input.tool !== "read") return;
388
-
389
- // Skip directory reads
390
- if (output.output.includes("<type>directory</type>")) return;
391
-
392
- // Extract absolute file path from output and normalize it
393
- const pathMatch = output.output.match(/<path>(.+?)<\/path>/);
394
- if (!pathMatch) return;
395
- const filePath = path.normalize(pathMatch[1]);
396
-
397
- // Transform content lines: "N: content" → "N:hash| content"
398
- // The first line is concatenated with <content> (no newline), so we
399
- // match an optional <content> prefix and preserve it in the output.
400
- const hashes = new Map<string, string>();
401
- output.output = output.output.replace(
402
- /^(<content>)?(\d+): (.*)$/gm,
403
- (
404
- _match,
405
- prefix: string | undefined,
406
- lineNum: string,
407
- content: string,
408
- ) => {
409
- const hash = hashLine(content);
410
- const ref = `${lineNum}:${hash}`;
411
- hashes.set(ref, content);
412
- return `${prefix ?? ""}${lineNum}:${hash}| ${content}`;
413
- },
414
- );
415
-
416
- if (hashes.size > 0) {
417
- // Merge with existing hashes (supports partial reads / offset reads)
418
- const existing = fileHashes.get(filePath);
419
- if (existing) {
420
- for (const [ref, content] of hashes) {
421
- existing.set(ref, content);
422
- }
423
- } else {
424
- fileHashes.set(filePath, hashes);
425
- }
426
- }
427
- },
428
-
429
- // ── Tool schema: replace params with hash references ─────────────
430
- // Requires PR #4956 (tool.definition hook) to take effect.
431
- // OpenCode shows `edit` for Anthropic models, `apply_patch` for Codex.
432
- "tool.definition": async (input: any, output: any) => {
433
- if (input.toolID === "edit") {
434
- output.description = editDescription;
435
- output.parameters = editParams;
436
- } else if (input.toolID === "apply_patch") {
437
- output.description = patchDescription;
438
- output.parameters = patchParams;
439
- }
440
- },
441
-
442
- // ── System prompt: instruct the model to use hashline edits ────────
443
- "experimental.chat.system.transform": async (_input: any, output: any) => {
444
- output.system.push(
445
- [
446
- "## Hashline Edit Mode (MANDATORY)",
447
- "",
448
- "When you read a file, each line is tagged with a hash: `<lineNumber>:<hash>| <content>`.",
449
- "You MUST use these hash references when editing files. Do NOT use oldString/newString or patchText.",
450
- "",
451
- "Pass an `edits` array with one or more edits. Each edit has: filePath, and one of:",
452
- "",
453
- "1. **Replace line** — `startHash` + `content`:",
454
- ' `{ filePath: "...", startHash: "3:cc7", content: "new line" }`',
455
- "",
456
- "2. **Replace range** — `startHash` + `endHash` + `content`:",
457
- ' `{ filePath: "...", startHash: "3:cc7", endHash: "5:e60", content: "line3\\nline4\\nline5" }`',
458
- "",
459
- "3. **Insert after** — `afterHash` + `content`:",
460
- ' `{ filePath: "...", afterHash: "3:cc7", content: " inserted line" }`',
461
- "",
462
- "Multiple edits can be batched in a single call:",
463
- ' `{ edits: [{ filePath: "...", startHash: "3:cc7", content: "..." }, { filePath: "...", afterHash: "7:e2c", content: "..." }] }`',
464
- "",
465
- "IMPORTANT: The hash value (e.g. `cc7`) is the EXACT 3-character code shown after the line number and colon.",
466
- "Copy it exactly as shown — do NOT include the `|` separator or any surrounding spaces.",
467
- "Example: for line `42:a3f| function hello()`, the hash reference is `42:a3f` (not `42:a3f|`).",
468
- "",
469
- "NEVER pass oldString, newString, or patchText. ALWAYS use the edits array with hash references.",
470
- ].join("\n"),
471
- );
472
- },
473
-
474
- // ── Edit/Patch: resolve hash references before built-in tool runs ─
475
- "tool.execute.before": async (input, output) => {
476
- // ── apply_patch: resolve hashes → generate patchText ──
477
- if (input.tool === "apply_patch") {
478
- const args = output.args;
479
-
480
- // Raw patchText with no hashline args → let normal patch through
481
- if (args.patchText && !args.edits && !args.startHash && !args.afterHash)
482
- return;
483
-
484
- // ── Multi-file edits array ──
485
- if (args.edits && Array.isArray(args.edits)) {
486
- // Normalize all hash refs up front
487
- const edits = (args.edits as HashlineEdit[]).map(normalizeEdit);
488
-
489
- // Group edits by file path (preserving order within each file)
490
- const editsByFile = new Map<
491
- string,
492
- { absPath: string; relPath: string; edits: HashlineEdit[] }
493
- >();
494
-
495
- for (const edit of edits) {
496
- const absPath = resolvePath(edit.filePath);
497
- let entry = editsByFile.get(absPath);
498
- if (!entry) {
499
- const relPath = path
500
- .relative(directory, absPath)
501
- .split(path.sep)
502
- .join("/");
503
- entry = { absPath, relPath, edits: [] };
504
- editsByFile.set(absPath, entry);
505
- }
506
- entry.edits.push(edit);
507
- }
508
-
509
- const patchLines: string[] = ["*** Begin Patch"];
510
- const editedPaths: string[] = [];
511
-
512
- for (const [absPath, { relPath, edits }] of editsByFile) {
513
- editedPaths.push(absPath);
514
- const hashes = ensureHashes(absPath);
515
- if (!hashes) continue;
516
-
517
- // Sort edits by line number so chunks apply top-to-bottom
518
- edits.sort((a, b) => {
519
- const lineA = Number.parseInt(
520
- (a.startHash || a.afterHash || "0").split(":")[0],
521
- 10,
522
- );
523
- const lineB = Number.parseInt(
524
- (b.startHash || b.afterHash || "0").split(":")[0],
525
- 10,
526
- );
527
- return lineA - lineB;
528
- });
529
-
530
- // One *** Update File section with multiple @@ chunks
531
- patchLines.push(`*** Update File: ${relPath}`);
532
-
533
- for (const edit of edits) {
534
- const chunkLines = generatePatchChunk(absPath, edit, hashes);
535
- patchLines.push(...chunkLines);
536
- }
537
- }
538
-
539
- patchLines.push("*** End Patch");
540
-
541
- pendingPatchFilePaths = editedPaths;
542
- args.patchText = patchLines.join("\n");
543
- delete args.edits;
544
- return;
545
- }
546
-
547
- // ── Single-file fallback (backwards compat) ──
548
- if (!args.startHash && !args.afterHash) return;
549
-
550
- const filePath = resolvePath(args.filePath);
551
- pendingPatchFilePaths = [filePath];
552
- const relativePath = path
553
- .relative(directory, filePath)
554
- .split(path.sep)
555
- .join("/");
556
-
557
- const hashes = ensureHashes(filePath);
558
- if (!hashes) return;
559
-
560
- const edit: HashlineEdit = normalizeEdit({
561
- filePath: args.filePath,
562
- startHash: args.startHash,
563
- endHash: args.endHash,
564
- afterHash: args.afterHash,
565
- content: args.content,
566
- });
567
- const patchLines: string[] = [
568
- "*** Begin Patch",
569
- `*** Update File: ${relativePath}`,
570
- ];
571
- patchLines.push(...generatePatchChunk(filePath, edit, hashes));
572
- patchLines.push("*** End Patch");
573
-
574
- args.patchText = patchLines.join("\n");
575
-
576
- delete args.filePath;
577
- delete args.startHash;
578
- delete args.endHash;
579
- delete args.afterHash;
580
- delete args.content;
581
- return;
582
- }
583
-
584
- // ── edit: resolve hashes → oldString/newString ──
585
- if (input.tool !== "edit") return;
586
-
587
- const args = output.args;
588
-
589
- // ── Multi-edit via edits array ──
590
- if (args.edits && Array.isArray(args.edits)) {
591
- // Normalize all hash refs up front
592
- const edits = (args.edits as HashlineEdit[]).map(normalizeEdit);
593
- if (edits.length === 0) return;
594
-
595
- // All edits must target the same file (edit tool is single-file)
596
- const filePath = resolvePath(edits[0].filePath);
597
- const uniqueFiles = new Set(edits.map((e) => resolvePath(e.filePath)));
598
- if (uniqueFiles.size > 1) {
599
- throw new Error(
600
- "The edit tool supports one file per call. Make separate calls for each file.",
601
- );
602
- }
603
-
604
- let hashes = ensureHashes(filePath);
605
- if (!hashes) return;
606
-
607
- // Validate all hashes up front
608
- for (const edit of edits) {
609
- if (edit.afterHash) {
610
- hashes = validateHash(filePath, edit.afterHash, hashes);
611
- } else if (edit.startHash) {
612
- hashes = validateHash(filePath, edit.startHash, hashes);
613
- if (edit.endHash)
614
- hashes = validateHash(filePath, edit.endHash, hashes);
615
- }
616
- }
617
-
618
- // Read original file, apply all edits, produce oldString/newString
619
- const originalContent = fs.readFileSync(filePath, "utf-8");
620
- const lines = originalContent.split("\n");
621
- applyEditsToLines(filePath, edits, lines, hashes);
622
- const newContent = lines.join("\n");
623
-
624
- args.filePath = filePath;
625
- args.oldString = originalContent;
626
- args.newString = newContent;
627
- delete args.edits;
628
- return;
629
- }
630
-
631
- // ── Single-edit fallback (backwards compat) ──
632
-
633
- // Auto-convert oldString to hashline - reads file if needed
634
- if (args.oldString && !args.startHash && !args.afterHash) {
635
- const filePath = resolvePath(args.filePath);
636
-
637
- // Read file and compute hashes if not cached
638
- let hashes = fileHashes.get(filePath);
639
- if (!hashes) {
640
- hashes = computeFileHashes(filePath);
641
- }
642
-
643
- if (hashes && args.oldString && args.newString !== undefined) {
644
- const oldContent = args.oldString;
645
- const newContent = args.newString;
646
-
647
- // Try exact match first
648
- let found = false;
649
- let matchedRef = "";
650
- for (const [ref, lineContent] of hashes) {
651
- if (lineContent.trim() === oldContent.trim()) {
652
- // Validate hash is still valid
653
- hashes = validateHash(filePath, ref, hashes);
654
- matchedRef = ref;
655
- found = true;
656
- break;
657
- }
658
- }
659
-
660
- // Try first line match if exact match failed
661
- if (!found) {
662
- const firstLine = oldContent.split("\n")[0].trim();
663
- for (const [ref, lineContent] of hashes) {
664
- if (lineContent.trim() === firstLine) {
665
- // Validate hash is still valid
666
- hashes = validateHash(filePath, ref, hashes);
667
- matchedRef = ref;
668
- found = true;
669
- break;
670
- }
671
- }
672
- }
673
-
674
- if (found && matchedRef) {
675
- args.startHash = matchedRef;
676
- args.content = newContent;
677
- delete args.oldString;
678
- delete args.newString;
679
- } else {
680
- // Provide helpful error with suggestions
681
- const searchKey = oldContent.split("\n")[0].substring(0, 30);
682
- let suggestion = "";
683
- for (const [ref, lineContent] of hashes) {
684
- if (
685
- lineContent.includes(searchKey) ||
686
- searchKey.includes(lineContent.substring(0, 20))
687
- ) {
688
- suggestion = ` Did you mean hash ${ref} for: ${lineContent.substring(0, 40)}...?`;
689
- break;
690
- }
691
- }
692
- throw new Error(
693
- `Hashline Plugin: oldString does not match any line in the file.${suggestion}\n` +
694
- "Please re-read the file and use hashline format (startHash/endHash/afterHash) instead of oldString/newString.",
695
- );
696
- }
697
- }
698
- }
699
-
700
- // Only intercept hashline edits; fall through for normal edits
701
- if (!args.startHash && !args.afterHash) return;
702
-
703
- // Normalize hash refs
704
- if (args.startHash) args.startHash = normalizeHashRef(args.startHash);
705
- if (args.endHash) args.endHash = normalizeHashRef(args.endHash);
706
- if (args.afterHash) args.afterHash = normalizeHashRef(args.afterHash);
707
-
708
- // Insert after
709
- if (args.afterHash) {
710
- const filePath = resolvePath(args.filePath);
711
- let hashes = ensureHashes(filePath);
712
- if (!hashes) return;
713
- hashes = validateHash(filePath, args.afterHash, hashes);
714
-
715
- const anchorContent = hashes.get(args.afterHash)!;
716
- args.oldString = anchorContent;
717
- args.newString = anchorContent + "\n" + args.content;
718
-
719
- delete args.afterHash;
720
- delete args.content;
721
- return;
722
- }
723
-
724
- // Replace (single line or range)
725
- const filePath = resolvePath(args.filePath);
726
- let hashes = ensureHashes(filePath);
727
- if (!hashes) return;
728
-
729
- hashes = validateHash(filePath, args.startHash, hashes);
730
-
731
- const startLine = Number.parseInt(args.startHash.split(":")[0], 10);
732
- const endLine = args.endHash
733
- ? Number.parseInt(args.endHash.split(":")[0], 10)
734
- : startLine;
735
-
736
- if (args.endHash) {
737
- hashes = validateHash(filePath, args.endHash, hashes);
738
- }
739
-
740
- if (endLine < startLine) {
741
- throw new Error(
742
- `endHash line (${endLine}) must be >= startHash line (${startLine})`,
743
- );
744
- }
745
-
746
- const rangeLines = collectRange(filePath, hashes, startLine, endLine);
747
- args.oldString = rangeLines.join("\n");
748
- args.newString = args.content;
749
-
750
- delete args.startHash;
751
- delete args.endHash;
752
- delete args.content;
753
- },
754
- } as any;
755
- };
756
-
757
- export default HashlinePlugin;