open-hashline 0.2.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/package.json +38 -0
  4. package/src/index.ts +303 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # open-hashline
2
+
3
+ **Stop reproducing code to edit it.** An [OpenCode](https://github.com/anomalyco/opencode) plugin that tags every line with a content hash, so the model references lines by hash instead of copying exact text.
4
+
5
+ Based on the [Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — most LLM edit failures are mechanical, not intellectual. Models know *what* to change but fail at *locating* it because they must reproduce exact content (including whitespace) to specify edit locations.
6
+
7
+ ---
8
+
9
+ ## The Problem
10
+
11
+ When an LLM edits a file, it needs to specify *which lines* to change. The standard approach requires the model to reproduce the exact content of those lines as `oldString` — including every space, tab, and quote. This is fragile:
12
+
13
+ - Whitespace mismatches cause silent failures
14
+ - Long lines get truncated or hallucinated
15
+ - Repeated content creates ambiguity
16
+ - The model wastes tokens reproducing code it already read
17
+
18
+ ## The Solution
19
+
20
+ Hashline tags every line the model reads with a short content hash:
21
+
22
+ ```
23
+ 42:a3f| function hello() {
24
+ 43:f1b| return "world";
25
+ 44:0e9| }
26
+ ```
27
+
28
+ To edit, the model just references the hash — no content reproduction needed:
29
+
30
+ ```json
31
+ { "startHash": "42:a3f", "endHash": "44:0e9", "content": "function hello() {\n return \"universe\";\n}" }
32
+ ```
33
+
34
+ The plugin resolves hashes back to actual content before the built-in edit tool runs. The TUI diff display works exactly as before.
35
+
36
+ ---
37
+
38
+ ## How It Works
39
+
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
+
42
+ 2. **Edit schema** — The `tool.definition` hook replaces the edit tool's parameters with `startHash`, `endHash`, `afterHash`, and `content` (instead of `oldString`/`newString`).
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.
45
+
46
+ 4. **System prompt** — The `experimental.chat.system.transform` hook injects instructions so the model knows to use hashline references.
47
+
48
+ No modifications are made to any built-in tools. Everything works through hooks.
49
+
50
+ ---
51
+
52
+ ## Three Edit Operations
53
+
54
+ ### 1. Replace a single line
55
+
56
+ ```json
57
+ { "startHash": "3:cc7", "content": " \"version\": \"2.0.0\"," }
58
+ ```
59
+
60
+ ### 2. Replace a range of lines
61
+
62
+ ```json
63
+ { "startHash": "10:a1b", "endHash": "15:f3d", "content": "// new implementation\nfunction updated() {\n return true;\n}" }
64
+ ```
65
+
66
+ ### 3. Insert after a line
67
+
68
+ ```json
69
+ { "afterHash": "7:e2c", "content": " \"newField\": \"value\"," }
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Installation
75
+
76
+ ### Prerequisites
77
+
78
+ - [OpenCode](https://github.com/anomalyco/opencode) with the `tool.definition` hook (PR [#4956](https://github.com/anomalyco/opencode/pull/4956))
79
+ - [Bun](https://bun.sh) runtime
80
+
81
+ ### Option 1: Install from npm
82
+
83
+ ```bash
84
+ npm install open-hashline
85
+ ```
86
+
87
+ or with Bun:
88
+
89
+ ```bash
90
+ bun add open-hashline
91
+ ```
92
+
93
+ Then add to your OpenCode config (`~/.config/opencode/config.json` or `.opencode/config.json` in your project):
94
+
95
+ ```json
96
+ {
97
+ "plugins": {
98
+ "hashline": {
99
+ "module": "open-hashline"
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### Option 2: Install from source
106
+
107
+ ```bash
108
+ git clone https://github.com/ASidorenkoCode/openhashline.git
109
+ cd openhashline
110
+ bun install
111
+ ```
112
+
113
+ Then add to your OpenCode config using a file path:
114
+
115
+ ```json
116
+ {
117
+ "plugins": {
118
+ "hashline": {
119
+ "module": "file:///path/to/openhashline/src/index.ts"
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### Start OpenCode
126
+
127
+ ```bash
128
+ opencode
129
+ ```
130
+
131
+ That's it. Read any file and you'll see hash markers on every line. Edits will automatically use hash references.
132
+
133
+ ---
134
+
135
+ ## Hash Algorithm
136
+
137
+ Each line is hashed using djb2, truncated to 3 hex characters (4096 possible values):
138
+
139
+ ```typescript
140
+ function hashLine(content: string): string {
141
+ const trimmed = content.trimEnd()
142
+ let h = 5381
143
+ for (let i = 0; i < trimmed.length; i++) {
144
+ h = ((h << 5) + h + trimmed.charCodeAt(i)) | 0
145
+ }
146
+ return (h >>> 0).toString(16).slice(-3).padStart(3, "0")
147
+ }
148
+ ```
149
+
150
+ Collisions are rare and disambiguated by line number — the full reference is `<lineNumber>:<hash>` (e.g. `42:a3f`).
151
+
152
+ ---
153
+
154
+ ## Edge Cases
155
+
156
+ | Scenario | Behavior |
157
+ |---|---|
158
+ | **Stale hashes** | File changed since last read — edit rejected, model told to re-read |
159
+ | **File not previously read** | Falls through to normal `oldString`/`newString` edit |
160
+ | **Hash collision** | Line number provides disambiguation |
161
+ | **Partial/offset reads** | Hashes merge with existing stored hashes for the file |
162
+ | **Edit invalidation** | Stored hashes cleared after any edit to prevent stale references |
163
+
164
+ ---
165
+
166
+ ## Project Structure
167
+
168
+ ```
169
+ open-hashline/
170
+ ├── src/
171
+ │ └── index.ts # Plugin implementation (single file)
172
+ ├── package.json
173
+ ├── tsconfig.json
174
+ ├── LICENSE
175
+ └── README.md
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Contributing
181
+
182
+ Contributions are welcome. Please open an issue or submit a pull request.
183
+
184
+ ## License
185
+
186
+ This project is licensed under the [MIT License](LICENSE).
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "open-hashline",
3
+ "version": "0.2.0",
4
+ "description": "OpenCode plugin that tags lines with content hashes for reliable LLM edits",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "ASidorenkoCode",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/ASidorenkoCode/openhashline.git"
11
+ },
12
+ "homepage": "https://github.com/ASidorenkoCode/openhashline",
13
+ "bugs": {
14
+ "url": "https://github.com/ASidorenkoCode/openhashline/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "hashline",
20
+ "llm",
21
+ "code-editing",
22
+ "ai"
23
+ ],
24
+ "exports": {
25
+ ".": "./src/index.ts"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
32
+ "peerDependencies": {
33
+ "@opencode-ai/plugin": "*"
34
+ },
35
+ "devDependencies": {
36
+ "@opencode-ai/plugin": "latest"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,303 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import * as fs from "fs"
4
+ import * as path from "path"
5
+
6
+ const z = tool.schema
7
+
8
+ /**
9
+ * djb2 hash of trimmed line content, truncated to 3 hex chars.
10
+ * 3 hex chars = 4096 values. Collisions are rare and disambiguated by line number.
11
+ */
12
+ function hashLine(content: string): string {
13
+ const trimmed = content.trimEnd()
14
+ let h = 5381
15
+ for (let i = 0; i < trimmed.length; i++) {
16
+ h = ((h << 5) + h + trimmed.charCodeAt(i)) | 0
17
+ }
18
+ return (h >>> 0).toString(16).slice(-3).padStart(3, "0")
19
+ }
20
+
21
+ /** Per-file mapping: hash ref (e.g. "42:a3f") → line content */
22
+ const fileHashes = new Map<string, Map<string, string>>()
23
+
24
+ export const HashlinePlugin: Plugin = async ({ directory }) => {
25
+ function resolvePath(filePath: string): string {
26
+ if (path.isAbsolute(filePath)) return path.normalize(filePath)
27
+ return path.resolve(directory, filePath)
28
+ }
29
+
30
+ /** Read file from disk and compute fresh hashes */
31
+ function computeFileHashes(filePath: string): Map<string, string> {
32
+ const content = fs.readFileSync(filePath, "utf-8")
33
+ const lines = content.split("\n")
34
+ const hashes = new Map<string, string>()
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const hash = hashLine(lines[i])
37
+ hashes.set(`${i + 1}:${hash}`, lines[i])
38
+ }
39
+ fileHashes.set(filePath, hashes)
40
+ return hashes
41
+ }
42
+
43
+ return {
44
+ // ── Read: tag each line with its content hash ──────────────────────
45
+ "tool.execute.after": async (input, output) => {
46
+ if (input.tool === "edit") {
47
+ // Invalidate stored hashes after any edit
48
+ const filePath = resolvePath(input.args.filePath)
49
+ fileHashes.delete(filePath)
50
+ return
51
+ }
52
+
53
+ if (input.tool !== "read") return
54
+
55
+ // Skip directory reads
56
+ if (output.output.includes("<type>directory</type>")) return
57
+
58
+ // Extract absolute file path from output and normalize it
59
+ const pathMatch = output.output.match(/<path>(.+?)<\/path>/)
60
+ if (!pathMatch) return
61
+ const filePath = path.normalize(pathMatch[1])
62
+
63
+ // Transform content lines: "N: content" → "N:hash| content"
64
+ // The first line is concatenated with <content> (no newline), so we
65
+ // match an optional <content> prefix and preserve it in the output.
66
+ const hashes = new Map<string, string>()
67
+ output.output = output.output.replace(
68
+ /^(<content>)?(\d+): (.*)$/gm,
69
+ (
70
+ _match,
71
+ prefix: string | undefined,
72
+ lineNum: string,
73
+ content: string,
74
+ ) => {
75
+ const hash = hashLine(content)
76
+ const ref = `${lineNum}:${hash}`
77
+ hashes.set(ref, content)
78
+ return `${prefix ?? ""}${lineNum}:${hash}| ${content}`
79
+ },
80
+ )
81
+
82
+ if (hashes.size > 0) {
83
+ // Merge with existing hashes (supports partial reads / offset reads)
84
+ const existing = fileHashes.get(filePath)
85
+ if (existing) {
86
+ for (const [ref, content] of hashes) {
87
+ existing.set(ref, content)
88
+ }
89
+ } else {
90
+ fileHashes.set(filePath, hashes)
91
+ }
92
+ }
93
+ },
94
+
95
+ // ── Edit schema: replace oldString/newString with hash references ──
96
+ // Requires PR #4956 (tool.definition hook) to take effect.
97
+ "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
+ })
132
+ },
133
+
134
+ // ── System prompt: instruct the model to use hashline edits ────────
135
+ "experimental.chat.system.transform": async (_input: any, output: any) => {
136
+ output.system.push(
137
+ [
138
+ "## Hashline Edit Mode (MANDATORY)",
139
+ "",
140
+ "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.",
142
+ "",
143
+ "Three operations:",
144
+ "",
145
+ "1. **Replace line** — replace a single line:",
146
+ ' `startHash: "3:cc7", content: " \\"version\\": \\"1.0.0\\","` ',
147
+ "",
148
+ "2. **Replace range** — replace lines startHash through endHash:",
149
+ ' `startHash: "3:cc7", endHash: "5:e60", content: "line3\\nline4\\nline5"`',
150
+ "",
151
+ "3. **Insert after** — insert new content after a line (without replacing it):",
152
+ ' `afterHash: "3:cc7", content: " \\"newKey\\": \\"newValue\\","` ',
153
+ "",
154
+ "NEVER pass oldString or newString. ALWAYS use startHash/afterHash + content.",
155
+ ].join("\n"),
156
+ )
157
+ },
158
+
159
+ // ── Edit: resolve hash references before the built-in edit runs ────
160
+ "tool.execute.before": async (input, output) => {
161
+ if (input.tool !== "edit") return
162
+
163
+ const args = output.args
164
+
165
+ // Reject oldString edits for files we have hashes for — force hashline usage
166
+ if (args.oldString && !args.startHash) {
167
+ const filePath = resolvePath(args.filePath)
168
+ if (fileHashes.has(filePath)) {
169
+ throw new Error(
170
+ [
171
+ "You must use hashline references to edit this file.",
172
+ "Use startHash (e.g. \"3:cc7\") instead of oldString.",
173
+ "Refer to the hash markers from the read output.",
174
+ ].join(" "),
175
+ )
176
+ }
177
+ // No hashes for this file — allow normal edit
178
+ return
179
+ }
180
+
181
+ // Only intercept hashline edits; fall through for normal edits
182
+ if (!args.startHash && !args.afterHash) return
183
+
184
+ // ── Insert after: append content after the referenced line ──
185
+ if (args.afterHash) {
186
+ 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
+ }
210
+
211
+ const anchorContent = hashes.get(args.afterHash)!
212
+ // oldString = anchor line, newString = anchor line + new content
213
+ args.oldString = anchorContent
214
+ args.newString = anchorContent + "\n" + args.content
215
+
216
+ delete args.afterHash
217
+ delete args.content
218
+ return
219
+ }
220
+
221
+ 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
+ }
233
+
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
+ }
250
+
251
+ const startLine = parseInt(args.startHash.split(":")[0], 10)
252
+ const endLine = args.endHash
253
+ ? parseInt(args.endHash.split(":")[0], 10)
254
+ : startLine
255
+
256
+ // Validate endHash
257
+ if (args.endHash && !hashes.has(args.endHash)) {
258
+ fileHashes.delete(filePath)
259
+ throw new Error(
260
+ `Hash reference "${args.endHash}" not found. The file may have changed since last read. Please re-read the file.`,
261
+ )
262
+ }
263
+
264
+ if (endLine < startLine) {
265
+ throw new Error(
266
+ `endHash line (${endLine}) must be >= startHash line (${startLine})`,
267
+ )
268
+ }
269
+
270
+ // 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
+
289
+ const oldString = rangeLines.join("\n")
290
+
291
+ // Set resolved args for the built-in edit tool
292
+ args.oldString = oldString
293
+ args.newString = args.content
294
+
295
+ // Remove hashline-specific fields so the built-in edit doesn't choke
296
+ delete args.startHash
297
+ delete args.endHash
298
+ delete args.content
299
+ },
300
+ } as any
301
+ }
302
+
303
+ export default HashlinePlugin