open-hashline 0.4.0 → 0.6.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.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/index.ts +403 -100
package/README.md
CHANGED
|
@@ -69,6 +69,22 @@ No modifications are made to any built-in tools. Everything works through hooks.
|
|
|
69
69
|
{ "afterHash": "7:e2c", "content": " \"newField\": \"value\"," }
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
### 4. Multi-file edit (Codex / `apply_patch`)
|
|
73
|
+
|
|
74
|
+
Codex models use the `apply_patch` tool which accepts an `edits` array — multiple files and multiple edits per file in a single call:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"edits": [
|
|
79
|
+
{ "filePath": "/project/src/config.ts", "startHash": "3:cc7", "content": " version: \"2.0.0\"," },
|
|
80
|
+
{ "filePath": "/project/src/config.ts", "afterHash": "5:e60", "content": " debug: true," },
|
|
81
|
+
{ "filePath": "/project/src/index.ts", "startHash": "10:a1b", "endHash": "12:f3d", "content": "// refactored" }
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Edits to the same file are grouped into a single patch section with multiple `@@` chunks, sorted by line number.
|
|
87
|
+
|
|
72
88
|
---
|
|
73
89
|
|
|
74
90
|
## Installation
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -5,6 +5,13 @@ import * as path from "path"
|
|
|
5
5
|
|
|
6
6
|
const z = tool.schema
|
|
7
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
|
+
|
|
8
15
|
/**
|
|
9
16
|
* djb2 hash of trimmed line content, truncated to 3 hex chars.
|
|
10
17
|
* 3 hex chars = 4096 values. Collisions are rare and disambiguated by line number.
|
|
@@ -18,11 +25,46 @@ function hashLine(content: string): string {
|
|
|
18
25
|
return (h >>> 0).toString(16).slice(-3).padStart(3, "0")
|
|
19
26
|
}
|
|
20
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
|
+
|
|
21
45
|
/** Per-file mapping: hash ref (e.g. "42:a3f") → line content */
|
|
22
46
|
const fileHashes = new Map<string, Map<string, string>>()
|
|
23
47
|
|
|
24
|
-
/** Track file
|
|
25
|
-
let
|
|
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
|
+
}
|
|
26
68
|
|
|
27
69
|
export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
28
70
|
function resolvePath(filePath: string): string {
|
|
@@ -30,13 +72,22 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
30
72
|
return path.resolve(directory, filePath)
|
|
31
73
|
}
|
|
32
74
|
|
|
33
|
-
/**
|
|
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
|
+
*/
|
|
34
80
|
function computeFileHashes(filePath: string): Map<string, string> {
|
|
35
81
|
const content = fs.readFileSync(filePath, "utf-8")
|
|
36
82
|
const lines = content.split("\n")
|
|
37
83
|
const hashes = new Map<string, string>()
|
|
38
84
|
for (let i = 0; i < lines.length; i++) {
|
|
39
|
-
|
|
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)
|
|
40
91
|
hashes.set(`${i + 1}:${hash}`, lines[i])
|
|
41
92
|
}
|
|
42
93
|
fileHashes.set(filePath, hashes)
|
|
@@ -54,6 +105,17 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
54
105
|
return undefined
|
|
55
106
|
}
|
|
56
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
|
+
|
|
57
119
|
/** Validate a hash reference exists, re-reading file once if stale */
|
|
58
120
|
function validateHash(
|
|
59
121
|
filePath: string,
|
|
@@ -61,6 +123,8 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
61
123
|
hashes: Map<string, string>,
|
|
62
124
|
): Map<string, string> {
|
|
63
125
|
if (hashes.has(hashRef)) return hashes
|
|
126
|
+
|
|
127
|
+
// Re-read file and recompute hashes
|
|
64
128
|
try {
|
|
65
129
|
hashes = computeFileHashes(filePath)
|
|
66
130
|
} catch {
|
|
@@ -68,13 +132,28 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
68
132
|
`Cannot read file "${filePath}" to verify hash references.`,
|
|
69
133
|
)
|
|
70
134
|
}
|
|
71
|
-
if (
|
|
72
|
-
|
|
135
|
+
if (hashes.has(hashRef)) return hashes
|
|
136
|
+
|
|
137
|
+
// Hash not found — provide a diagnostic error message
|
|
138
|
+
const lineNum = parseInt(hashRef.split(":")[0], 10)
|
|
139
|
+
const actualRef = getHashRefByLineNumber(hashes, lineNum)
|
|
140
|
+
|
|
141
|
+
if (actualRef) {
|
|
73
142
|
throw new Error(
|
|
74
|
-
|
|
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(" "),
|
|
75
148
|
)
|
|
76
149
|
}
|
|
77
|
-
|
|
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
|
+
)
|
|
78
157
|
}
|
|
79
158
|
|
|
80
159
|
/** Ensure hashes exist for a file, computing them if needed */
|
|
@@ -111,8 +190,118 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
111
190
|
return lines
|
|
112
191
|
}
|
|
113
192
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
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 = parseInt(edit.afterHash.split(":")[0], 10)
|
|
205
|
+
const ctx =
|
|
206
|
+
anchorLine > 1
|
|
207
|
+
? (getLineByNumber(hashes, anchorLine - 1) ?? "")
|
|
208
|
+
: ""
|
|
209
|
+
chunk.push(`@@ ${ctx}`)
|
|
210
|
+
chunk.push(` ${anchorContent}`)
|
|
211
|
+
for (const line of edit.content.split("\n")) {
|
|
212
|
+
chunk.push(`+${line}`)
|
|
213
|
+
}
|
|
214
|
+
} else if (edit.startHash) {
|
|
215
|
+
hashes = validateHash(filePath, edit.startHash, hashes)
|
|
216
|
+
if (edit.endHash) {
|
|
217
|
+
hashes = validateHash(filePath, edit.endHash, hashes)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const startLine = parseInt(edit.startHash.split(":")[0], 10)
|
|
221
|
+
const endLine = edit.endHash
|
|
222
|
+
? parseInt(edit.endHash.split(":")[0], 10)
|
|
223
|
+
: startLine
|
|
224
|
+
|
|
225
|
+
if (endLine < startLine) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`endHash line (${endLine}) must be >= startHash line (${startLine})`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ctx =
|
|
232
|
+
startLine > 1
|
|
233
|
+
? (getLineByNumber(hashes, startLine - 1) ?? "")
|
|
234
|
+
: ""
|
|
235
|
+
chunk.push(`@@ ${ctx}`)
|
|
236
|
+
|
|
237
|
+
const oldLines = collectRange(filePath, hashes, startLine, endLine)
|
|
238
|
+
for (const line of oldLines) {
|
|
239
|
+
chunk.push(`-${line}`)
|
|
240
|
+
}
|
|
241
|
+
for (const line of edit.content.split("\n")) {
|
|
242
|
+
chunk.push(`+${line}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return chunk
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Apply multiple hashline edits to a file's lines array (in-place).
|
|
251
|
+
* Edits are sorted descending by line number and applied bottom-to-top
|
|
252
|
+
* so earlier indices aren't affected by insertions/deletions.
|
|
253
|
+
*/
|
|
254
|
+
function applyEditsToLines(
|
|
255
|
+
filePath: string,
|
|
256
|
+
edits: HashlineEdit[],
|
|
257
|
+
lines: string[],
|
|
258
|
+
hashes: Map<string, string>,
|
|
259
|
+
): void {
|
|
260
|
+
// Sort descending by line number (apply from bottom to top)
|
|
261
|
+
const sorted = [...edits].sort((a, b) => {
|
|
262
|
+
const lineA = parseInt(
|
|
263
|
+
(a.startHash || a.afterHash || "0").split(":")[0],
|
|
264
|
+
10,
|
|
265
|
+
)
|
|
266
|
+
const lineB = parseInt(
|
|
267
|
+
(b.startHash || b.afterHash || "0").split(":")[0],
|
|
268
|
+
10,
|
|
269
|
+
)
|
|
270
|
+
return lineB - lineA
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
for (const edit of sorted) {
|
|
274
|
+
if (edit.afterHash) {
|
|
275
|
+
hashes = validateHash(filePath, edit.afterHash, hashes)
|
|
276
|
+
const anchorLine = parseInt(edit.afterHash.split(":")[0], 10)
|
|
277
|
+
const newLines = edit.content.split("\n")
|
|
278
|
+
// Insert after anchorLine (1-indexed → splice at anchorLine)
|
|
279
|
+
lines.splice(anchorLine, 0, ...newLines)
|
|
280
|
+
} else if (edit.startHash) {
|
|
281
|
+
hashes = validateHash(filePath, edit.startHash, hashes)
|
|
282
|
+
if (edit.endHash) {
|
|
283
|
+
hashes = validateHash(filePath, edit.endHash, hashes)
|
|
284
|
+
}
|
|
285
|
+
const startLine = parseInt(edit.startHash.split(":")[0], 10)
|
|
286
|
+
const endLine = edit.endHash
|
|
287
|
+
? parseInt(edit.endHash.split(":")[0], 10)
|
|
288
|
+
: startLine
|
|
289
|
+
|
|
290
|
+
if (endLine < startLine) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`endHash line (${endLine}) must be >= startHash line (${startLine})`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const newLines = edit.content.split("\n")
|
|
297
|
+
// Replace lines startLine..endLine (1-indexed)
|
|
298
|
+
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Hashline edit schema — shared shape for individual edits */
|
|
304
|
+
const editShape = {
|
|
116
305
|
filePath: z
|
|
117
306
|
.string()
|
|
118
307
|
.describe("The absolute path to the file to modify"),
|
|
@@ -137,13 +326,43 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
137
326
|
content: z
|
|
138
327
|
.string()
|
|
139
328
|
.describe("The new content to insert or replace with"),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Schema for edit tool — edits array, single file per call */
|
|
332
|
+
const editParams = z.object({
|
|
333
|
+
edits: z
|
|
334
|
+
.array(z.object(editShape))
|
|
335
|
+
.describe(
|
|
336
|
+
"Array of edits to apply. All edits must target the same file.",
|
|
337
|
+
),
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
/** Schema for apply_patch tool — edits array, multi-file supported */
|
|
341
|
+
const patchParams = z.object({
|
|
342
|
+
edits: z
|
|
343
|
+
.array(z.object(editShape))
|
|
344
|
+
.describe(
|
|
345
|
+
"Array of edits to apply. Multiple files and multiple edits per file are supported.",
|
|
346
|
+
),
|
|
140
347
|
})
|
|
141
348
|
|
|
142
|
-
const
|
|
349
|
+
const editDescription = [
|
|
143
350
|
"Edit a file using hashline references from the most recent read output.",
|
|
144
351
|
"Each line is tagged as `<line>:<hash>| <content>`.",
|
|
352
|
+
"Pass an `edits` array with one or more edits (all must target the same file).",
|
|
353
|
+
"",
|
|
354
|
+
"Three operations per edit:",
|
|
355
|
+
"1. Replace line: startHash only → replaces that single line",
|
|
356
|
+
"2. Replace range: startHash + endHash → replaces all lines in range",
|
|
357
|
+
"3. Insert after: afterHash → inserts content after that line (no replacement)",
|
|
358
|
+
].join("\n")
|
|
359
|
+
|
|
360
|
+
const patchDescription = [
|
|
361
|
+
"Edit one or more files using hashline references from read output.",
|
|
362
|
+
"Each line is tagged as `<line>:<hash>| <content>`.",
|
|
363
|
+
"Pass an `edits` array — multiple files and multiple edits per file are supported.",
|
|
145
364
|
"",
|
|
146
|
-
"Three operations:",
|
|
365
|
+
"Three operations per edit:",
|
|
147
366
|
"1. Replace line: startHash only → replaces that single line",
|
|
148
367
|
"2. Replace range: startHash + endHash → replaces all lines in range",
|
|
149
368
|
"3. Insert after: afterHash → inserts content after that line (no replacement)",
|
|
@@ -153,16 +372,27 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
153
372
|
// ── Read: tag each line with its content hash ──────────────────────
|
|
154
373
|
"tool.execute.after": async (input, output) => {
|
|
155
374
|
if (input.tool === "edit") {
|
|
375
|
+
// Recompute hashes from the edited file so subsequent edits
|
|
376
|
+
// get diagnostic "line N now has hash X" errors instead of
|
|
377
|
+
// a generic "not found" when using stale refs.
|
|
156
378
|
const filePath = resolvePath(input.args.filePath)
|
|
157
|
-
|
|
379
|
+
try {
|
|
380
|
+
computeFileHashes(filePath)
|
|
381
|
+
} catch {
|
|
382
|
+
fileHashes.delete(filePath)
|
|
383
|
+
}
|
|
158
384
|
return
|
|
159
385
|
}
|
|
160
386
|
|
|
161
387
|
if (input.tool === "apply_patch") {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
388
|
+
for (const fp of pendingPatchFilePaths) {
|
|
389
|
+
try {
|
|
390
|
+
computeFileHashes(fp)
|
|
391
|
+
} catch {
|
|
392
|
+
fileHashes.delete(fp)
|
|
393
|
+
}
|
|
165
394
|
}
|
|
395
|
+
pendingPatchFilePaths = []
|
|
166
396
|
return
|
|
167
397
|
}
|
|
168
398
|
|
|
@@ -212,9 +442,13 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
212
442
|
// Requires PR #4956 (tool.definition hook) to take effect.
|
|
213
443
|
// OpenCode shows `edit` for Anthropic models, `apply_patch` for Codex.
|
|
214
444
|
"tool.definition": async (input: any, output: any) => {
|
|
215
|
-
if (input.toolID
|
|
216
|
-
|
|
217
|
-
|
|
445
|
+
if (input.toolID === "edit") {
|
|
446
|
+
output.description = editDescription
|
|
447
|
+
output.parameters = editParams
|
|
448
|
+
} else if (input.toolID === "apply_patch") {
|
|
449
|
+
output.description = patchDescription
|
|
450
|
+
output.parameters = patchParams
|
|
451
|
+
}
|
|
218
452
|
},
|
|
219
453
|
|
|
220
454
|
// ── System prompt: instruct the model to use hashline edits ────────
|
|
@@ -226,18 +460,25 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
226
460
|
"When you read a file, each line is tagged with a hash: `<lineNumber>:<hash>| <content>`.",
|
|
227
461
|
"You MUST use these hash references when editing files. Do NOT use oldString/newString or patchText.",
|
|
228
462
|
"",
|
|
229
|
-
"
|
|
463
|
+
"Pass an `edits` array with one or more edits. Each edit has: filePath, and one of:",
|
|
464
|
+
"",
|
|
465
|
+
"1. **Replace line** — `startHash` + `content`:",
|
|
466
|
+
' `{ filePath: "...", startHash: "3:cc7", content: "new line" }`',
|
|
467
|
+
"",
|
|
468
|
+
"2. **Replace range** — `startHash` + `endHash` + `content`:",
|
|
469
|
+
' `{ filePath: "...", startHash: "3:cc7", endHash: "5:e60", content: "line3\\nline4\\nline5" }`',
|
|
230
470
|
"",
|
|
231
|
-
"
|
|
232
|
-
' `
|
|
471
|
+
"3. **Insert after** — `afterHash` + `content`:",
|
|
472
|
+
' `{ filePath: "...", afterHash: "3:cc7", content: " inserted line" }`',
|
|
233
473
|
"",
|
|
234
|
-
"
|
|
235
|
-
' `startHash: "3:cc7",
|
|
474
|
+
"Multiple edits can be batched in a single call:",
|
|
475
|
+
' `{ edits: [{ filePath: "...", startHash: "3:cc7", content: "..." }, { filePath: "...", afterHash: "7:e2c", content: "..." }] }`',
|
|
236
476
|
"",
|
|
237
|
-
"
|
|
238
|
-
|
|
477
|
+
"IMPORTANT: The hash value (e.g. `cc7`) is the EXACT 3-character code shown after the line number and colon.",
|
|
478
|
+
"Copy it exactly as shown — do NOT include the `|` separator or any surrounding spaces.",
|
|
479
|
+
"Example: for line `42:a3f| function hello()`, the hash reference is `42:a3f` (not `42:a3f|`).",
|
|
239
480
|
"",
|
|
240
|
-
"NEVER pass oldString, newString, or patchText. ALWAYS use
|
|
481
|
+
"NEVER pass oldString, newString, or patchText. ALWAYS use the edits array with hash references.",
|
|
241
482
|
].join("\n"),
|
|
242
483
|
)
|
|
243
484
|
},
|
|
@@ -249,13 +490,82 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
249
490
|
const args = output.args
|
|
250
491
|
|
|
251
492
|
// Raw patchText with no hashline args → let normal patch through
|
|
252
|
-
if (
|
|
493
|
+
if (
|
|
494
|
+
args.patchText &&
|
|
495
|
+
!args.edits &&
|
|
496
|
+
!args.startHash &&
|
|
497
|
+
!args.afterHash
|
|
498
|
+
)
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
// ── Multi-file edits array ──
|
|
502
|
+
if (args.edits && Array.isArray(args.edits)) {
|
|
503
|
+
// Normalize all hash refs up front
|
|
504
|
+
const edits = (args.edits as HashlineEdit[]).map(normalizeEdit)
|
|
505
|
+
|
|
506
|
+
// Group edits by file path (preserving order within each file)
|
|
507
|
+
const editsByFile = new Map<
|
|
508
|
+
string,
|
|
509
|
+
{ absPath: string; relPath: string; edits: HashlineEdit[] }
|
|
510
|
+
>()
|
|
511
|
+
|
|
512
|
+
for (const edit of edits) {
|
|
513
|
+
const absPath = resolvePath(edit.filePath)
|
|
514
|
+
let entry = editsByFile.get(absPath)
|
|
515
|
+
if (!entry) {
|
|
516
|
+
const relPath = path
|
|
517
|
+
.relative(directory, absPath)
|
|
518
|
+
.split(path.sep)
|
|
519
|
+
.join("/")
|
|
520
|
+
entry = { absPath, relPath, edits: [] }
|
|
521
|
+
editsByFile.set(absPath, entry)
|
|
522
|
+
}
|
|
523
|
+
entry.edits.push(edit)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const patchLines: string[] = ["*** Begin Patch"]
|
|
527
|
+
const editedPaths: string[] = []
|
|
528
|
+
|
|
529
|
+
for (const [absPath, { relPath, edits }] of editsByFile) {
|
|
530
|
+
editedPaths.push(absPath)
|
|
531
|
+
let hashes = ensureHashes(absPath)
|
|
532
|
+
if (!hashes) continue
|
|
533
|
+
|
|
534
|
+
// Sort edits by line number so chunks apply top-to-bottom
|
|
535
|
+
edits.sort((a, b) => {
|
|
536
|
+
const lineA = parseInt(
|
|
537
|
+
(a.startHash || a.afterHash || "0").split(":")[0],
|
|
538
|
+
10,
|
|
539
|
+
)
|
|
540
|
+
const lineB = parseInt(
|
|
541
|
+
(b.startHash || b.afterHash || "0").split(":")[0],
|
|
542
|
+
10,
|
|
543
|
+
)
|
|
544
|
+
return lineA - lineB
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
// One *** Update File section with multiple @@ chunks
|
|
548
|
+
patchLines.push(`*** Update File: ${relPath}`)
|
|
549
|
+
|
|
550
|
+
for (const edit of edits) {
|
|
551
|
+
const chunkLines = generatePatchChunk(absPath, edit, hashes)
|
|
552
|
+
patchLines.push(...chunkLines)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
253
555
|
|
|
254
|
-
|
|
556
|
+
patchLines.push("*** End Patch")
|
|
557
|
+
|
|
558
|
+
pendingPatchFilePaths = editedPaths
|
|
559
|
+
args.patchText = patchLines.join("\n")
|
|
560
|
+
delete args.edits
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Single-file fallback (backwards compat) ──
|
|
255
565
|
if (!args.startHash && !args.afterHash) return
|
|
256
566
|
|
|
257
567
|
const filePath = resolvePath(args.filePath)
|
|
258
|
-
|
|
568
|
+
pendingPatchFilePaths = [filePath]
|
|
259
569
|
const relativePath = path
|
|
260
570
|
.relative(directory, filePath)
|
|
261
571
|
.split(path.sep)
|
|
@@ -264,68 +574,22 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
264
574
|
let hashes = ensureHashes(filePath)
|
|
265
575
|
if (!hashes) return
|
|
266
576
|
|
|
577
|
+
const edit: HashlineEdit = normalizeEdit({
|
|
578
|
+
filePath: args.filePath,
|
|
579
|
+
startHash: args.startHash,
|
|
580
|
+
endHash: args.endHash,
|
|
581
|
+
afterHash: args.afterHash,
|
|
582
|
+
content: args.content,
|
|
583
|
+
})
|
|
267
584
|
const patchLines: string[] = [
|
|
268
585
|
"*** Begin Patch",
|
|
269
586
|
`*** Update File: ${relativePath}`,
|
|
270
587
|
]
|
|
271
|
-
|
|
272
|
-
if (args.afterHash) {
|
|
273
|
-
// ── Insert after ──
|
|
274
|
-
hashes = validateHash(filePath, args.afterHash, hashes)
|
|
275
|
-
const anchorContent = hashes.get(args.afterHash)!
|
|
276
|
-
const anchorLine = parseInt(args.afterHash.split(":")[0], 10)
|
|
277
|
-
|
|
278
|
-
// Use line before anchor as @@ context for positioning
|
|
279
|
-
const ctx =
|
|
280
|
-
anchorLine > 1
|
|
281
|
-
? (getLineByNumber(hashes, anchorLine - 1) ?? "")
|
|
282
|
-
: ""
|
|
283
|
-
patchLines.push(`@@ ${ctx}`)
|
|
284
|
-
patchLines.push(` ${anchorContent}`) // space = keep line
|
|
285
|
-
for (const line of args.content.split("\n")) {
|
|
286
|
-
patchLines.push(`+${line}`)
|
|
287
|
-
}
|
|
288
|
-
} else {
|
|
289
|
-
// ── Replace (single line or range) ──
|
|
290
|
-
hashes = validateHash(filePath, args.startHash, hashes)
|
|
291
|
-
if (args.endHash) {
|
|
292
|
-
hashes = validateHash(filePath, args.endHash, hashes)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const startLine = parseInt(args.startHash.split(":")[0], 10)
|
|
296
|
-
const endLine = args.endHash
|
|
297
|
-
? parseInt(args.endHash.split(":")[0], 10)
|
|
298
|
-
: startLine
|
|
299
|
-
|
|
300
|
-
if (endLine < startLine) {
|
|
301
|
-
throw new Error(
|
|
302
|
-
`endHash line (${endLine}) must be >= startHash line (${startLine})`,
|
|
303
|
-
)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Use line before range as @@ context for positioning
|
|
307
|
-
const ctx =
|
|
308
|
-
startLine > 1
|
|
309
|
-
? (getLineByNumber(hashes, startLine - 1) ?? "")
|
|
310
|
-
: ""
|
|
311
|
-
patchLines.push(`@@ ${ctx}`)
|
|
312
|
-
|
|
313
|
-
// Old lines (-)
|
|
314
|
-
const oldLines = collectRange(filePath, hashes, startLine, endLine)
|
|
315
|
-
for (const line of oldLines) {
|
|
316
|
-
patchLines.push(`-${line}`)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// New lines (+)
|
|
320
|
-
for (const line of args.content.split("\n")) {
|
|
321
|
-
patchLines.push(`+${line}`)
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
588
|
+
patchLines.push(...generatePatchChunk(filePath, edit, hashes))
|
|
325
589
|
patchLines.push("*** End Patch")
|
|
590
|
+
|
|
326
591
|
args.patchText = patchLines.join("\n")
|
|
327
592
|
|
|
328
|
-
// Clean up hashline fields — apply_patch only reads patchText
|
|
329
593
|
delete args.filePath
|
|
330
594
|
delete args.startHash
|
|
331
595
|
delete args.endHash
|
|
@@ -339,6 +603,50 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
339
603
|
|
|
340
604
|
const args = output.args
|
|
341
605
|
|
|
606
|
+
// ── Multi-edit via edits array ──
|
|
607
|
+
if (args.edits && Array.isArray(args.edits)) {
|
|
608
|
+
// Normalize all hash refs up front
|
|
609
|
+
const edits = (args.edits as HashlineEdit[]).map(normalizeEdit)
|
|
610
|
+
if (edits.length === 0) return
|
|
611
|
+
|
|
612
|
+
// All edits must target the same file (edit tool is single-file)
|
|
613
|
+
const filePath = resolvePath(edits[0].filePath)
|
|
614
|
+
const uniqueFiles = new Set(edits.map((e) => resolvePath(e.filePath)))
|
|
615
|
+
if (uniqueFiles.size > 1) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
"The edit tool supports one file per call. Make separate calls for each file.",
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
let hashes = ensureHashes(filePath)
|
|
622
|
+
if (!hashes) return
|
|
623
|
+
|
|
624
|
+
// Validate all hashes up front
|
|
625
|
+
for (const edit of edits) {
|
|
626
|
+
if (edit.afterHash) {
|
|
627
|
+
hashes = validateHash(filePath, edit.afterHash, hashes)
|
|
628
|
+
} else if (edit.startHash) {
|
|
629
|
+
hashes = validateHash(filePath, edit.startHash, hashes)
|
|
630
|
+
if (edit.endHash)
|
|
631
|
+
hashes = validateHash(filePath, edit.endHash, hashes)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Read original file, apply all edits, produce oldString/newString
|
|
636
|
+
const originalContent = fs.readFileSync(filePath, "utf-8")
|
|
637
|
+
const lines = originalContent.split("\n")
|
|
638
|
+
applyEditsToLines(filePath, edits, lines, hashes)
|
|
639
|
+
const newContent = lines.join("\n")
|
|
640
|
+
|
|
641
|
+
args.filePath = filePath
|
|
642
|
+
args.oldString = originalContent
|
|
643
|
+
args.newString = newContent
|
|
644
|
+
delete args.edits
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Single-edit fallback (backwards compat) ──
|
|
649
|
+
|
|
342
650
|
// Reject oldString edits for files we have hashes for — force hashline usage
|
|
343
651
|
if (args.oldString && !args.startHash) {
|
|
344
652
|
const filePath = resolvePath(args.filePath)
|
|
@@ -351,14 +659,18 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
351
659
|
].join(" "),
|
|
352
660
|
)
|
|
353
661
|
}
|
|
354
|
-
// No hashes for this file — allow normal edit
|
|
355
662
|
return
|
|
356
663
|
}
|
|
357
664
|
|
|
358
665
|
// Only intercept hashline edits; fall through for normal edits
|
|
359
666
|
if (!args.startHash && !args.afterHash) return
|
|
360
667
|
|
|
361
|
-
//
|
|
668
|
+
// Normalize hash refs
|
|
669
|
+
if (args.startHash) args.startHash = normalizeHashRef(args.startHash)
|
|
670
|
+
if (args.endHash) args.endHash = normalizeHashRef(args.endHash)
|
|
671
|
+
if (args.afterHash) args.afterHash = normalizeHashRef(args.afterHash)
|
|
672
|
+
|
|
673
|
+
// Insert after
|
|
362
674
|
if (args.afterHash) {
|
|
363
675
|
const filePath = resolvePath(args.filePath)
|
|
364
676
|
let hashes = ensureHashes(filePath)
|
|
@@ -374,11 +686,11 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
374
686
|
return
|
|
375
687
|
}
|
|
376
688
|
|
|
689
|
+
// Replace (single line or range)
|
|
377
690
|
const filePath = resolvePath(args.filePath)
|
|
378
691
|
let hashes = ensureHashes(filePath)
|
|
379
692
|
if (!hashes) return
|
|
380
693
|
|
|
381
|
-
// Validate startHash
|
|
382
694
|
hashes = validateHash(filePath, args.startHash, hashes)
|
|
383
695
|
|
|
384
696
|
const startLine = parseInt(args.startHash.split(":")[0], 10)
|
|
@@ -386,12 +698,8 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
386
698
|
? parseInt(args.endHash.split(":")[0], 10)
|
|
387
699
|
: startLine
|
|
388
700
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
fileHashes.delete(filePath)
|
|
392
|
-
throw new Error(
|
|
393
|
-
`Hash reference "${args.endHash}" not found. The file may have changed since last read. Please re-read the file.`,
|
|
394
|
-
)
|
|
701
|
+
if (args.endHash) {
|
|
702
|
+
hashes = validateHash(filePath, args.endHash, hashes)
|
|
395
703
|
}
|
|
396
704
|
|
|
397
705
|
if (endLine < startLine) {
|
|
@@ -400,15 +708,10 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
400
708
|
)
|
|
401
709
|
}
|
|
402
710
|
|
|
403
|
-
// Build oldString from the line range
|
|
404
711
|
const rangeLines = collectRange(filePath, hashes, startLine, endLine)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
// Set resolved args for the built-in edit tool
|
|
408
|
-
args.oldString = oldString
|
|
712
|
+
args.oldString = rangeLines.join("\n")
|
|
409
713
|
args.newString = args.content
|
|
410
714
|
|
|
411
|
-
// Remove hashline-specific fields so the built-in edit doesn't choke
|
|
412
715
|
delete args.startHash
|
|
413
716
|
delete args.endHash
|
|
414
717
|
delete args.content
|