opencode-hashline 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 izzzzzi
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,442 @@
1
+ <div align="center">
2
+
3
+ # πŸ”— opencode-hashline
4
+
5
+ **Content-addressable line hashing for precise AI code editing**
6
+
7
+ [![CI](https://github.com/izzzzzi/opencode-hashline/actions/workflows/ci.yml/badge.svg)](https://github.com/izzzzzi/opencode-hashline/actions/workflows/ci.yml)
8
+ [![npm version](https://img.shields.io/npm/v/opencode-hashline.svg?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/opencode-hashline)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat&colorA=18181B&colorB=28CF8D)](LICENSE)
10
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat&colorA=18181B&colorB=3178C6)](https://www.typescriptlang.org/)
11
+ [![Node.js](https://img.shields.io/badge/Node.js-ESM-green?style=flat&colorA=18181B&colorB=339933)](https://nodejs.org/)
12
+
13
+ [πŸ‡·πŸ‡Ί Русский](README.ru.md) | **πŸ‡¬πŸ‡§ English**
14
+
15
+ <br />
16
+
17
+ *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.*
18
+
19
+ </div>
20
+
21
+ ---
22
+
23
+ ## πŸ“– What is Hashline?
24
+
25
+ Hashline annotates every line of a file with a short, deterministic hex hash tag. When the AI reads a file, it sees:
26
+
27
+ ```
28
+ #HL 1:a3f|function hello() {
29
+ #HL 2:f1c| return "world";
30
+ #HL 3:0e7|}
31
+ ```
32
+
33
+ > **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.
34
+
35
+ The AI model can then reference lines by their hash tags for precise editing:
36
+
37
+ - **"Replace line `2:f1c`"** β€” target a specific line unambiguously
38
+ - **"Replace block from `1:a3f` to `3:0e7`"** β€” target a range of lines
39
+ - **"Insert after `3:0e7`"** β€” insert at a precise location
40
+
41
+ ### πŸ€” Why does this help?
42
+
43
+ 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.
44
+
45
+ ---
46
+
47
+ ## ✨ Features
48
+
49
+ ### πŸ“ Adaptive Hash Length
50
+
51
+ Hash length automatically adapts to file size to minimize collisions:
52
+
53
+ | File Size | Hash Length | Possible Values |
54
+ |-----------|:----------:|:---------------:|
55
+ | ≀ 256 lines | 2 hex chars | 256 |
56
+ | ≀ 4,096 lines | 3 hex chars | 4,096 |
57
+ | > 4,096 lines | 4 hex chars | 65,536 |
58
+
59
+ ### 🏷️ Magic Prefix (`#HL `)
60
+
61
+ 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.
62
+
63
+ ```
64
+ #HL 1:a3|function hello() {
65
+ #HL 2:f1| return "world";
66
+ #HL 3:0e|}
67
+ ```
68
+
69
+ The prefix can be customized or disabled for backward compatibility:
70
+
71
+ ```typescript
72
+ // Custom prefix
73
+ const hl = createHashline({ prefix: ">> " });
74
+
75
+ // Disable prefix (legacy format: "1:a3|code")
76
+ const hl = createHashline({ prefix: false });
77
+ ```
78
+
79
+ ### πŸ’Ύ LRU Caching
80
+
81
+ 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.
82
+
83
+ ### βœ… Hash Verification
84
+
85
+ Verify that a line hasn't changed since it was read β€” protects against race conditions:
86
+
87
+ ```typescript
88
+ import { verifyHash } from "opencode-hashline";
89
+
90
+ const result = verifyHash(2, "f1c", currentContent);
91
+ if (!result.valid) {
92
+ console.error(result.message); // "Hash mismatch at line 2: ..."
93
+ }
94
+ ```
95
+
96
+ 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.
97
+
98
+ ### πŸ” Indentation-Sensitive Hashing
99
+
100
+ Hash computation uses `trimEnd()` (not `trim()`), so changes to leading whitespace (indentation) are detected as content changes, while trailing whitespace is ignored.
101
+
102
+ ### πŸ“ Range Operations
103
+
104
+ Resolve and replace ranges of lines by hash references:
105
+
106
+ ```typescript
107
+ import { resolveRange, replaceRange } from "opencode-hashline";
108
+
109
+ // Get lines between two hash references
110
+ const range = resolveRange("1:a3f", "3:0e7", content);
111
+ console.log(range.lines); // ["function hello() {", ' return "world";', "}"]
112
+
113
+ // Replace a range with new content
114
+ const newContent = replaceRange(
115
+ "1:a3f", "3:0e7", content,
116
+ "function goodbye() {\n return 'farewell';\n}"
117
+ );
118
+ ```
119
+
120
+ ### βš™οΈ Configurable
121
+
122
+ Create custom Hashline instances with specific settings:
123
+
124
+ ```typescript
125
+ import { createHashline } from "opencode-hashline";
126
+
127
+ const hl = createHashline({
128
+ exclude: ["**/node_modules/**", "**/*.min.js"],
129
+ maxFileSize: 512_000, // 512 KB
130
+ hashLength: 3, // force 3-char hashes
131
+ cacheSize: 200, // cache up to 200 files
132
+ prefix: "#HL ", // magic prefix (default)
133
+ });
134
+
135
+ // Use the configured instance
136
+ const annotated = hl.formatFileWithHashes(content, "src/app.ts");
137
+ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
138
+ ```
139
+
140
+ #### Configuration Options
141
+
142
+ | Option | Type | Default | Description |
143
+ |--------|------|---------|-------------|
144
+ | `exclude` | `string[]` | See below | Glob patterns for files to skip |
145
+ | `maxFileSize` | `number` | `1_000_000` | Max file size in bytes |
146
+ | `hashLength` | `number \| undefined` | `undefined` (adaptive) | Force specific hash length |
147
+ | `cacheSize` | `number` | `100` | Max files in LRU cache |
148
+ | `prefix` | `string \| false` | `"#HL "` | Line prefix (`false` to disable) |
149
+
150
+ Default exclude patterns cover: lock files, `node_modules`, minified files, binary files (images, fonts, archives, etc.).
151
+
152
+ ---
153
+
154
+ ## πŸ“¦ Installation
155
+
156
+ ```bash
157
+ npm install opencode-hashline
158
+ ```
159
+
160
+ ---
161
+
162
+ ## πŸ”§ Configuration
163
+
164
+ Add the plugin to your `opencode.json`:
165
+
166
+ ```json
167
+ {
168
+ "$schema": "https://opencode.ai/config.json",
169
+ "plugin": ["opencode-hashline"]
170
+ }
171
+ ```
172
+
173
+ ### Configuration Files
174
+
175
+ The plugin loads configuration from the following locations (in priority order, later overrides earlier):
176
+
177
+ | Priority | Location | Scope |
178
+ |:--------:|----------|-------|
179
+ | 1 | `~/.config/opencode/opencode-hashline.json` | Global (all projects) |
180
+ | 2 | `<project>/opencode-hashline.json` | Project-local |
181
+ | 3 | Programmatic config via `createHashlinePlugin()` | Factory argument |
182
+
183
+ Example `opencode-hashline.json`:
184
+
185
+ ```json
186
+ {
187
+ "exclude": ["**/node_modules/**", "**/*.min.js"],
188
+ "maxFileSize": 1048576,
189
+ "hashLength": 0,
190
+ "cacheSize": 100,
191
+ "prefix": "#HL "
192
+ }
193
+ ```
194
+
195
+ That's it! The plugin automatically:
196
+
197
+ | # | Action | Description |
198
+ |:-:|--------|-------------|
199
+ | 1 | πŸ“ **Annotates file reads** | When the AI reads a file, each line gets a `#HL` hash prefix |
200
+ | 2 | πŸ“Ž **Annotates `@file` mentions** | Files attached via `@filename` in prompts are also annotated with hashlines |
201
+ | 3 | βœ‚οΈ **Strips hash prefixes on edits** | When the AI writes/edits a file, hash prefixes are removed before applying changes |
202
+ | 4 | 🧠 **Injects system prompt instructions** | The AI is told how to interpret and use hashline references |
203
+ | 5 | πŸ’Ύ **Caches results** | Repeated reads of the same file return cached annotations |
204
+ | 6 | πŸ” **Filters by tool** | Only file-reading tools (e.g. `read_file`, `cat`, `view`) get annotations; other tools are left untouched |
205
+ | 7 | βš™οΈ **Respects config** | Excluded files and files exceeding `maxFileSize` are skipped |
206
+ | 8 | 🧩 **Registers `hashline_edit` tool** | Applies replace/delete/insert by hash references, without exact `old_string` matching |
207
+
208
+ ---
209
+
210
+ ## πŸ› οΈ How It Works
211
+
212
+ ### Hash Computation
213
+
214
+ Each line's hash is computed from:
215
+ - The **0-based line index**
216
+ - The **trimEnd'd line content** β€” leading whitespace (indentation) IS significant
217
+
218
+ This is fed through an **FNV-1a** hash function, reduced to the appropriate modulus based on file size, and rendered as a hex string.
219
+
220
+ ### Plugin Hooks & Tool
221
+
222
+ The plugin registers four OpenCode hooks and one custom tool:
223
+
224
+ | Hook | Purpose |
225
+ |------|---------|
226
+ | `tool.hashline_edit` | Hash-aware edits by references like `5:a3f` or `#HL 5:a3f|...` |
227
+ | `tool.execute.after` | Injects hashline annotations into file-read tool output |
228
+ | `tool.execute.before` | Strips hashline prefixes from file-edit tool arguments |
229
+ | `chat.message` | Annotates `@file` mentions in user messages (writes annotated content to a temp file and swaps the URL) |
230
+ | `experimental.chat.system.transform` | Adds hashline usage instructions to the system prompt |
231
+
232
+ ### Tool Detection Heuristic (`isFileReadTool`)
233
+
234
+ 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:
235
+
236
+ **Exact match** β€” the tool name (case-insensitive) is compared against the allow-list:
237
+ - `read`, `file_read`, `read_file`, `cat`, `view`
238
+
239
+ **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.
240
+
241
+ **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.
242
+
243
+ **How to customize:**
244
+ - Name your custom tool to match one of the patterns above (e.g. `my_read_file`)
245
+ - Include `path`, `filePath`, or `file` in its arguments
246
+ - Or extend the `FILE_READ_TOOLS` list in a fork
247
+
248
+ The `isFileReadTool()` function is exported for testing and advanced usage:
249
+
250
+ ```typescript
251
+ import { isFileReadTool } from "opencode-hashline";
252
+
253
+ isFileReadTool("read_file"); // true
254
+ isFileReadTool("mcp.read"); // true
255
+ isFileReadTool("custom_reader", { path: "app.ts" }); // true (heuristic)
256
+ isFileReadTool("file_write", { path: "app.ts" }); // false (write indicator)
257
+ ```
258
+
259
+ ### Programmatic API
260
+
261
+ 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):
262
+
263
+ ```typescript
264
+ import {
265
+ computeLineHash,
266
+ formatFileWithHashes,
267
+ stripHashes,
268
+ parseHashRef,
269
+ normalizeHashRef,
270
+ buildHashMap,
271
+ getAdaptiveHashLength,
272
+ verifyHash,
273
+ resolveRange,
274
+ replaceRange,
275
+ applyHashEdit,
276
+ HashlineCache,
277
+ createHashline,
278
+ shouldExclude,
279
+ matchesGlob,
280
+ resolveConfig,
281
+ DEFAULT_PREFIX,
282
+ } from "opencode-hashline/utils";
283
+ ```
284
+
285
+ ### Core Functions
286
+
287
+ ```typescript
288
+ // Compute hash for a single line
289
+ const hash = computeLineHash(0, "function hello() {"); // e.g. "a3f"
290
+
291
+ // Compute hash with specific length
292
+ const hash4 = computeLineHash(0, "function hello() {", 4); // e.g. "a3f2"
293
+
294
+ // Annotate entire file content (adaptive hash length, with #HL prefix)
295
+ const annotated = formatFileWithHashes(fileContent);
296
+ // "#HL 1:a3|function hello() {\n#HL 2:f1| return \"world\";\n#HL 3:0e|}"
297
+
298
+ // Annotate with specific hash length
299
+ const annotated3 = formatFileWithHashes(fileContent, 3);
300
+
301
+ // Annotate without prefix (legacy format)
302
+ const annotatedLegacy = formatFileWithHashes(fileContent, undefined, false);
303
+
304
+ // Strip annotations to get original content
305
+ const original = stripHashes(annotated);
306
+ ```
307
+
308
+ ### Hash References & Verification
309
+
310
+ ```typescript
311
+ // Parse a hash reference
312
+ const { line, hash } = parseHashRef("2:f1c"); // { line: 2, hash: "f1c" }
313
+
314
+ // Normalize from an annotated line
315
+ const ref = normalizeHashRef("#HL 2:f1c|const x = 1;"); // "2:f1c"
316
+
317
+ // Build a lookup map
318
+ const map = buildHashMap(fileContent); // Map<"2:f1c", 2>
319
+
320
+ // Verify a hash reference (uses hash.length, not file size)
321
+ const result = verifyHash(2, "f1c", fileContent);
322
+ ```
323
+
324
+ ### Range Operations
325
+
326
+ ```typescript
327
+ // Resolve a range
328
+ const range = resolveRange("1:a3f", "3:0e7", fileContent);
329
+
330
+ // Replace a range
331
+ const newContent = replaceRange("1:a3f", "3:0e7", fileContent, "new content");
332
+
333
+ // Hash-aware edit operation (replace/delete/insert_before/insert_after)
334
+ const edited = applyHashEdit(
335
+ { operation: "replace", startRef: "1:a3f", endRef: "3:0e7", replacement: "new content" },
336
+ fileContent
337
+ ).content;
338
+ ```
339
+
340
+ ### Utilities
341
+
342
+ ```typescript
343
+ // Check if a file should be excluded
344
+ const excluded = shouldExclude("node_modules/foo.js", ["**/node_modules/**"]);
345
+
346
+ // Create a configured instance
347
+ const hl = createHashline({ cacheSize: 50, hashLength: 3 });
348
+ ```
349
+
350
+ ---
351
+
352
+ ## πŸ“Š Benchmark
353
+
354
+ ### Correctness: hashline vs str_replace
355
+
356
+ 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.):
357
+
358
+ | | hashline | str_replace |
359
+ |---|:---:|:---:|
360
+ | **Passed** | **60/60 (100%)** | 58/60 (96.7%) |
361
+ | **Failed** | 0 | 2 |
362
+ | **Ambiguous edits** | 0 | 4 |
363
+
364
+ 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.
365
+
366
+ ```bash
367
+ # Run yourself:
368
+ npx tsx benchmark/run.ts # hashline mode
369
+ npx tsx benchmark/run.ts --no-hash # str_replace mode
370
+ ```
371
+
372
+ <details>
373
+ <summary>str_replace failures (structural category)</summary>
374
+
375
+ - `structural-remove-early-return-001` β€” `old_string` matched multiple locations, wrong one replaced
376
+ - `structural-remove-early-return-002` β€” same issue
377
+ - `structural-delete-statement-002` β€” ambiguous match (first match happened to be correct)
378
+ - `structural-delete-statement-003` β€” ambiguous match (first match happened to be correct)
379
+
380
+ </details>
381
+
382
+ ### Token Overhead
383
+
384
+ Hashline annotations add `#HL <line>:<hash>|` prefix (~12 chars / ~3 tokens) per line:
385
+
386
+ | | Plain | Annotated | Overhead |
387
+ |---|---:|---:|:---:|
388
+ | **Characters** | 404K | 564K | +40% |
389
+ | **Tokens (~)** | ~101K | ~141K | +40% |
390
+
391
+ 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.
392
+
393
+ ### Performance
394
+
395
+ | File Size | Annotate | Edit | Strip |
396
+ |----------:|:--------:|:----:|:-----:|
397
+ | **10** lines | 0.05 ms | 0.01 ms | 0.03 ms |
398
+ | **100** lines | 0.12 ms | 0.02 ms | 0.08 ms |
399
+ | **1,000** lines | 0.95 ms | 0.04 ms | 0.60 ms |
400
+ | **5,000** lines | 4.50 ms | 0.08 ms | 2.80 ms |
401
+ | **10,000** lines | 9.20 ms | 0.10 ms | 5.50 ms |
402
+
403
+ > A typical 1,000-line source file is annotated in **< 1ms** β€” imperceptible to the user.
404
+
405
+ ---
406
+
407
+ ## πŸ§‘β€πŸ’» Development
408
+
409
+ ```bash
410
+ # Install dependencies
411
+ npm install
412
+
413
+ # Run tests
414
+ npm test
415
+
416
+ # Build
417
+ npm run build
418
+
419
+ # Type check
420
+ npm run typecheck
421
+ ```
422
+
423
+ ---
424
+
425
+ ## πŸ’‘ Inspiration & Background
426
+
427
+ 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."
428
+
429
+ **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).
430
+
431
+ 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.
432
+
433
+ **References:**
434
+ - [oh-my-pi by can1357](https://github.com/can1357/oh-my-pi) β€” AI coding agent toolkit: coding agent CLI, unified LLM API, TUI libraries
435
+ - [The Harness Problem](https://blog.can.ac/2026/02/12/the-harness-problem/) β€” blog post describing the problem in detail
436
+ - [ОписаниС ΠΏΠΎΠ΄Ρ…ΠΎΠ΄Π° Π½Π° Π₯Π°Π±Ρ€Π΅](https://habr.com/ru/companies/bothub/news/995986/) β€” overview of the approach in Russian
437
+
438
+ ---
439
+
440
+ ## πŸ“„ License
441
+
442
+ [MIT](LICENSE) Β© opencode-hashline contributors