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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/package.json +38 -0
- 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
|