open-hashline 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/index.ts +225 -109
package/README.md
CHANGED
|
@@ -39,9 +39,9 @@ The plugin resolves hashes back to actual content before the built-in edit tool
|
|
|
39
39
|
|
|
40
40
|
1. **Read** — The `tool.execute.after` hook transforms read output, tagging each line as `<line>:<hash>| <content>` and storing a per-file hash map in memory.
|
|
41
41
|
|
|
42
|
-
2. **Edit schema** — The `tool.definition` hook replaces the edit tool's parameters with `startHash`, `endHash`, `afterHash`, and `content
|
|
42
|
+
2. **Edit schema** — The `tool.definition` hook replaces the edit tool's parameters (and `apply_patch` for Codex models) with `startHash`, `endHash`, `afterHash`, and `content`.
|
|
43
43
|
|
|
44
|
-
3. **Edit resolve** — The `tool.execute.before` hook intercepts hash-based edits
|
|
44
|
+
3. **Edit resolve** — The `tool.execute.before` hook intercepts hash-based edits and resolves them to the format the underlying tool expects — `oldString`/`newString` for `edit` (Anthropic), or generated `patchText` for `apply_patch` (Codex).
|
|
45
45
|
|
|
46
46
|
4. **System prompt** — The `experimental.chat.system.transform` hook injects instructions so the model knows to use hashline references.
|
|
47
47
|
|
|
@@ -146,7 +146,8 @@ Collisions are rare and disambiguated by line number — the full reference is `
|
|
|
146
146
|
| Scenario | Behavior |
|
|
147
147
|
|---|---|
|
|
148
148
|
| **Stale hashes** | File changed since last read — edit rejected, model told to re-read |
|
|
149
|
-
| **File not previously read** | Falls through to normal `oldString`/`newString` edit |
|
|
149
|
+
| **File not previously read** | Falls through to normal `oldString`/`newString` or `patchText` edit |
|
|
150
|
+
| **Codex models** | Hash refs resolved to `patchText` format for `apply_patch` tool |
|
|
150
151
|
| **Hash collision** | Line number provides disambiguation |
|
|
151
152
|
| **Partial/offset reads** | Hashes merge with existing stored hashes for the file |
|
|
152
153
|
| **Edit invalidation** | Stored hashes cleared after any edit to prevent stale references |
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -21,6 +21,9 @@ function hashLine(content: string): string {
|
|
|
21
21
|
/** Per-file mapping: hash ref (e.g. "42:a3f") → line content */
|
|
22
22
|
const fileHashes = new Map<string, Map<string, string>>()
|
|
23
23
|
|
|
24
|
+
/** Track file path for apply_patch hash invalidation across before/after hooks */
|
|
25
|
+
let pendingPatchFilePath: string | undefined
|
|
26
|
+
|
|
24
27
|
export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
25
28
|
function resolvePath(filePath: string): string {
|
|
26
29
|
if (path.isAbsolute(filePath)) return path.normalize(filePath)
|
|
@@ -40,16 +43,129 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
40
43
|
return hashes
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
/** Get line content by line number from hash map */
|
|
47
|
+
function getLineByNumber(
|
|
48
|
+
hashes: Map<string, string>,
|
|
49
|
+
lineNum: number,
|
|
50
|
+
): string | undefined {
|
|
51
|
+
for (const [ref, content] of hashes) {
|
|
52
|
+
if (ref.startsWith(`${lineNum}:`)) return content
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Validate a hash reference exists, re-reading file once if stale */
|
|
58
|
+
function validateHash(
|
|
59
|
+
filePath: string,
|
|
60
|
+
hashRef: string,
|
|
61
|
+
hashes: Map<string, string>,
|
|
62
|
+
): Map<string, string> {
|
|
63
|
+
if (hashes.has(hashRef)) return hashes
|
|
64
|
+
try {
|
|
65
|
+
hashes = computeFileHashes(filePath)
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Cannot read file "${filePath}" to verify hash references.`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
if (!hashes.has(hashRef)) {
|
|
72
|
+
fileHashes.delete(filePath)
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Hash reference "${hashRef}" not found. The file may have changed since last read. Please re-read the file.`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return hashes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Ensure hashes exist for a file, computing them if needed */
|
|
81
|
+
function ensureHashes(filePath: string): Map<string, string> | undefined {
|
|
82
|
+
let hashes = fileHashes.get(filePath)
|
|
83
|
+
if (!hashes) {
|
|
84
|
+
try {
|
|
85
|
+
hashes = computeFileHashes(filePath)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return hashes
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Collect old lines from a line range, throwing if any are missing */
|
|
94
|
+
function collectRange(
|
|
95
|
+
filePath: string,
|
|
96
|
+
hashes: Map<string, string>,
|
|
97
|
+
startLine: number,
|
|
98
|
+
endLine: number,
|
|
99
|
+
): string[] {
|
|
100
|
+
const lines: string[] = []
|
|
101
|
+
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
|
|
102
|
+
const content = getLineByNumber(hashes, lineNum)
|
|
103
|
+
if (content === undefined) {
|
|
104
|
+
fileHashes.delete(filePath)
|
|
105
|
+
throw new Error(
|
|
106
|
+
`No hash found for line ${lineNum} in range ${startLine}-${endLine}. The file may have changed. Please re-read the file.`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
lines.push(content)
|
|
110
|
+
}
|
|
111
|
+
return lines
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Build hashline parameter schema (shared between edit and apply_patch) */
|
|
115
|
+
const hashlineParams = z.object({
|
|
116
|
+
filePath: z
|
|
117
|
+
.string()
|
|
118
|
+
.describe("The absolute path to the file to modify"),
|
|
119
|
+
startHash: z
|
|
120
|
+
.string()
|
|
121
|
+
.optional()
|
|
122
|
+
.describe(
|
|
123
|
+
'Hash reference for the start line to replace (e.g. "42:a3f")',
|
|
124
|
+
),
|
|
125
|
+
endHash: z
|
|
126
|
+
.string()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe(
|
|
129
|
+
"Hash reference for the end line (for multi-line range replacement)",
|
|
130
|
+
),
|
|
131
|
+
afterHash: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe(
|
|
135
|
+
"Hash reference for the line to insert after (no replacement)",
|
|
136
|
+
),
|
|
137
|
+
content: z
|
|
138
|
+
.string()
|
|
139
|
+
.describe("The new content to insert or replace with"),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const hashlineDescription = [
|
|
143
|
+
"Edit a file using hashline references from the most recent read output.",
|
|
144
|
+
"Each line is tagged as `<line>:<hash>| <content>`.",
|
|
145
|
+
"",
|
|
146
|
+
"Three operations:",
|
|
147
|
+
"1. Replace line: startHash only → replaces that single line",
|
|
148
|
+
"2. Replace range: startHash + endHash → replaces all lines in range",
|
|
149
|
+
"3. Insert after: afterHash → inserts content after that line (no replacement)",
|
|
150
|
+
].join("\n")
|
|
151
|
+
|
|
43
152
|
return {
|
|
44
153
|
// ── Read: tag each line with its content hash ──────────────────────
|
|
45
154
|
"tool.execute.after": async (input, output) => {
|
|
46
155
|
if (input.tool === "edit") {
|
|
47
|
-
// Invalidate stored hashes after any edit
|
|
48
156
|
const filePath = resolvePath(input.args.filePath)
|
|
49
157
|
fileHashes.delete(filePath)
|
|
50
158
|
return
|
|
51
159
|
}
|
|
52
160
|
|
|
161
|
+
if (input.tool === "apply_patch") {
|
|
162
|
+
if (pendingPatchFilePath) {
|
|
163
|
+
fileHashes.delete(pendingPatchFilePath)
|
|
164
|
+
pendingPatchFilePath = undefined
|
|
165
|
+
}
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
53
169
|
if (input.tool !== "read") return
|
|
54
170
|
|
|
55
171
|
// Skip directory reads
|
|
@@ -92,43 +208,13 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
92
208
|
}
|
|
93
209
|
},
|
|
94
210
|
|
|
95
|
-
// ──
|
|
211
|
+
// ── Tool schema: replace params with hash references ─────────────
|
|
96
212
|
// Requires PR #4956 (tool.definition hook) to take effect.
|
|
213
|
+
// OpenCode shows `edit` for Anthropic models, `apply_patch` for Codex.
|
|
97
214
|
"tool.definition": async (input: any, output: any) => {
|
|
98
|
-
if (input.toolID !== "edit") return
|
|
99
|
-
output.description =
|
|
100
|
-
|
|
101
|
-
"Each line is tagged as `<line>:<hash>| <content>`.",
|
|
102
|
-
"",
|
|
103
|
-
"Three operations:",
|
|
104
|
-
"1. Replace line: startHash only → replaces that single line",
|
|
105
|
-
"2. Replace range: startHash + endHash → replaces all lines in range",
|
|
106
|
-
"3. Insert after: afterHash → inserts content after that line (no replacement)",
|
|
107
|
-
].join("\n")
|
|
108
|
-
output.parameters = z.object({
|
|
109
|
-
filePath: z.string().describe("The absolute path to the file to modify"),
|
|
110
|
-
startHash: z
|
|
111
|
-
.string()
|
|
112
|
-
.optional()
|
|
113
|
-
.describe(
|
|
114
|
-
'Hash reference for the start line to replace (e.g. "42:a3f")',
|
|
115
|
-
),
|
|
116
|
-
endHash: z
|
|
117
|
-
.string()
|
|
118
|
-
.optional()
|
|
119
|
-
.describe(
|
|
120
|
-
"Hash reference for the end line (for multi-line range replacement)",
|
|
121
|
-
),
|
|
122
|
-
afterHash: z
|
|
123
|
-
.string()
|
|
124
|
-
.optional()
|
|
125
|
-
.describe(
|
|
126
|
-
"Hash reference for the line to insert after (no replacement)",
|
|
127
|
-
),
|
|
128
|
-
content: z
|
|
129
|
-
.string()
|
|
130
|
-
.describe("The new content to insert or replace with"),
|
|
131
|
-
})
|
|
215
|
+
if (input.toolID !== "edit" && input.toolID !== "apply_patch") return
|
|
216
|
+
output.description = hashlineDescription
|
|
217
|
+
output.parameters = hashlineParams
|
|
132
218
|
},
|
|
133
219
|
|
|
134
220
|
// ── System prompt: instruct the model to use hashline edits ────────
|
|
@@ -138,7 +224,7 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
138
224
|
"## Hashline Edit Mode (MANDATORY)",
|
|
139
225
|
"",
|
|
140
226
|
"When you read a file, each line is tagged with a hash: `<lineNumber>:<hash>| <content>`.",
|
|
141
|
-
"You MUST use these hash references when editing files. Do NOT use oldString/newString.",
|
|
227
|
+
"You MUST use these hash references when editing files. Do NOT use oldString/newString or patchText.",
|
|
142
228
|
"",
|
|
143
229
|
"Three operations:",
|
|
144
230
|
"",
|
|
@@ -151,13 +237,104 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
151
237
|
"3. **Insert after** — insert new content after a line (without replacing it):",
|
|
152
238
|
' `afterHash: "3:cc7", content: " \\"newKey\\": \\"newValue\\","` ',
|
|
153
239
|
"",
|
|
154
|
-
"NEVER pass oldString or
|
|
240
|
+
"NEVER pass oldString, newString, or patchText. ALWAYS use startHash/afterHash + content.",
|
|
155
241
|
].join("\n"),
|
|
156
242
|
)
|
|
157
243
|
},
|
|
158
244
|
|
|
159
|
-
// ── Edit: resolve hash references before
|
|
245
|
+
// ── Edit/Patch: resolve hash references before built-in tool runs ─
|
|
160
246
|
"tool.execute.before": async (input, output) => {
|
|
247
|
+
// ── apply_patch: resolve hashes → generate patchText ──
|
|
248
|
+
if (input.tool === "apply_patch") {
|
|
249
|
+
const args = output.args
|
|
250
|
+
|
|
251
|
+
// Raw patchText with no hashline args → let normal patch through
|
|
252
|
+
if (args.patchText && !args.startHash && !args.afterHash) return
|
|
253
|
+
|
|
254
|
+
// No hashline args at all → nothing to do
|
|
255
|
+
if (!args.startHash && !args.afterHash) return
|
|
256
|
+
|
|
257
|
+
const filePath = resolvePath(args.filePath)
|
|
258
|
+
pendingPatchFilePath = filePath
|
|
259
|
+
const relativePath = path
|
|
260
|
+
.relative(directory, filePath)
|
|
261
|
+
.split(path.sep)
|
|
262
|
+
.join("/")
|
|
263
|
+
|
|
264
|
+
let hashes = ensureHashes(filePath)
|
|
265
|
+
if (!hashes) return
|
|
266
|
+
|
|
267
|
+
const patchLines: string[] = [
|
|
268
|
+
"*** Begin Patch",
|
|
269
|
+
`*** Update File: ${relativePath}`,
|
|
270
|
+
]
|
|
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
|
+
|
|
325
|
+
patchLines.push("*** End Patch")
|
|
326
|
+
args.patchText = patchLines.join("\n")
|
|
327
|
+
|
|
328
|
+
// Clean up hashline fields — apply_patch only reads patchText
|
|
329
|
+
delete args.filePath
|
|
330
|
+
delete args.startHash
|
|
331
|
+
delete args.endHash
|
|
332
|
+
delete args.afterHash
|
|
333
|
+
delete args.content
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── edit: resolve hashes → oldString/newString ──
|
|
161
338
|
if (input.tool !== "edit") return
|
|
162
339
|
|
|
163
340
|
const args = output.args
|
|
@@ -169,7 +346,7 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
169
346
|
throw new Error(
|
|
170
347
|
[
|
|
171
348
|
"You must use hashline references to edit this file.",
|
|
172
|
-
|
|
349
|
+
'Use startHash (e.g. "3:cc7") instead of oldString.',
|
|
173
350
|
"Refer to the hash markers from the read output.",
|
|
174
351
|
].join(" "),
|
|
175
352
|
)
|
|
@@ -184,32 +361,11 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
184
361
|
// ── Insert after: append content after the referenced line ──
|
|
185
362
|
if (args.afterHash) {
|
|
186
363
|
const filePath = resolvePath(args.filePath)
|
|
187
|
-
let hashes =
|
|
188
|
-
if (!hashes)
|
|
189
|
-
|
|
190
|
-
hashes = computeFileHashes(filePath)
|
|
191
|
-
} catch {
|
|
192
|
-
return
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (!hashes.has(args.afterHash)) {
|
|
196
|
-
try {
|
|
197
|
-
hashes = computeFileHashes(filePath)
|
|
198
|
-
} catch {
|
|
199
|
-
throw new Error(
|
|
200
|
-
`Cannot read file "${args.filePath}" to verify hash references.`,
|
|
201
|
-
)
|
|
202
|
-
}
|
|
203
|
-
if (!hashes.has(args.afterHash)) {
|
|
204
|
-
fileHashes.delete(filePath)
|
|
205
|
-
throw new Error(
|
|
206
|
-
`Hash reference "${args.afterHash}" not found. The file may have changed since last read. Please re-read the file.`,
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
}
|
|
364
|
+
let hashes = ensureHashes(filePath)
|
|
365
|
+
if (!hashes) return
|
|
366
|
+
hashes = validateHash(filePath, args.afterHash, hashes)
|
|
210
367
|
|
|
211
368
|
const anchorContent = hashes.get(args.afterHash)!
|
|
212
|
-
// oldString = anchor line, newString = anchor line + new content
|
|
213
369
|
args.oldString = anchorContent
|
|
214
370
|
args.newString = anchorContent + "\n" + args.content
|
|
215
371
|
|
|
@@ -219,34 +375,11 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
219
375
|
}
|
|
220
376
|
|
|
221
377
|
const filePath = resolvePath(args.filePath)
|
|
222
|
-
let hashes =
|
|
223
|
-
|
|
224
|
-
// No stored hashes → try reading the file fresh
|
|
225
|
-
if (!hashes) {
|
|
226
|
-
try {
|
|
227
|
-
hashes = computeFileHashes(filePath)
|
|
228
|
-
} catch {
|
|
229
|
-
// Can't read file — fall through to normal edit behavior
|
|
230
|
-
return
|
|
231
|
-
}
|
|
232
|
-
}
|
|
378
|
+
let hashes = ensureHashes(filePath)
|
|
379
|
+
if (!hashes) return
|
|
233
380
|
|
|
234
|
-
// Validate startHash
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
hashes = computeFileHashes(filePath)
|
|
238
|
-
} catch {
|
|
239
|
-
throw new Error(
|
|
240
|
-
`Cannot read file "${args.filePath}" to verify hash references.`,
|
|
241
|
-
)
|
|
242
|
-
}
|
|
243
|
-
if (!hashes.has(args.startHash)) {
|
|
244
|
-
fileHashes.delete(filePath)
|
|
245
|
-
throw new Error(
|
|
246
|
-
`Hash reference "${args.startHash}" not found. The file may have changed since last read. Please re-read the file.`,
|
|
247
|
-
)
|
|
248
|
-
}
|
|
249
|
-
}
|
|
381
|
+
// Validate startHash
|
|
382
|
+
hashes = validateHash(filePath, args.startHash, hashes)
|
|
250
383
|
|
|
251
384
|
const startLine = parseInt(args.startHash.split(":")[0], 10)
|
|
252
385
|
const endLine = args.endHash
|
|
@@ -268,24 +401,7 @@ export const HashlinePlugin: Plugin = async ({ directory }) => {
|
|
|
268
401
|
}
|
|
269
402
|
|
|
270
403
|
// Build oldString from the line range
|
|
271
|
-
const rangeLines
|
|
272
|
-
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
|
|
273
|
-
let found = false
|
|
274
|
-
for (const [ref, content] of hashes) {
|
|
275
|
-
if (ref.startsWith(`${lineNum}:`)) {
|
|
276
|
-
rangeLines.push(content)
|
|
277
|
-
found = true
|
|
278
|
-
break
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (!found) {
|
|
282
|
-
fileHashes.delete(filePath)
|
|
283
|
-
throw new Error(
|
|
284
|
-
`No hash found for line ${lineNum} in range ${startLine}-${endLine}. The file may have changed. Please re-read the file.`,
|
|
285
|
-
)
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
404
|
+
const rangeLines = collectRange(filePath, hashes, startLine, endLine)
|
|
289
405
|
const oldString = rangeLines.join("\n")
|
|
290
406
|
|
|
291
407
|
// Set resolved args for the built-in edit tool
|