opencode-hashline 1.0.5 → 1.1.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.en.md +446 -0
- package/README.md +159 -184
- package/README.ru.md +1 -1
- package/dist/{index.cjs → opencode-hashline.cjs} +5 -5
- package/dist/{index.js → opencode-hashline.js} +2 -2
- package/package.json +8 -8
- /package/dist/{index.d.cts → opencode-hashline.d.cts} +0 -0
- /package/dist/{index.d.ts → opencode-hashline.d.ts} +0 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🔗 opencode-hashline
|
|
4
|
+
|
|
5
|
+
**Content-addressable line hashing for precise AI code editing**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/izzzzzi/opencode-hashline/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/izzzzzi/opencode-hashline/actions/workflows/release.yml)
|
|
9
|
+
[](https://www.npmjs.com/package/opencode-hashline)
|
|
10
|
+
[](https://www.npmjs.com/package/opencode-hashline)
|
|
11
|
+
[](https://github.com/izzzzzi/opencode-hashline/releases)
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
[](https://github.com/semantic-release/semantic-release)
|
|
14
|
+
[](https://www.typescriptlang.org/)
|
|
15
|
+
[](https://nodejs.org/)
|
|
16
|
+
|
|
17
|
+
[🇷🇺 Русский](README.md) | **🇬🇧 English**
|
|
18
|
+
|
|
19
|
+
<br />
|
|
20
|
+
|
|
21
|
+
*Hashline plugin for [OpenCode](https://github.com/anomalyco/opencode) — annotate every line with a deterministic hash tag so the AI can reference and edit code with surgical precision.*
|
|
22
|
+
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 📖 What is Hashline?
|
|
28
|
+
|
|
29
|
+
Hashline annotates every line of a file with a short, deterministic hex hash tag. When the AI reads a file, it sees:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
#HL 1:a3f|function hello() {
|
|
33
|
+
#HL 2:f1c| return "world";
|
|
34
|
+
#HL 3:0e7|}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> **Note:** Hash length is adaptive — it depends on file size (3 chars for ≤4096 lines, 4 chars for >4096 lines). Minimum hash length is 3 to reduce collision risk. The `#HL ` prefix protects against false positives when stripping hashes and is configurable.
|
|
38
|
+
|
|
39
|
+
The AI model can then reference lines by their hash tags for precise editing:
|
|
40
|
+
|
|
41
|
+
- **"Replace line `2:f1c`"** — target a specific line unambiguously
|
|
42
|
+
- **"Replace block from `1:a3f` to `3:0e7`"** — target a range of lines
|
|
43
|
+
- **"Insert after `3:0e7`"** — insert at a precise location
|
|
44
|
+
|
|
45
|
+
### 🤔 Why does this help?
|
|
46
|
+
|
|
47
|
+
Traditional line numbers shift as edits are made, causing off-by-one errors and stale references. Hashline tags are **content-addressable** — they're derived from both the line index and the line's content, so they serve as a stable, verifiable reference that the AI can use to communicate about code locations with precision.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## ✨ Features
|
|
52
|
+
|
|
53
|
+
### 📏 Adaptive Hash Length
|
|
54
|
+
|
|
55
|
+
Hash length automatically adapts to file size to minimize collisions:
|
|
56
|
+
|
|
57
|
+
| File Size | Hash Length | Possible Values |
|
|
58
|
+
|-----------|:----------:|:---------------:|
|
|
59
|
+
| ≤ 256 lines | 2 hex chars | 256 |
|
|
60
|
+
| ≤ 4,096 lines | 3 hex chars | 4,096 |
|
|
61
|
+
| > 4,096 lines | 4 hex chars | 65,536 |
|
|
62
|
+
|
|
63
|
+
### 🏷️ Magic Prefix (`#HL `)
|
|
64
|
+
|
|
65
|
+
Lines are annotated with a configurable prefix (default: `#HL `) to prevent false positives when stripping hashes. This ensures that data lines like `1:ab|some data` are not accidentally stripped.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
#HL 1:a3|function hello() {
|
|
69
|
+
#HL 2:f1| return "world";
|
|
70
|
+
#HL 3:0e|}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The prefix can be customized or disabled for backward compatibility:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// Custom prefix
|
|
77
|
+
const hl = createHashline({ prefix: ">> " });
|
|
78
|
+
|
|
79
|
+
// Disable prefix (legacy format: "1:a3|code")
|
|
80
|
+
const hl = createHashline({ prefix: false });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 💾 LRU Caching
|
|
84
|
+
|
|
85
|
+
Built-in LRU cache (`filePath → annotatedContent`) with configurable size (default: 100 files). When the same file is read again with unchanged content, the cached result is returned instantly. Cache is automatically invalidated when file content changes.
|
|
86
|
+
|
|
87
|
+
### ✅ Hash Verification
|
|
88
|
+
|
|
89
|
+
Verify that a line hasn't changed since it was read — protects against race conditions:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { verifyHash } from "opencode-hashline";
|
|
93
|
+
|
|
94
|
+
const result = verifyHash(2, "f1c", currentContent);
|
|
95
|
+
if (!result.valid) {
|
|
96
|
+
console.error(result.message); // "Hash mismatch at line 2: ..."
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Hash verification uses the length of the provided hash reference (not the current file size), so a reference like `2:f1` remains valid even if the file has grown.
|
|
101
|
+
|
|
102
|
+
### 🔍 Indentation-Sensitive Hashing
|
|
103
|
+
|
|
104
|
+
Hash computation uses `trimEnd()` (not `trim()`), so changes to leading whitespace (indentation) are detected as content changes, while trailing whitespace is ignored.
|
|
105
|
+
|
|
106
|
+
### 📐 Range Operations
|
|
107
|
+
|
|
108
|
+
Resolve and replace ranges of lines by hash references:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { resolveRange, replaceRange } from "opencode-hashline";
|
|
112
|
+
|
|
113
|
+
// Get lines between two hash references
|
|
114
|
+
const range = resolveRange("1:a3f", "3:0e7", content);
|
|
115
|
+
console.log(range.lines); // ["function hello() {", ' return "world";', "}"]
|
|
116
|
+
|
|
117
|
+
// Replace a range with new content
|
|
118
|
+
const newContent = replaceRange(
|
|
119
|
+
"1:a3f", "3:0e7", content,
|
|
120
|
+
"function goodbye() {\n return 'farewell';\n}"
|
|
121
|
+
);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### ⚙️ Configurable
|
|
125
|
+
|
|
126
|
+
Create custom Hashline instances with specific settings:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { createHashline } from "opencode-hashline";
|
|
130
|
+
|
|
131
|
+
const hl = createHashline({
|
|
132
|
+
exclude: ["**/node_modules/**", "**/*.min.js"],
|
|
133
|
+
maxFileSize: 512_000, // 512 KB
|
|
134
|
+
hashLength: 3, // force 3-char hashes
|
|
135
|
+
cacheSize: 200, // cache up to 200 files
|
|
136
|
+
prefix: "#HL ", // magic prefix (default)
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Use the configured instance
|
|
140
|
+
const annotated = hl.formatFileWithHashes(content, "src/app.ts");
|
|
141
|
+
const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### Configuration Options
|
|
145
|
+
|
|
146
|
+
| Option | Type | Default | Description |
|
|
147
|
+
|--------|------|---------|-------------|
|
|
148
|
+
| `exclude` | `string[]` | See below | Glob patterns for files to skip |
|
|
149
|
+
| `maxFileSize` | `number` | `1_000_000` | Max file size in bytes |
|
|
150
|
+
| `hashLength` | `number \| undefined` | `undefined` (adaptive) | Force specific hash length |
|
|
151
|
+
| `cacheSize` | `number` | `100` | Max files in LRU cache |
|
|
152
|
+
| `prefix` | `string \| false` | `"#HL "` | Line prefix (`false` to disable) |
|
|
153
|
+
|
|
154
|
+
Default exclude patterns cover: lock files, `node_modules`, minified files, binary files (images, fonts, archives, etc.).
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 📦 Installation
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm install opencode-hashline
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 🔧 Configuration
|
|
167
|
+
|
|
168
|
+
Add the plugin to your `opencode.json`:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"$schema": "https://opencode.ai/config.json",
|
|
173
|
+
"plugin": ["opencode-hashline"]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Configuration Files
|
|
178
|
+
|
|
179
|
+
The plugin loads configuration from the following locations (in priority order, later overrides earlier):
|
|
180
|
+
|
|
181
|
+
| Priority | Location | Scope |
|
|
182
|
+
|:--------:|----------|-------|
|
|
183
|
+
| 1 | `~/.config/opencode/opencode-hashline.json` | Global (all projects) |
|
|
184
|
+
| 2 | `<project>/opencode-hashline.json` | Project-local |
|
|
185
|
+
| 3 | Programmatic config via `createHashlinePlugin()` | Factory argument |
|
|
186
|
+
|
|
187
|
+
Example `opencode-hashline.json`:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"exclude": ["**/node_modules/**", "**/*.min.js"],
|
|
192
|
+
"maxFileSize": 1048576,
|
|
193
|
+
"hashLength": 0,
|
|
194
|
+
"cacheSize": 100,
|
|
195
|
+
"prefix": "#HL "
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
That's it! The plugin automatically:
|
|
200
|
+
|
|
201
|
+
| # | Action | Description |
|
|
202
|
+
|:-:|--------|-------------|
|
|
203
|
+
| 1 | 📝 **Annotates file reads** | When the AI reads a file, each line gets a `#HL` hash prefix |
|
|
204
|
+
| 2 | 📎 **Annotates `@file` mentions** | Files attached via `@filename` in prompts are also annotated with hashlines |
|
|
205
|
+
| 3 | ✂️ **Strips hash prefixes on edits** | When the AI writes/edits a file, hash prefixes are removed before applying changes |
|
|
206
|
+
| 4 | 🧠 **Injects system prompt instructions** | The AI is told how to interpret and use hashline references |
|
|
207
|
+
| 5 | 💾 **Caches results** | Repeated reads of the same file return cached annotations |
|
|
208
|
+
| 6 | 🔍 **Filters by tool** | Only file-reading tools (e.g. `read_file`, `cat`, `view`) get annotations; other tools are left untouched |
|
|
209
|
+
| 7 | ⚙️ **Respects config** | Excluded files and files exceeding `maxFileSize` are skipped |
|
|
210
|
+
| 8 | 🧩 **Registers `hashline_edit` tool** | Applies replace/delete/insert by hash references, without exact `old_string` matching |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 🛠️ How It Works
|
|
215
|
+
|
|
216
|
+
### Hash Computation
|
|
217
|
+
|
|
218
|
+
Each line's hash is computed from:
|
|
219
|
+
- The **0-based line index**
|
|
220
|
+
- The **trimEnd'd line content** — leading whitespace (indentation) IS significant
|
|
221
|
+
|
|
222
|
+
This is fed through an **FNV-1a** hash function, reduced to the appropriate modulus based on file size, and rendered as a hex string.
|
|
223
|
+
|
|
224
|
+
### Plugin Hooks & Tool
|
|
225
|
+
|
|
226
|
+
The plugin registers four OpenCode hooks and one custom tool:
|
|
227
|
+
|
|
228
|
+
| Hook | Purpose |
|
|
229
|
+
|------|---------|
|
|
230
|
+
| `tool.hashline_edit` | Hash-aware edits by references like `5:a3f` or `#HL 5:a3f|...` |
|
|
231
|
+
| `tool.execute.after` | Injects hashline annotations into file-read tool output |
|
|
232
|
+
| `tool.execute.before` | Strips hashline prefixes from file-edit tool arguments |
|
|
233
|
+
| `chat.message` | Annotates `@file` mentions in user messages (writes annotated content to a temp file and swaps the URL) |
|
|
234
|
+
| `experimental.chat.system.transform` | Adds hashline usage instructions to the system prompt |
|
|
235
|
+
|
|
236
|
+
### Tool Detection Heuristic (`isFileReadTool`)
|
|
237
|
+
|
|
238
|
+
The plugin needs to determine which tools are "file-read" tools (to annotate their output) vs "file-edit" tools (to strip hash prefixes from their input). Since the OpenCode plugin API does not expose a semantic tool category, the plugin uses a name-based heuristic:
|
|
239
|
+
|
|
240
|
+
**Exact match** — the tool name (case-insensitive) is compared against the allow-list:
|
|
241
|
+
- `read`, `file_read`, `read_file`, `cat`, `view`
|
|
242
|
+
|
|
243
|
+
**Dotted suffix match** — for namespaced tools like `mcp.read` or `custom_provider.file_read`, the part after the last `.` is matched against the same list.
|
|
244
|
+
|
|
245
|
+
**Fallback heuristic** — if the tool has `path`, `filePath`, or `file` arguments AND the tool name does NOT contain write/edit/execute indicators (`write`, `edit`, `patch`, `execute`, `run`, `command`, `shell`, `bash`), it is treated as a file-read tool.
|
|
246
|
+
|
|
247
|
+
**How to customize:**
|
|
248
|
+
- Name your custom tool to match one of the patterns above (e.g. `my_read_file`)
|
|
249
|
+
- Include `path`, `filePath`, or `file` in its arguments
|
|
250
|
+
- Or extend the `FILE_READ_TOOLS` list in a fork
|
|
251
|
+
|
|
252
|
+
The `isFileReadTool()` function is exported for testing and advanced usage:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
import { isFileReadTool } from "opencode-hashline";
|
|
256
|
+
|
|
257
|
+
isFileReadTool("read_file"); // true
|
|
258
|
+
isFileReadTool("mcp.read"); // true
|
|
259
|
+
isFileReadTool("custom_reader", { path: "app.ts" }); // true (heuristic)
|
|
260
|
+
isFileReadTool("file_write", { path: "app.ts" }); // false (write indicator)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Programmatic API
|
|
264
|
+
|
|
265
|
+
The core utilities are exported from the `opencode-hashline/utils` subpath (to avoid conflicts with OpenCode's plugin loader, which calls every export as a Plugin function):
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import {
|
|
269
|
+
computeLineHash,
|
|
270
|
+
formatFileWithHashes,
|
|
271
|
+
stripHashes,
|
|
272
|
+
parseHashRef,
|
|
273
|
+
normalizeHashRef,
|
|
274
|
+
buildHashMap,
|
|
275
|
+
getAdaptiveHashLength,
|
|
276
|
+
verifyHash,
|
|
277
|
+
resolveRange,
|
|
278
|
+
replaceRange,
|
|
279
|
+
applyHashEdit,
|
|
280
|
+
HashlineCache,
|
|
281
|
+
createHashline,
|
|
282
|
+
shouldExclude,
|
|
283
|
+
matchesGlob,
|
|
284
|
+
resolveConfig,
|
|
285
|
+
DEFAULT_PREFIX,
|
|
286
|
+
} from "opencode-hashline/utils";
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Core Functions
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
// Compute hash for a single line
|
|
293
|
+
const hash = computeLineHash(0, "function hello() {"); // e.g. "a3f"
|
|
294
|
+
|
|
295
|
+
// Compute hash with specific length
|
|
296
|
+
const hash4 = computeLineHash(0, "function hello() {", 4); // e.g. "a3f2"
|
|
297
|
+
|
|
298
|
+
// Annotate entire file content (adaptive hash length, with #HL prefix)
|
|
299
|
+
const annotated = formatFileWithHashes(fileContent);
|
|
300
|
+
// "#HL 1:a3|function hello() {\n#HL 2:f1| return \"world\";\n#HL 3:0e|}"
|
|
301
|
+
|
|
302
|
+
// Annotate with specific hash length
|
|
303
|
+
const annotated3 = formatFileWithHashes(fileContent, 3);
|
|
304
|
+
|
|
305
|
+
// Annotate without prefix (legacy format)
|
|
306
|
+
const annotatedLegacy = formatFileWithHashes(fileContent, undefined, false);
|
|
307
|
+
|
|
308
|
+
// Strip annotations to get original content
|
|
309
|
+
const original = stripHashes(annotated);
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Hash References & Verification
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
// Parse a hash reference
|
|
316
|
+
const { line, hash } = parseHashRef("2:f1c"); // { line: 2, hash: "f1c" }
|
|
317
|
+
|
|
318
|
+
// Normalize from an annotated line
|
|
319
|
+
const ref = normalizeHashRef("#HL 2:f1c|const x = 1;"); // "2:f1c"
|
|
320
|
+
|
|
321
|
+
// Build a lookup map
|
|
322
|
+
const map = buildHashMap(fileContent); // Map<"2:f1c", 2>
|
|
323
|
+
|
|
324
|
+
// Verify a hash reference (uses hash.length, not file size)
|
|
325
|
+
const result = verifyHash(2, "f1c", fileContent);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Range Operations
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
// Resolve a range
|
|
332
|
+
const range = resolveRange("1:a3f", "3:0e7", fileContent);
|
|
333
|
+
|
|
334
|
+
// Replace a range
|
|
335
|
+
const newContent = replaceRange("1:a3f", "3:0e7", fileContent, "new content");
|
|
336
|
+
|
|
337
|
+
// Hash-aware edit operation (replace/delete/insert_before/insert_after)
|
|
338
|
+
const edited = applyHashEdit(
|
|
339
|
+
{ operation: "replace", startRef: "1:a3f", endRef: "3:0e7", replacement: "new content" },
|
|
340
|
+
fileContent
|
|
341
|
+
).content;
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Utilities
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// Check if a file should be excluded
|
|
348
|
+
const excluded = shouldExclude("node_modules/foo.js", ["**/node_modules/**"]);
|
|
349
|
+
|
|
350
|
+
// Create a configured instance
|
|
351
|
+
const hl = createHashline({ cacheSize: 50, hashLength: 3 });
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 📊 Benchmark
|
|
357
|
+
|
|
358
|
+
### Correctness: hashline vs str_replace
|
|
359
|
+
|
|
360
|
+
We tested both approaches on **60 fixtures from [react-edit-benchmark](https://github.com/can1357/oh-my-pi/tree/main/packages/react-edit-benchmark)** — mutated React source files with known bugs (flipped booleans, swapped operators, removed guard clauses, etc.):
|
|
361
|
+
|
|
362
|
+
| | hashline | str_replace |
|
|
363
|
+
|---|:---:|:---:|
|
|
364
|
+
| **Passed** | **60/60 (100%)** | 58/60 (96.7%) |
|
|
365
|
+
| **Failed** | 0 | 2 |
|
|
366
|
+
| **Ambiguous edits** | 0 | 4 |
|
|
367
|
+
|
|
368
|
+
str_replace fails when the `old_string` appears multiple times in the file (e.g. repeated guard clauses, similar code blocks). Hashline addresses each line uniquely via `lineNumber:hash`, so ambiguity is impossible.
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
# Run yourself:
|
|
372
|
+
npx tsx benchmark/run.ts # hashline mode
|
|
373
|
+
npx tsx benchmark/run.ts --no-hash # str_replace mode
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
<details>
|
|
377
|
+
<summary>str_replace failures (structural category)</summary>
|
|
378
|
+
|
|
379
|
+
- `structural-remove-early-return-001` — `old_string` matched multiple locations, wrong one replaced
|
|
380
|
+
- `structural-remove-early-return-002` — same issue
|
|
381
|
+
- `structural-delete-statement-002` — ambiguous match (first match happened to be correct)
|
|
382
|
+
- `structural-delete-statement-003` — ambiguous match (first match happened to be correct)
|
|
383
|
+
|
|
384
|
+
</details>
|
|
385
|
+
|
|
386
|
+
### Token Overhead
|
|
387
|
+
|
|
388
|
+
Hashline annotations add `#HL <line>:<hash>|` prefix (~12 chars / ~3 tokens) per line:
|
|
389
|
+
|
|
390
|
+
| | Plain | Annotated | Overhead |
|
|
391
|
+
|---|---:|---:|:---:|
|
|
392
|
+
| **Characters** | 404K | 564K | +40% |
|
|
393
|
+
| **Tokens (~)** | ~101K | ~141K | +40% |
|
|
394
|
+
|
|
395
|
+
Overhead is stable at ~40% regardless of file size. For a typical 200-line file (~800 tokens), hashline adds ~600 tokens — negligible in a 200K context window.
|
|
396
|
+
|
|
397
|
+
### Performance
|
|
398
|
+
|
|
399
|
+
| File Size | Annotate | Edit | Strip |
|
|
400
|
+
|----------:|:--------:|:----:|:-----:|
|
|
401
|
+
| **10** lines | 0.05 ms | 0.01 ms | 0.03 ms |
|
|
402
|
+
| **100** lines | 0.12 ms | 0.02 ms | 0.08 ms |
|
|
403
|
+
| **1,000** lines | 0.95 ms | 0.04 ms | 0.60 ms |
|
|
404
|
+
| **5,000** lines | 4.50 ms | 0.08 ms | 2.80 ms |
|
|
405
|
+
| **10,000** lines | 9.20 ms | 0.10 ms | 5.50 ms |
|
|
406
|
+
|
|
407
|
+
> A typical 1,000-line source file is annotated in **< 1ms** — imperceptible to the user.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## 🧑💻 Development
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
# Install dependencies
|
|
415
|
+
npm install
|
|
416
|
+
|
|
417
|
+
# Run tests
|
|
418
|
+
npm test
|
|
419
|
+
|
|
420
|
+
# Build
|
|
421
|
+
npm run build
|
|
422
|
+
|
|
423
|
+
# Type check
|
|
424
|
+
npm run typecheck
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## 💡 Inspiration & Background
|
|
430
|
+
|
|
431
|
+
The idea behind hashline is inspired by concepts from **oh-my-pi** by [can1357](https://github.com/can1357/oh-my-pi) — an AI coding agent toolkit (coding agent CLI, unified LLM API, TUI libraries) — and the article "The Harness Problem."
|
|
432
|
+
|
|
433
|
+
**The Harness Problem** describes a fundamental limitation of current AI coding tools: while modern LLMs are extremely capable, the *harness* layer — the tooling that feeds context to the model and applies its edits back to files — loses information and introduces errors. The model sees a file's content, but when it needs to edit, it must "guess" surrounding context for search-and-replace (which breaks on duplicate lines) or produce diffs (which are unreliable in practice).
|
|
434
|
+
|
|
435
|
+
Hashline solves this by assigning each line a short, deterministic hash tag (e.g. `2:f1c`), making line addressing **exact and unambiguous**. The model can reference any line or range precisely, eliminating off-by-one errors and duplicate-line confusion.
|
|
436
|
+
|
|
437
|
+
**References:**
|
|
438
|
+
- [oh-my-pi by can1357](https://github.com/can1357/oh-my-pi) — AI coding agent toolkit: coding agent CLI, unified LLM API, TUI libraries
|
|
439
|
+
- [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — blog post describing the problem in detail
|
|
440
|
+
- [Описание подхода на Хабре](https://habr.com/ru/companies/bothub/news/995986/) — overview of the approach in Russian
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## 📄 License
|
|
445
|
+
|
|
446
|
+
[MIT](LICENSE) © opencode-hashline contributors
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# 🔗 opencode-hashline
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Контентно-адресуемое хеширование строк для точного редактирования кода с помощью AI**
|
|
6
6
|
|
|
7
7
|
[](https://github.com/izzzzzi/opencode-hashline/actions/workflows/ci.yml)
|
|
8
8
|
[](https://github.com/izzzzzi/opencode-hashline/actions/workflows/release.yml)
|
|
@@ -14,19 +14,19 @@
|
|
|
14
14
|
[](https://www.typescriptlang.org/)
|
|
15
15
|
[](https://nodejs.org/)
|
|
16
16
|
|
|
17
|
-
[
|
|
17
|
+
**🇷🇺 Русский** | [🇬🇧 English](README.en.md)
|
|
18
18
|
|
|
19
19
|
<br />
|
|
20
20
|
|
|
21
|
-
*Hashline
|
|
21
|
+
*Hashline-плагин для [OpenCode](https://github.com/anomalyco/opencode) — аннотирует каждую строку файла детерминированным хеш-тегом, чтобы AI мог ссылаться на код и редактировать его с хирургической точностью.*
|
|
22
22
|
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
-
## 📖
|
|
27
|
+
## 📖 Что такое Hashline?
|
|
28
28
|
|
|
29
|
-
Hashline
|
|
29
|
+
Hashline аннотирует каждую строку файла коротким детерминированным hex-хешем. Когда AI читает файл, он видит:
|
|
30
30
|
|
|
31
31
|
```
|
|
32
32
|
#HL 1:a3f|function hello() {
|
|
@@ -34,35 +34,35 @@ Hashline annotates every line of a file with a short, deterministic hex hash tag
|
|
|
34
34
|
#HL 3:0e7|}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
>
|
|
37
|
+
> **Примечание:** Длина хеша адаптивная — она зависит от размера файла (2 символа для ≤256 строк, 3 символа для ≤4096 строк, 4 символа для >4096 строк). В примерах ниже используются 3-символьные хеши. Префикс `#HL ` защищает от ложных срабатываний при удалении хешей и является настраиваемым.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
AI-модель может ссылаться на строки по их хеш-тегам для точного редактирования:
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
41
|
+
- **«Заменить строку `2:f1c`»** — указать конкретную строку однозначно
|
|
42
|
+
- **«Заменить блок от `1:a3f` до `3:0e7`»** — указать диапазон строк
|
|
43
|
+
- **«Вставить после `3:0e7`»** — вставить в точное место
|
|
44
44
|
|
|
45
|
-
### 🤔
|
|
45
|
+
### 🤔 Почему это помогает?
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Традиционные номера строк сдвигаются при редактировании, вызывая ошибки смещения и устаревшие ссылки. Хеш-теги Hashline **контентно-адресуемы** — они вычисляются из индекса строки и её содержимого, что делает их стабильной, верифицируемой ссылкой для точной коммуникации о местоположении в коде.
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
51
|
-
## ✨
|
|
51
|
+
## ✨ Возможности
|
|
52
52
|
|
|
53
|
-
### 📏
|
|
53
|
+
### 📏 Адаптивная длина хеша
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Длина хеша автоматически адаптируется к размеру файла для минимизации коллизий:
|
|
56
56
|
|
|
57
|
-
|
|
|
58
|
-
|
|
59
|
-
| ≤ 256
|
|
60
|
-
| ≤ 4
|
|
61
|
-
| > 4
|
|
57
|
+
| Размер файла | Длина хеша | Возможных значений |
|
|
58
|
+
|-------------|:----------:|:------------------:|
|
|
59
|
+
| ≤ 256 строк | 2 hex-символа | 256 |
|
|
60
|
+
| ≤ 4 096 строк | 3 hex-символа | 4 096 |
|
|
61
|
+
| > 4 096 строк | 4 hex-символа | 65 536 |
|
|
62
62
|
|
|
63
|
-
### 🏷️
|
|
63
|
+
### 🏷️ Магический префикс (`#HL `)
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Строки аннотируются настраиваемым префиксом (по умолчанию: `#HL `), чтобы предотвратить ложные срабатывания при удалении хешей. Это гарантирует, что строки данных вроде `1:ab|some data` не будут случайно обрезаны.
|
|
66
66
|
|
|
67
67
|
```
|
|
68
68
|
#HL 1:a3|function hello() {
|
|
@@ -70,23 +70,23 @@ Lines are annotated with a configurable prefix (default: `#HL `) to prevent fals
|
|
|
70
70
|
#HL 3:0e|}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
Префикс можно настроить или отключить для обратной совместимости:
|
|
74
74
|
|
|
75
75
|
```typescript
|
|
76
|
-
//
|
|
76
|
+
// Кастомный префикс
|
|
77
77
|
const hl = createHashline({ prefix: ">> " });
|
|
78
78
|
|
|
79
|
-
//
|
|
79
|
+
// Отключить префикс (legacy-формат: "1:a3|code")
|
|
80
80
|
const hl = createHashline({ prefix: false });
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
### 💾 LRU
|
|
83
|
+
### 💾 LRU-кеширование
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Встроенный LRU-кеш (`filePath → annotatedContent`) с настраиваемым размером (по умолчанию 100 файлов). При повторном чтении того же файла с неизменённым содержимым возвращается кешированный результат. Кеш автоматически инвалидируется при изменении содержимого файла.
|
|
86
86
|
|
|
87
|
-
### ✅
|
|
87
|
+
### ✅ Верификация хешей
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
Проверка того, что строка не изменилась с момента чтения — защита от race conditions:
|
|
90
90
|
|
|
91
91
|
```typescript
|
|
92
92
|
import { verifyHash } from "opencode-hashline";
|
|
@@ -97,65 +97,65 @@ if (!result.valid) {
|
|
|
97
97
|
}
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
Верификация хешей использует длину предоставленной хеш-ссылки (а не текущий размер файла), поэтому ссылка вроде `2:f1` остаётся валидной даже если файл вырос.
|
|
101
101
|
|
|
102
|
-
### 🔍
|
|
102
|
+
### 🔍 Чувствительность к отступам
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
Вычисление хеша использует `trimEnd()` (а не `trim()`), поэтому изменения ведущих пробелов (отступов) обнаруживаются как изменения содержимого, а завершающие пробелы игнорируются.
|
|
105
105
|
|
|
106
|
-
### 📐 Range
|
|
106
|
+
### 📐 Range-операции
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
Резолвинг и замена диапазонов строк по хеш-ссылкам:
|
|
109
109
|
|
|
110
110
|
```typescript
|
|
111
111
|
import { resolveRange, replaceRange } from "opencode-hashline";
|
|
112
112
|
|
|
113
|
-
//
|
|
113
|
+
// Получить строки между двумя хеш-ссылками
|
|
114
114
|
const range = resolveRange("1:a3f", "3:0e7", content);
|
|
115
115
|
console.log(range.lines); // ["function hello() {", ' return "world";', "}"]
|
|
116
116
|
|
|
117
|
-
//
|
|
117
|
+
// Заменить диапазон новым содержимым
|
|
118
118
|
const newContent = replaceRange(
|
|
119
119
|
"1:a3f", "3:0e7", content,
|
|
120
120
|
"function goodbye() {\n return 'farewell';\n}"
|
|
121
121
|
);
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
### ⚙️
|
|
124
|
+
### ⚙️ Конфигурируемость
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
Создание кастомных экземпляров Hashline с определёнными настройками:
|
|
127
127
|
|
|
128
128
|
```typescript
|
|
129
129
|
import { createHashline } from "opencode-hashline";
|
|
130
130
|
|
|
131
131
|
const hl = createHashline({
|
|
132
132
|
exclude: ["**/node_modules/**", "**/*.min.js"],
|
|
133
|
-
maxFileSize: 512_000, // 512
|
|
134
|
-
hashLength: 3, //
|
|
135
|
-
cacheSize: 200, //
|
|
136
|
-
prefix: "#HL ", //
|
|
133
|
+
maxFileSize: 512_000, // 512 КБ
|
|
134
|
+
hashLength: 3, // принудительно 3-символьные хеши
|
|
135
|
+
cacheSize: 200, // кешировать до 200 файлов
|
|
136
|
+
prefix: "#HL ", // магический префикс (по умолчанию)
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
//
|
|
139
|
+
// Использование настроенного экземпляра
|
|
140
140
|
const annotated = hl.formatFileWithHashes(content, "src/app.ts");
|
|
141
141
|
const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
####
|
|
144
|
+
#### Параметры конфигурации
|
|
145
145
|
|
|
146
|
-
|
|
|
147
|
-
|
|
148
|
-
| `exclude` | `string[]` |
|
|
149
|
-
| `maxFileSize` | `number` | `1_000_000` |
|
|
150
|
-
| `hashLength` | `number \| undefined` | `undefined` (
|
|
151
|
-
| `cacheSize` | `number` | `100` |
|
|
152
|
-
| `prefix` | `string \| false` | `"#HL "` |
|
|
146
|
+
| Параметр | Тип | По умолчанию | Описание |
|
|
147
|
+
|----------|-----|:------------:|----------|
|
|
148
|
+
| `exclude` | `string[]` | См. ниже | Glob-паттерны для исключения файлов |
|
|
149
|
+
| `maxFileSize` | `number` | `1_000_000` | Макс. размер файла в байтах |
|
|
150
|
+
| `hashLength` | `number \| undefined` | `undefined` (адаптивно) | Принудительная длина хеша |
|
|
151
|
+
| `cacheSize` | `number` | `100` | Макс. файлов в LRU-кеше |
|
|
152
|
+
| `prefix` | `string \| false` | `"#HL "` | Префикс строки (`false` для отключения) |
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
Паттерны исключения по умолчанию: lock-файлы, `node_modules`, минифицированные файлы, бинарные файлы (изображения, шрифты, архивы и т.д.).
|
|
155
155
|
|
|
156
156
|
---
|
|
157
157
|
|
|
158
|
-
## 📦
|
|
158
|
+
## 📦 Установка
|
|
159
159
|
|
|
160
160
|
```bash
|
|
161
161
|
npm install opencode-hashline
|
|
@@ -163,9 +163,9 @@ npm install opencode-hashline
|
|
|
163
163
|
|
|
164
164
|
---
|
|
165
165
|
|
|
166
|
-
## 🔧
|
|
166
|
+
## 🔧 Конфигурация
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
Добавьте плагин в ваш `opencode.json`:
|
|
169
169
|
|
|
170
170
|
```json
|
|
171
171
|
{
|
|
@@ -174,17 +174,17 @@ Add the plugin to your `opencode.json`:
|
|
|
174
174
|
}
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
###
|
|
177
|
+
### Файлы конфигурации
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
Плагин загружает конфигурацию из следующих мест (в порядке приоритета, более поздние перезаписывают ранние):
|
|
180
180
|
|
|
181
|
-
|
|
|
182
|
-
|
|
183
|
-
| 1 | `~/.config/opencode/opencode-hashline.json` |
|
|
184
|
-
| 2 | `<project>/opencode-hashline.json` |
|
|
185
|
-
| 3 |
|
|
181
|
+
| Приоритет | Расположение | Область |
|
|
182
|
+
|:---------:|-------------|---------|
|
|
183
|
+
| 1 | `~/.config/opencode/opencode-hashline.json` | Глобальная (все проекты) |
|
|
184
|
+
| 2 | `<project>/opencode-hashline.json` | Локальная (проект) |
|
|
185
|
+
| 3 | Программная конфигурация через `createHashlinePlugin()` | Аргумент фабрики |
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
Пример `opencode-hashline.json`:
|
|
188
188
|
|
|
189
189
|
```json
|
|
190
190
|
{
|
|
@@ -196,73 +196,48 @@ Example `opencode-hashline.json`:
|
|
|
196
196
|
}
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
Вот и всё! Плагин автоматически:
|
|
200
200
|
|
|
201
|
-
| # |
|
|
202
|
-
|
|
203
|
-
| 1 | 📝
|
|
204
|
-
| 2 | 📎
|
|
205
|
-
| 3 | ✂️
|
|
206
|
-
| 4 | 🧠
|
|
207
|
-
| 5 | 💾
|
|
208
|
-
| 6 | 🔍
|
|
209
|
-
| 7 | ⚙️
|
|
210
|
-
| 8 | 🧩
|
|
201
|
+
| # | Действие | Описание |
|
|
202
|
+
|:-:|----------|----------|
|
|
203
|
+
| 1 | 📝 **Аннотирует чтение файлов** | При чтении файла AI каждая строка получает `#HL` хеш-префикс |
|
|
204
|
+
| 2 | 📎 **Аннотирует `@file` упоминания** | Файлы, прикреплённые через `@filename` в промпте, тоже аннотируются хешлайнами |
|
|
205
|
+
| 3 | ✂️ **Убирает хеш-префиксы при редактировании** | При записи/редактировании файла хеш-префиксы удаляются перед применением изменений |
|
|
206
|
+
| 4 | 🧠 **Внедряет инструкции в системный промпт** | AI получает инструкции по интерпретации и использованию hashline-ссылок |
|
|
207
|
+
| 5 | 💾 **Кеширует результаты** | Повторные чтения того же файла возвращают кешированные аннотации |
|
|
208
|
+
| 6 | 🔍 **Фильтрует по инструменту** | Только инструменты чтения файлов (например `read_file`, `cat`, `view`) получают аннотации; остальные не затрагиваются |
|
|
209
|
+
| 7 | ⚙️ **Учитывает конфигурацию** | Исключённые файлы и файлы, превышающие `maxFileSize`, пропускаются |
|
|
210
|
+
| 8 | 🧩 **Регистрирует `hashline_edit` tool** | Применяет replace/delete/insert по hash-ссылкам без точного `old_string`-матчинга |
|
|
211
211
|
|
|
212
212
|
---
|
|
213
213
|
|
|
214
|
-
## 🛠️
|
|
214
|
+
## 🛠️ Как это работает
|
|
215
215
|
|
|
216
|
-
###
|
|
216
|
+
### Вычисление хеша
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
-
|
|
220
|
-
-
|
|
218
|
+
Хеш каждой строки вычисляется из:
|
|
219
|
+
- **0-based индекса** строки
|
|
220
|
+
- **Содержимого строки** с обрезанными завершающими пробелами (trimEnd) — ведущие пробелы (отступы) ЗНАЧИМЫ
|
|
221
221
|
|
|
222
|
-
|
|
222
|
+
Это подаётся в хеш-функцию **FNV-1a**, сводится к соответствующему модулю в зависимости от размера файла и отображается как hex-строка.
|
|
223
223
|
|
|
224
|
-
###
|
|
224
|
+
### Хуки и tool плагина
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
Плагин регистрирует четыре хука OpenCode и один кастомный tool:
|
|
227
227
|
|
|
228
|
-
|
|
|
229
|
-
|
|
230
|
-
| `tool.hashline_edit` | Hash-aware
|
|
231
|
-
| `tool.execute.after` |
|
|
232
|
-
| `tool.execute.before` |
|
|
233
|
-
| `chat.message` |
|
|
234
|
-
| `experimental.chat.system.transform` |
|
|
228
|
+
| Хук | Назначение |
|
|
229
|
+
|-----|-----------|
|
|
230
|
+
| `tool.hashline_edit` | Hash-aware правки по ссылкам вроде `5:a3f` или `#HL 5:a3f|...` |
|
|
231
|
+
| `tool.execute.after` | Добавляет hashline-аннотации в вывод инструментов чтения файлов |
|
|
232
|
+
| `tool.execute.before` | Убирает hashline-префиксы из аргументов инструментов редактирования |
|
|
233
|
+
| `chat.message` | Аннотирует `@file` упоминания в сообщениях пользователя (записывает аннотированный контент во временный файл и подменяет URL) |
|
|
234
|
+
| `experimental.chat.system.transform` | Добавляет инструкции по использованию hashline в системный промпт |
|
|
235
235
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
The plugin needs to determine which tools are "file-read" tools (to annotate their output) vs "file-edit" tools (to strip hash prefixes from their input). Since the OpenCode plugin API does not expose a semantic tool category, the plugin uses a name-based heuristic:
|
|
239
|
-
|
|
240
|
-
**Exact match** — the tool name (case-insensitive) is compared against the allow-list:
|
|
241
|
-
- `read`, `file_read`, `read_file`, `cat`, `view`
|
|
242
|
-
|
|
243
|
-
**Dotted suffix match** — for namespaced tools like `mcp.read` or `custom_provider.file_read`, the part after the last `.` is matched against the same list.
|
|
244
|
-
|
|
245
|
-
**Fallback heuristic** — if the tool has `path`, `filePath`, or `file` arguments AND the tool name does NOT contain write/edit/execute indicators (`write`, `edit`, `patch`, `execute`, `run`, `command`, `shell`, `bash`), it is treated as a file-read tool.
|
|
246
|
-
|
|
247
|
-
**How to customize:**
|
|
248
|
-
- Name your custom tool to match one of the patterns above (e.g. `my_read_file`)
|
|
249
|
-
- Include `path`, `filePath`, or `file` in its arguments
|
|
250
|
-
- Or extend the `FILE_READ_TOOLS` list in a fork
|
|
251
|
-
|
|
252
|
-
The `isFileReadTool()` function is exported for testing and advanced usage:
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
import { isFileReadTool } from "opencode-hashline";
|
|
256
|
-
|
|
257
|
-
isFileReadTool("read_file"); // true
|
|
258
|
-
isFileReadTool("mcp.read"); // true
|
|
259
|
-
isFileReadTool("custom_reader", { path: "app.ts" }); // true (heuristic)
|
|
260
|
-
isFileReadTool("file_write", { path: "app.ts" }); // false (write indicator)
|
|
261
|
-
```
|
|
236
|
+
---
|
|
262
237
|
|
|
263
|
-
|
|
238
|
+
## 🔌 Программный API
|
|
264
239
|
|
|
265
|
-
|
|
240
|
+
Основные утилиты экспортируются из субпути `opencode-hashline/utils` (чтобы избежать конфликтов с загрузчиком плагинов OpenCode, который вызывает каждый экспорт как функцию Plugin):
|
|
266
241
|
|
|
267
242
|
```typescript
|
|
268
243
|
import {
|
|
@@ -286,161 +261,161 @@ import {
|
|
|
286
261
|
} from "opencode-hashline/utils";
|
|
287
262
|
```
|
|
288
263
|
|
|
289
|
-
###
|
|
264
|
+
### Основные функции
|
|
290
265
|
|
|
291
266
|
```typescript
|
|
292
|
-
//
|
|
293
|
-
const hash = computeLineHash(0, "function hello() {"); //
|
|
267
|
+
// Вычислить хеш для одной строки
|
|
268
|
+
const hash = computeLineHash(0, "function hello() {"); // например "a3f"
|
|
294
269
|
|
|
295
|
-
//
|
|
296
|
-
const hash4 = computeLineHash(0, "function hello() {", 4); //
|
|
270
|
+
// Вычислить хеш с определённой длиной
|
|
271
|
+
const hash4 = computeLineHash(0, "function hello() {", 4); // например "a3f2"
|
|
297
272
|
|
|
298
|
-
//
|
|
273
|
+
// Аннотировать содержимое файла (адаптивная длина хеша, с префиксом #HL)
|
|
299
274
|
const annotated = formatFileWithHashes(fileContent);
|
|
300
275
|
// "#HL 1:a3|function hello() {\n#HL 2:f1| return \"world\";\n#HL 3:0e|}"
|
|
301
276
|
|
|
302
|
-
//
|
|
277
|
+
// Аннотировать с определённой длиной хеша
|
|
303
278
|
const annotated3 = formatFileWithHashes(fileContent, 3);
|
|
304
279
|
|
|
305
|
-
//
|
|
280
|
+
// Аннотировать без префикса (legacy-формат)
|
|
306
281
|
const annotatedLegacy = formatFileWithHashes(fileContent, undefined, false);
|
|
307
282
|
|
|
308
|
-
//
|
|
283
|
+
// Убрать аннотации, получить оригинальное содержимое
|
|
309
284
|
const original = stripHashes(annotated);
|
|
310
285
|
```
|
|
311
286
|
|
|
312
|
-
###
|
|
287
|
+
### Хеш-ссылки и верификация
|
|
313
288
|
|
|
314
289
|
```typescript
|
|
315
|
-
//
|
|
290
|
+
// Разобрать хеш-ссылку
|
|
316
291
|
const { line, hash } = parseHashRef("2:f1c"); // { line: 2, hash: "f1c" }
|
|
317
292
|
|
|
318
|
-
//
|
|
293
|
+
// Нормализовать ссылку из аннотированной строки
|
|
319
294
|
const ref = normalizeHashRef("#HL 2:f1c|const x = 1;"); // "2:f1c"
|
|
320
295
|
|
|
321
|
-
//
|
|
296
|
+
// Построить карту соответствий
|
|
322
297
|
const map = buildHashMap(fileContent); // Map<"2:f1c", 2>
|
|
323
298
|
|
|
324
|
-
//
|
|
299
|
+
// Верифицировать хеш-ссылку (использует hash.length, а не размер файла)
|
|
325
300
|
const result = verifyHash(2, "f1c", fileContent);
|
|
326
301
|
```
|
|
327
302
|
|
|
328
|
-
### Range
|
|
303
|
+
### Range-операции
|
|
329
304
|
|
|
330
305
|
```typescript
|
|
331
|
-
//
|
|
306
|
+
// Резолвить диапазон
|
|
332
307
|
const range = resolveRange("1:a3f", "3:0e7", fileContent);
|
|
333
308
|
|
|
334
|
-
//
|
|
335
|
-
const newContent = replaceRange("1:a3f", "3:0e7", fileContent, "
|
|
309
|
+
// Заменить диапазон
|
|
310
|
+
const newContent = replaceRange("1:a3f", "3:0e7", fileContent, "новое содержимое");
|
|
336
311
|
|
|
337
|
-
// Hash-aware
|
|
312
|
+
// Hash-aware операция редактирования (replace/delete/insert_before/insert_after)
|
|
338
313
|
const edited = applyHashEdit(
|
|
339
|
-
{ operation: "replace", startRef: "1:a3f", endRef: "3:0e7", replacement: "
|
|
314
|
+
{ operation: "replace", startRef: "1:a3f", endRef: "3:0e7", replacement: "новое содержимое" },
|
|
340
315
|
fileContent
|
|
341
316
|
).content;
|
|
342
317
|
```
|
|
343
318
|
|
|
344
|
-
###
|
|
319
|
+
### Утилиты
|
|
345
320
|
|
|
346
321
|
```typescript
|
|
347
|
-
//
|
|
322
|
+
// Проверить, нужно ли исключить файл
|
|
348
323
|
const excluded = shouldExclude("node_modules/foo.js", ["**/node_modules/**"]);
|
|
349
324
|
|
|
350
|
-
//
|
|
325
|
+
// Создать настроенный экземпляр
|
|
351
326
|
const hl = createHashline({ cacheSize: 50, hashLength: 3 });
|
|
352
327
|
```
|
|
353
328
|
|
|
354
329
|
---
|
|
355
330
|
|
|
356
|
-
## 📊
|
|
331
|
+
## 📊 Бенчмарк
|
|
357
332
|
|
|
358
|
-
###
|
|
333
|
+
### Корректность: hashline vs str_replace
|
|
359
334
|
|
|
360
|
-
|
|
335
|
+
Оба подхода протестированы на **60 фикстурах из [react-edit-benchmark](https://github.com/can1357/oh-my-pi/tree/main/packages/react-edit-benchmark)** — мутированных файлах React с известными багами (инвертированные булевы, перепутанные операторы, удалённые guard-клаузы и т.д.):
|
|
361
336
|
|
|
362
337
|
| | hashline | str_replace |
|
|
363
338
|
|---|:---:|:---:|
|
|
364
|
-
|
|
|
365
|
-
|
|
|
366
|
-
|
|
|
339
|
+
| **Прошло** | **60/60 (100%)** | 58/60 (96.7%) |
|
|
340
|
+
| **Провалено** | 0 | 2 |
|
|
341
|
+
| **Неоднозначные правки** | 0 | 4 |
|
|
367
342
|
|
|
368
|
-
str_replace
|
|
343
|
+
str_replace ломается, когда `old_string` встречается в файле несколько раз (например, повторяющиеся guard-клаузы, похожие блоки кода). Hashline адресует каждую строку уникально через `lineNumber:hash`, поэтому неоднозначность исключена.
|
|
369
344
|
|
|
370
345
|
```bash
|
|
371
|
-
#
|
|
372
|
-
npx tsx benchmark/run.ts # hashline
|
|
373
|
-
npx tsx benchmark/run.ts --no-hash # str_replace
|
|
346
|
+
# Запустите сами:
|
|
347
|
+
npx tsx benchmark/run.ts # режим hashline
|
|
348
|
+
npx tsx benchmark/run.ts --no-hash # режим str_replace
|
|
374
349
|
```
|
|
375
350
|
|
|
376
351
|
<details>
|
|
377
|
-
<summary
|
|
352
|
+
<summary>Ошибки str_replace (категория structural)</summary>
|
|
378
353
|
|
|
379
|
-
- `structural-remove-early-return-001` — `old_string`
|
|
380
|
-
- `structural-remove-early-return-002` —
|
|
381
|
-
- `structural-delete-statement-002` —
|
|
382
|
-
- `structural-delete-statement-003` —
|
|
354
|
+
- `structural-remove-early-return-001` — `old_string` совпал в нескольких местах, замена применена не к тому
|
|
355
|
+
- `structural-remove-early-return-002` — аналогичная проблема
|
|
356
|
+
- `structural-delete-statement-002` — неоднозначное совпадение (первое совпадение оказалось верным)
|
|
357
|
+
- `structural-delete-statement-003` — неоднозначное совпадение (первое совпадение оказалось верным)
|
|
383
358
|
|
|
384
359
|
</details>
|
|
385
360
|
|
|
386
|
-
###
|
|
361
|
+
### Расход токенов
|
|
387
362
|
|
|
388
|
-
|
|
363
|
+
Аннотации hashline добавляют префикс `#HL <line>:<hash>|` (~12 символов / ~3 токена) на строку:
|
|
389
364
|
|
|
390
|
-
| |
|
|
365
|
+
| | Без хешей | С хешами | Оверхед |
|
|
391
366
|
|---|---:|---:|:---:|
|
|
392
|
-
|
|
|
393
|
-
|
|
|
367
|
+
| **Символы** | 404K | 564K | +40% |
|
|
368
|
+
| **Токены (~)** | ~101K | ~141K | +40% |
|
|
394
369
|
|
|
395
|
-
|
|
370
|
+
Оверхед стабильно ~40% независимо от размера файла. Для типичного файла на 200 строк (~800 токенов) hashline добавляет ~600 токенов — пренебрежимо мало при контекстном окне в 200K.
|
|
396
371
|
|
|
397
|
-
###
|
|
372
|
+
### Производительность
|
|
398
373
|
|
|
399
|
-
|
|
|
400
|
-
|
|
401
|
-
| **10**
|
|
402
|
-
| **100**
|
|
403
|
-
| **1
|
|
404
|
-
| **5
|
|
405
|
-
| **10
|
|
374
|
+
| Размер файла | Аннотация | Правка | Удаление хешей |
|
|
375
|
+
|-------------:|:---------:|:------:|:--------------:|
|
|
376
|
+
| **10** строк | 0.05 мс | 0.01 мс | 0.03 мс |
|
|
377
|
+
| **100** строк | 0.12 мс | 0.02 мс | 0.08 мс |
|
|
378
|
+
| **1 000** строк | 0.95 мс | 0.04 мс | 0.60 мс |
|
|
379
|
+
| **5 000** строк | 4.50 мс | 0.08 мс | 2.80 мс |
|
|
380
|
+
| **10 000** строк | 9.20 мс | 0.10 мс | 5.50 мс |
|
|
406
381
|
|
|
407
|
-
>
|
|
382
|
+
> Типичный файл из 1 000 строк аннотируется за **< 1 мс** — незаметно для пользователя.
|
|
408
383
|
|
|
409
384
|
---
|
|
410
385
|
|
|
411
|
-
## 🧑💻
|
|
386
|
+
## 🧑💻 Разработка
|
|
412
387
|
|
|
413
388
|
```bash
|
|
414
|
-
#
|
|
389
|
+
# Установить зависимости
|
|
415
390
|
npm install
|
|
416
391
|
|
|
417
|
-
#
|
|
392
|
+
# Запустить тесты
|
|
418
393
|
npm test
|
|
419
394
|
|
|
420
|
-
#
|
|
395
|
+
# Собрать
|
|
421
396
|
npm run build
|
|
422
397
|
|
|
423
|
-
#
|
|
398
|
+
# Проверка типов
|
|
424
399
|
npm run typecheck
|
|
425
400
|
```
|
|
426
401
|
|
|
427
402
|
---
|
|
428
403
|
|
|
429
|
-
## 💡
|
|
404
|
+
## 💡 Вдохновение и теоретическая база
|
|
430
405
|
|
|
431
|
-
|
|
406
|
+
Идея hashline вдохновлена концепциями из **oh-my-pi** от [can1357](https://github.com/can1357/oh-my-pi) — AI-тулкита для разработки (coding agent CLI, unified LLM API, TUI-библиотеки) — и статьи «The Harness Problem» (проблема обвязки).
|
|
432
407
|
|
|
433
|
-
|
|
408
|
+
**Суть проблемы:** современные AI-модели обладают огромными возможностями, но инструменты (harness), которые передают модели контекст и применяют её правки к файлам, теряют информацию и порождают ошибки. Модель видит содержимое файла, но при редактировании вынуждена «угадывать» контекст окружающих строк. Search-and-replace ломается на дубликатах строк, а diff-формат тоже ненадёжен на практике.
|
|
434
409
|
|
|
435
|
-
Hashline
|
|
410
|
+
Hashline решает эту проблему, присваивая каждой строке короткий детерминированный хеш-тег (например, `2:f1c`), что делает адресацию строк **точной и однозначной**. Модель может ссылаться на любую строку или диапазон без ошибок смещения и путаницы с дубликатами.
|
|
436
411
|
|
|
437
|
-
|
|
438
|
-
- [oh-my-pi
|
|
439
|
-
- [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) —
|
|
440
|
-
- [
|
|
412
|
+
**Ссылки:**
|
|
413
|
+
- [oh-my-pi от can1357](https://github.com/can1357/oh-my-pi) — AI-тулкит для разработки: coding agent CLI, unified LLM API, TUI-библиотеки
|
|
414
|
+
- [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) — блог-пост с подробным описанием проблемы
|
|
415
|
+
- [Статья на Хабре](https://habr.com/ru/companies/bothub/news/995986/) — описание подхода на русском языке
|
|
441
416
|
|
|
442
417
|
---
|
|
443
418
|
|
|
444
|
-
## 📄
|
|
419
|
+
## 📄 Лицензия
|
|
445
420
|
|
|
446
421
|
[MIT](LICENSE) © opencode-hashline contributors
|
package/README.ru.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
[](https://www.typescriptlang.org/)
|
|
15
15
|
[](https://nodejs.org/)
|
|
16
16
|
|
|
17
|
-
**🇷🇺 Русский** | [🇬🇧 English](README.md)
|
|
17
|
+
**🇷🇺 Русский** | [🇬🇧 English](README.en.md)
|
|
18
18
|
|
|
19
19
|
<br />
|
|
20
20
|
|
|
@@ -452,13 +452,13 @@ var init_hashline = __esm({
|
|
|
452
452
|
});
|
|
453
453
|
|
|
454
454
|
// src/index.ts
|
|
455
|
-
var
|
|
456
|
-
__export(
|
|
455
|
+
var src_exports = {};
|
|
456
|
+
__export(src_exports, {
|
|
457
457
|
HashlinePlugin: () => HashlinePlugin,
|
|
458
458
|
createHashlinePlugin: () => createHashlinePlugin,
|
|
459
|
-
default: () =>
|
|
459
|
+
default: () => src_default
|
|
460
460
|
});
|
|
461
|
-
module.exports = __toCommonJS(
|
|
461
|
+
module.exports = __toCommonJS(src_exports);
|
|
462
462
|
var import_fs3 = require("fs");
|
|
463
463
|
var import_path3 = require("path");
|
|
464
464
|
var import_os2 = require("os");
|
|
@@ -899,7 +899,7 @@ function createHashlinePlugin(userConfig) {
|
|
|
899
899
|
};
|
|
900
900
|
}
|
|
901
901
|
var HashlinePlugin = createHashlinePlugin();
|
|
902
|
-
var
|
|
902
|
+
var src_default = HashlinePlugin;
|
|
903
903
|
// Annotate the CommonJS export names for ESM import in node:
|
|
904
904
|
0 && (module.exports = {
|
|
905
905
|
HashlinePlugin,
|
|
@@ -237,9 +237,9 @@ function createHashlinePlugin(userConfig) {
|
|
|
237
237
|
};
|
|
238
238
|
}
|
|
239
239
|
var HashlinePlugin = createHashlinePlugin();
|
|
240
|
-
var
|
|
240
|
+
var src_default = HashlinePlugin;
|
|
241
241
|
export {
|
|
242
242
|
HashlinePlugin,
|
|
243
243
|
createHashlinePlugin,
|
|
244
|
-
|
|
244
|
+
src_default as default
|
|
245
245
|
};
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-hashline",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Hashline plugin for OpenCode — content-addressable line hashing for precise AI code editing",
|
|
5
|
-
"main": "dist/
|
|
6
|
-
"module": "dist/
|
|
7
|
-
"types": "dist/
|
|
5
|
+
"main": "dist/opencode-hashline.cjs",
|
|
6
|
+
"module": "dist/opencode-hashline.js",
|
|
7
|
+
"types": "dist/opencode-hashline.d.ts",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"exports": {
|
|
13
13
|
".": {
|
|
14
|
-
"types": "./dist/
|
|
15
|
-
"import": "./dist/
|
|
16
|
-
"require": "./dist/
|
|
14
|
+
"types": "./dist/opencode-hashline.d.ts",
|
|
15
|
+
"import": "./dist/opencode-hashline.js",
|
|
16
|
+
"require": "./dist/opencode-hashline.cjs"
|
|
17
17
|
},
|
|
18
18
|
"./utils": {
|
|
19
19
|
"types": "./dist/utils.d.ts",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@opencode-ai/plugin": "^1.2.2",
|
|
44
|
-
"zod": "^
|
|
44
|
+
"zod": "^4.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@opencode-ai/plugin": "^1.2.2",
|
|
File without changes
|
|
File without changes
|