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.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/package.json +1 -1
  3. 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` (instead of `oldString`/`newString`).
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, resolves references back to `oldString`/`newString`, and passes them to the built-in edit tool.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-hashline",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin that tags lines with content hashes for reliable LLM edits",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- // ── Edit schema: replace oldString/newString with hash references ──
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
- "Edit a file using hashline references from the most recent read output.",
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 newString. ALWAYS use startHash/afterHash + content.",
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 the built-in edit runs ────
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
- "Use startHash (e.g. \"3:cc7\") instead of oldString.",
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 = fileHashes.get(filePath)
188
- if (!hashes) {
189
- try {
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 = fileHashes.get(filePath)
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; if stale, re-read and retry once
235
- if (!hashes.has(args.startHash)) {
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: string[] = []
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