opencode-hashline 1.3.0 → 1.3.2

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  <div align="center">
2
2
 
3
+ <img src="banner.jpg" alt="opencode-hashline banner" width="100%" />
4
+
3
5
  # 🔗 opencode-hashline
4
6
 
5
7
  **Content-addressable line hashing for precise AI code editing**
@@ -61,7 +63,6 @@ Hash length automatically adapts to file size to minimize collisions:
61
63
 
62
64
  | File Size | Hash Length | Possible Values |
63
65
  |-----------|:----------:|:---------------:|
64
- | ≤ 256 lines | 2 hex chars | 256 |
65
66
  | ≤ 4,096 lines | 3 hex chars | 4,096 |
66
67
  | > 4,096 lines | 4 hex chars | 65,536 |
67
68
 
@@ -94,7 +95,7 @@ Built-in LRU cache (`filePath → annotatedContent`) with configurable size (def
94
95
  Verify that a line hasn't changed since it was read — protects against race conditions:
95
96
 
96
97
  ```typescript
97
- import { verifyHash } from "opencode-hashline";
98
+ import { verifyHash } from "opencode-hashline/utils";
98
99
 
99
100
  const result = verifyHash(2, "f1c", currentContent);
100
101
  if (!result.valid) {
@@ -156,7 +157,7 @@ Hash computation uses `trimEnd()` (not `trim()`), so changes to leading whitespa
156
157
  Resolve and replace ranges of lines by hash references:
157
158
 
158
159
  ```typescript
159
- import { resolveRange, replaceRange } from "opencode-hashline";
160
+ import { resolveRange, replaceRange } from "opencode-hashline/utils";
160
161
 
161
162
  // Get lines between two hash references
162
163
  const range = resolveRange("1:a3f", "3:0e7", content);
@@ -174,7 +175,7 @@ const newContent = replaceRange(
174
175
  Create custom Hashline instances with specific settings:
175
176
 
176
177
  ```typescript
177
- import { createHashline } from "opencode-hashline";
178
+ import { createHashline } from "opencode-hashline/utils";
178
179
 
179
180
  const hl = createHashline({
180
181
  exclude: ["**/node_modules/**", "**/*.min.js"],
@@ -194,7 +195,7 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
194
195
  | Option | Type | Default | Description |
195
196
  |--------|------|---------|-------------|
196
197
  | `exclude` | `string[]` | See below | Glob patterns for files to skip |
197
- | `maxFileSize` | `number` | `1_000_000` | Max file size in bytes |
198
+ | `maxFileSize` | `number` | `1_048_576` (1 MB) | Max file size in bytes |
198
199
  | `hashLength` | `number \| undefined` | `undefined` (adaptive) | Force specific hash length |
199
200
  | `cacheSize` | `number` | `100` | Max files in LRU cache |
200
201
  | `prefix` | `string \| false` | `"#HL "` | Line prefix (`false` to disable) |
@@ -302,7 +303,7 @@ The plugin needs to determine which tools are "file-read" tools (to annotate the
302
303
  The `isFileReadTool()` function is exported for testing and advanced usage:
303
304
 
304
305
  ```typescript
305
- import { isFileReadTool } from "opencode-hashline";
306
+ import { isFileReadTool } from "opencode-hashline/utils";
306
307
 
307
308
  isFileReadTool("read_file"); // true
308
309
  isFileReadTool("mcp.read"); // true
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  <div align="center">
2
2
 
3
+ <img src="banner.jpg" alt="opencode-hashline banner" width="100%" />
4
+
3
5
  # 🔗 opencode-hashline
4
6
 
5
7
  **Контентно-адресуемое хеширование строк для точного редактирования кода с помощью AI**
@@ -34,7 +36,7 @@ Hashline аннотирует каждую строку файла коротк
34
36
  #HL 3:0e7|}
35
37
  ```
36
38
 
37
- > **Примечание:** Длина хеша адаптивная — она зависит от размера файла (2 символа для ≤256 строк, 3 символа для ≤4096 строк, 4 символа для >4096 строк). В примерах ниже используются 3-символьные хеши. Префикс `#HL ` защищает от ложных срабатываний при удалении хешей и является настраиваемым.
39
+ > **Примечание:** Длина хеша адаптивная — она зависит от размера файла (3 символа для ≤4096 строк, 4 символа для >4096 строк). Минимальная длина хеша — 3 символа. В примерах ниже используются 3-символьные хеши. Префикс `#HL ` защищает от ложных срабатываний при удалении хешей и является настраиваемым.
38
40
 
39
41
  AI-модель может ссылаться на строки по их хеш-тегам для точного редактирования:
40
42
 
@@ -61,7 +63,6 @@ Hashline адресует каждую строку уникальным хеш
61
63
 
62
64
  | Размер файла | Длина хеша | Возможных значений |
63
65
  |-------------|:----------:|:------------------:|
64
- | ≤ 256 строк | 2 hex-символа | 256 |
65
66
  | ≤ 4 096 строк | 3 hex-символа | 4 096 |
66
67
  | > 4 096 строк | 4 hex-символа | 65 536 |
67
68
 
@@ -94,7 +95,7 @@ const hl = createHashline({ prefix: false });
94
95
  Проверка того, что строка не изменилась с момента чтения — защита от race conditions:
95
96
 
96
97
  ```typescript
97
- import { verifyHash } from "opencode-hashline";
98
+ import { verifyHash } from "opencode-hashline/utils";
98
99
 
99
100
  const result = verifyHash(2, "f1c", currentContent);
100
101
  if (!result.valid) {
@@ -156,7 +157,7 @@ const result = applyHashEdit(
156
157
  Резолвинг и замена диапазонов строк по хеш-ссылкам:
157
158
 
158
159
  ```typescript
159
- import { resolveRange, replaceRange } from "opencode-hashline";
160
+ import { resolveRange, replaceRange } from "opencode-hashline/utils";
160
161
 
161
162
  // Получить строки между двумя хеш-ссылками
162
163
  const range = resolveRange("1:a3f", "3:0e7", content);
@@ -174,7 +175,7 @@ const newContent = replaceRange(
174
175
  Создание кастомных экземпляров Hashline с определёнными настройками:
175
176
 
176
177
  ```typescript
177
- import { createHashline } from "opencode-hashline";
178
+ import { createHashline } from "opencode-hashline/utils";
178
179
 
179
180
  const hl = createHashline({
180
181
  exclude: ["**/node_modules/**", "**/*.min.js"],
@@ -194,7 +195,7 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
194
195
  | Параметр | Тип | По умолчанию | Описание |
195
196
  |----------|-----|:------------:|----------|
196
197
  | `exclude` | `string[]` | См. ниже | Glob-паттерны для исключения файлов |
197
- | `maxFileSize` | `number` | `1_000_000` | Макс. размер файла в байтах |
198
+ | `maxFileSize` | `number` | `1_048_576` (1 МБ) | Макс. размер файла в байтах |
198
199
  | `hashLength` | `number \| undefined` | `undefined` (адаптивно) | Принудительная длина хеша |
199
200
  | `cacheSize` | `number` | `100` | Макс. файлов в LRU-кеше |
200
201
  | `prefix` | `string \| false` | `"#HL "` | Префикс строки (`false` для отключения) |
package/README.ru.md CHANGED
@@ -34,7 +34,7 @@ Hashline аннотирует каждую строку файла коротк
34
34
  #HL 3:0e7|}
35
35
  ```
36
36
 
37
- > **Примечание:** Длина хеша адаптивная — она зависит от размера файла (2 символа для ≤256 строк, 3 символа для ≤4096 строк, 4 символа для >4096 строк). В примерах ниже используются 3-символьные хеши. Префикс `#HL ` защищает от ложных срабатываний при удалении хешей и является настраиваемым.
37
+ > **Примечание:** Длина хеша адаптивная — она зависит от размера файла (3 символа для ≤4096 строк, 4 символа для >4096 строк). Минимальная длина хеша — 3 символа. В примерах ниже используются 3-символьные хеши. Префикс `#HL ` защищает от ложных срабатываний при удалении хешей и является настраиваемым.
38
38
 
39
39
  AI-модель может ссылаться на строки по их хеш-тегам для точного редактирования:
40
40
 
@@ -56,7 +56,6 @@ AI-модель может ссылаться на строки по их хеш
56
56
 
57
57
  | Размер файла | Длина хеша | Возможных значений |
58
58
  |-------------|:----------:|:------------------:|
59
- | ≤ 256 строк | 2 hex-символа | 256 |
60
59
  | ≤ 4 096 строк | 3 hex-символа | 4 096 |
61
60
  | > 4 096 строк | 4 hex-символа | 65 536 |
62
61
 
@@ -89,7 +88,7 @@ const hl = createHashline({ prefix: false });
89
88
  Проверка того, что строка не изменилась с момента чтения — защита от race conditions:
90
89
 
91
90
  ```typescript
92
- import { verifyHash } from "opencode-hashline";
91
+ import { verifyHash } from "opencode-hashline/utils";
93
92
 
94
93
  const result = verifyHash(2, "f1c", currentContent);
95
94
  if (!result.valid) {
@@ -108,7 +107,7 @@ if (!result.valid) {
108
107
  Резолвинг и замена диапазонов строк по хеш-ссылкам:
109
108
 
110
109
  ```typescript
111
- import { resolveRange, replaceRange } from "opencode-hashline";
110
+ import { resolveRange, replaceRange } from "opencode-hashline/utils";
112
111
 
113
112
  // Получить строки между двумя хеш-ссылками
114
113
  const range = resolveRange("1:a3f", "3:0e7", content);
@@ -126,7 +125,7 @@ const newContent = replaceRange(
126
125
  Создание кастомных экземпляров Hashline с определёнными настройками:
127
126
 
128
127
  ```typescript
129
- import { createHashline } from "opencode-hashline";
128
+ import { createHashline } from "opencode-hashline/utils";
130
129
 
131
130
  const hl = createHashline({
132
131
  exclude: ["**/node_modules/**", "**/*.min.js"],
@@ -146,10 +145,12 @@ const isExcluded = hl.shouldExclude("node_modules/foo.js"); // true
146
145
  | Параметр | Тип | По умолчанию | Описание |
147
146
  |----------|-----|:------------:|----------|
148
147
  | `exclude` | `string[]` | См. ниже | Glob-паттерны для исключения файлов |
149
- | `maxFileSize` | `number` | `1_000_000` | Макс. размер файла в байтах |
148
+ | `maxFileSize` | `number` | `1_048_576` (1 МБ) | Макс. размер файла в байтах |
150
149
  | `hashLength` | `number \| undefined` | `undefined` (адаптивно) | Принудительная длина хеша |
151
150
  | `cacheSize` | `number` | `100` | Макс. файлов в LRU-кеше |
152
151
  | `prefix` | `string \| false` | `"#HL "` | Префикс строки (`false` для отключения) |
152
+ | `fileRev` | `boolean` | `true` | Включать ревизию файла (`#HL REV:...`) в аннотации |
153
+ | `safeReapply` | `boolean` | `false` | Автоматический поиск перемещённых строк по хешу |
153
154
 
154
155
  Паттерны исключения по умолчанию: lock-файлы, `node_modules`, минифицированные файлы, бинарные файлы (изображения, шрифты, архивы и т.д.).
155
156
 
@@ -4,7 +4,7 @@ import {
4
4
  resolveConfig,
5
5
  shouldExclude,
6
6
  stripHashes
7
- } from "./chunk-DOR4YDIS.js";
7
+ } from "./chunk-C323JLG3.js";
8
8
 
9
9
  // src/hooks.ts
10
10
  import { appendFileSync } from "fs";
@@ -12,18 +12,22 @@ import { join } from "path";
12
12
  import { homedir } from "os";
13
13
  var DEBUG_LOG = join(homedir(), ".config", "opencode", "hashline-debug.log");
14
14
  var MAX_PROCESSED_IDS = 1e4;
15
- function createBoundedSet(maxSize) {
16
- const set = /* @__PURE__ */ new Set();
17
- const originalAdd = set.add.bind(set);
18
- set.add = (value) => {
19
- if (set.size >= maxSize) {
20
- const first = set.values().next().value;
21
- if (first !== void 0) set.delete(first);
15
+ var BoundedSet = class {
16
+ constructor(maxSize) {
17
+ this.maxSize = maxSize;
18
+ }
19
+ set = /* @__PURE__ */ new Set();
20
+ has(value) {
21
+ return this.set.has(value);
22
+ }
23
+ add(value) {
24
+ if (this.set.size >= this.maxSize) {
25
+ const first = this.set.values().next().value;
26
+ if (first !== void 0) this.set.delete(first);
22
27
  }
23
- return originalAdd(value);
24
- };
25
- return set;
26
- }
28
+ this.set.add(value);
29
+ }
30
+ };
27
31
  var debugEnabled = false;
28
32
  function setDebug(enabled) {
29
33
  debugEnabled = enabled;
@@ -58,7 +62,7 @@ function createFileReadAfterHook(cache, config) {
58
62
  const resolved = config ?? resolveConfig();
59
63
  const hashLen = resolved.hashLength || 0;
60
64
  const prefix = resolved.prefix;
61
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
65
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
62
66
  return async (input, output) => {
63
67
  debug("tool.execute.after:", input.tool, "args:", input.args);
64
68
  if (input.callID) {
@@ -67,6 +71,8 @@ function createFileReadAfterHook(cache, config) {
67
71
  return;
68
72
  }
69
73
  processedCallIds.add(input.callID);
74
+ } else {
75
+ debug("no callID \u2014 deduplication disabled for this call");
70
76
  }
71
77
  if (!isFileReadTool(input.tool, input.args)) {
72
78
  debug("skipped: not a file-read tool");
@@ -105,13 +111,16 @@ function createFileReadAfterHook(cache, config) {
105
111
  function createFileEditBeforeHook(config) {
106
112
  const resolved = config ?? resolveConfig();
107
113
  const prefix = resolved.prefix;
108
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
114
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
109
115
  return async (input, output) => {
110
116
  if (input.callID) {
111
117
  if (processedCallIds.has(input.callID)) {
118
+ debug("skipped: duplicate callID (edit)", input.callID);
112
119
  return;
113
120
  }
114
121
  processedCallIds.add(input.callID);
122
+ } else {
123
+ debug("no callID \u2014 deduplication disabled for this edit call");
115
124
  }
116
125
  const toolName = input.tool.toLowerCase();
117
126
  const isFileEdit = FILE_EDIT_TOOLS.some(
@@ -199,23 +199,47 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
199
199
  const lines = normalized.split("\n");
200
200
  const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
201
201
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
202
+ const hashLens = new Array(lines.length).fill(effectiveLen);
202
203
  const hashes = new Array(lines.length);
203
- const seen = /* @__PURE__ */ new Map();
204
- const upgraded = /* @__PURE__ */ new Set();
205
204
  for (let idx = 0; idx < lines.length; idx++) {
206
- const hash = computeLineHash(idx, lines[idx], effectiveLen);
207
- if (seen.has(hash)) {
208
- const longerLen = Math.min(effectiveLen + 1, 8);
209
- const prevIdx = seen.get(hash);
210
- if (!upgraded.has(prevIdx)) {
211
- hashes[prevIdx] = computeLineHash(prevIdx, lines[prevIdx], longerLen);
212
- upgraded.add(prevIdx);
205
+ hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
206
+ }
207
+ let dirtyIndices = null;
208
+ let hasCollisions = true;
209
+ while (hasCollisions) {
210
+ hasCollisions = false;
211
+ const seen = /* @__PURE__ */ new Map();
212
+ for (let idx = 0; idx < lines.length; idx++) {
213
+ const h = hashes[idx];
214
+ const group = seen.get(h);
215
+ if (group) {
216
+ group.push(idx);
217
+ } else {
218
+ seen.set(h, [idx]);
219
+ }
220
+ }
221
+ const nextDirty = /* @__PURE__ */ new Set();
222
+ for (const [, group] of seen) {
223
+ if (group.length < 2) continue;
224
+ if (dirtyIndices !== null && !group.some((idx) => dirtyIndices.has(idx))) continue;
225
+ for (const idx of group) {
226
+ const newLen = Math.min(hashLens[idx] + 1, 8);
227
+ if (newLen === hashLens[idx]) continue;
228
+ hashLens[idx] = newLen;
229
+ hashes[idx] = computeLineHash(idx, lines[idx], newLen);
230
+ nextDirty.add(idx);
231
+ hasCollisions = true;
213
232
  }
214
- hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
215
- upgraded.add(idx);
233
+ }
234
+ dirtyIndices = nextDirty;
235
+ }
236
+ const finalSeen = /* @__PURE__ */ new Map();
237
+ for (let idx = 0; idx < lines.length; idx++) {
238
+ const existing = finalSeen.get(hashes[idx]);
239
+ if (existing !== void 0) {
240
+ hashes[idx] = `${hashes[idx]}${idx.toString(16)}`;
216
241
  } else {
217
- seen.set(hash, idx);
218
- hashes[idx] = hash;
242
+ finalSeen.set(hashes[idx], idx);
219
243
  }
220
244
  }
221
245
  const annotatedLines = lines.map((line, idx) => {
@@ -231,12 +255,16 @@ var stripRegexCache = /* @__PURE__ */ new Map();
231
255
  function stripHashes(content, prefix) {
232
256
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
233
257
  const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
234
- let hashLinePattern = stripRegexCache.get(escapedPrefix);
235
- if (!hashLinePattern) {
236
- hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
237
- stripRegexCache.set(escapedPrefix, hashLinePattern);
258
+ let cached = stripRegexCache.get(escapedPrefix);
259
+ if (!cached) {
260
+ cached = {
261
+ hashLine: new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`),
262
+ rev: new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`)
263
+ };
264
+ stripRegexCache.set(escapedPrefix, cached);
238
265
  }
239
- const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
266
+ const hashLinePattern = cached.hashLine;
267
+ const revPattern = cached.rev;
240
268
  const lineEnding = detectLineEnding(content);
241
269
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
242
270
  const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
@@ -385,10 +413,10 @@ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
385
413
  content: rangeLines.join(lineEnding)
386
414
  };
387
415
  }
388
- function replaceRange(startRef, endRef, content, replacement, hashLen) {
416
+ function replaceRange(startRef, endRef, content, replacement, hashLen, safeReapply) {
389
417
  const lineEnding = detectLineEnding(content);
390
418
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
391
- const range = resolveRange(startRef, endRef, normalized, hashLen);
419
+ const range = resolveRange(startRef, endRef, normalized, hashLen, safeReapply);
392
420
  const lines = normalized.split("\n");
393
421
  const before = lines.slice(0, range.startLine - 1);
394
422
  const after = lines.slice(range.endLine);
@@ -558,9 +586,8 @@ function matchesGlob(filePath, pattern) {
558
586
  function shouldExclude(filePath, patterns) {
559
587
  return patterns.some((pattern) => matchesGlob(filePath, pattern));
560
588
  }
561
- var textEncoder = new TextEncoder();
562
589
  function getByteLength(content) {
563
- return textEncoder.encode(content).length;
590
+ return Buffer.byteLength(content, "utf-8");
564
591
  }
565
592
  function detectLineEnding(content) {
566
593
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -25,7 +25,7 @@ import {
25
25
  stripHashes,
26
26
  verifyFileRev,
27
27
  verifyHash
28
- } from "./chunk-DOR4YDIS.js";
28
+ } from "./chunk-C323JLG3.js";
29
29
  export {
30
30
  DEFAULT_CONFIG,
31
31
  DEFAULT_EXCLUDE_PATTERNS,
@@ -262,7 +262,7 @@ declare function resolveRange(startRef: string, endRef: string, content: string,
262
262
  * @param hashLen - override hash length (0 or undefined = use hash.length from ref)
263
263
  * @returns new file content with the range replaced
264
264
  */
265
- declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number): string;
265
+ declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number, safeReapply?: boolean): string;
266
266
  /**
267
267
  * Apply a hash-aware edit operation directly against file content.
268
268
  *
@@ -308,6 +308,11 @@ declare function matchesGlob(filePath: string, pattern: string): boolean;
308
308
  * Check if a file path should be excluded based on config patterns.
309
309
  */
310
310
  declare function shouldExclude(filePath: string, patterns: string[]): boolean;
311
+ /**
312
+ * Get the UTF-8 byte length of a string.
313
+ * Uses TextEncoder for accurate UTF-8 byte counting.
314
+ * This correctly handles multi-byte characters (Cyrillic, CJK, emoji, etc.).
315
+ */
311
316
  declare function getByteLength(content: string): number;
312
317
  /**
313
318
  * A Hashline instance with custom configuration.
@@ -262,7 +262,7 @@ declare function resolveRange(startRef: string, endRef: string, content: string,
262
262
  * @param hashLen - override hash length (0 or undefined = use hash.length from ref)
263
263
  * @returns new file content with the range replaced
264
264
  */
265
- declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number): string;
265
+ declare function replaceRange(startRef: string, endRef: string, content: string, replacement: string, hashLen?: number, safeReapply?: boolean): string;
266
266
  /**
267
267
  * Apply a hash-aware edit operation directly against file content.
268
268
  *
@@ -308,6 +308,11 @@ declare function matchesGlob(filePath: string, pattern: string): boolean;
308
308
  * Check if a file path should be excluded based on config patterns.
309
309
  */
310
310
  declare function shouldExclude(filePath: string, patterns: string[]): boolean;
311
+ /**
312
+ * Get the UTF-8 byte length of a string.
313
+ * Uses TextEncoder for accurate UTF-8 byte counting.
314
+ * This correctly handles multi-byte characters (Cyrillic, CJK, emoji, etc.).
315
+ */
311
316
  declare function getByteLength(content: string): number;
312
317
  /**
313
318
  * A Hashline instance with custom configuration.
@@ -154,23 +154,47 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
154
154
  const lines = normalized.split("\n");
155
155
  const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
156
156
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
157
+ const hashLens = new Array(lines.length).fill(effectiveLen);
157
158
  const hashes = new Array(lines.length);
158
- const seen = /* @__PURE__ */ new Map();
159
- const upgraded = /* @__PURE__ */ new Set();
160
159
  for (let idx = 0; idx < lines.length; idx++) {
161
- const hash = computeLineHash(idx, lines[idx], effectiveLen);
162
- if (seen.has(hash)) {
163
- const longerLen = Math.min(effectiveLen + 1, 8);
164
- const prevIdx = seen.get(hash);
165
- if (!upgraded.has(prevIdx)) {
166
- hashes[prevIdx] = computeLineHash(prevIdx, lines[prevIdx], longerLen);
167
- upgraded.add(prevIdx);
160
+ hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
161
+ }
162
+ let dirtyIndices = null;
163
+ let hasCollisions = true;
164
+ while (hasCollisions) {
165
+ hasCollisions = false;
166
+ const seen = /* @__PURE__ */ new Map();
167
+ for (let idx = 0; idx < lines.length; idx++) {
168
+ const h = hashes[idx];
169
+ const group = seen.get(h);
170
+ if (group) {
171
+ group.push(idx);
172
+ } else {
173
+ seen.set(h, [idx]);
174
+ }
175
+ }
176
+ const nextDirty = /* @__PURE__ */ new Set();
177
+ for (const [, group] of seen) {
178
+ if (group.length < 2) continue;
179
+ if (dirtyIndices !== null && !group.some((idx) => dirtyIndices.has(idx))) continue;
180
+ for (const idx of group) {
181
+ const newLen = Math.min(hashLens[idx] + 1, 8);
182
+ if (newLen === hashLens[idx]) continue;
183
+ hashLens[idx] = newLen;
184
+ hashes[idx] = computeLineHash(idx, lines[idx], newLen);
185
+ nextDirty.add(idx);
186
+ hasCollisions = true;
168
187
  }
169
- hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
170
- upgraded.add(idx);
188
+ }
189
+ dirtyIndices = nextDirty;
190
+ }
191
+ const finalSeen = /* @__PURE__ */ new Map();
192
+ for (let idx = 0; idx < lines.length; idx++) {
193
+ const existing = finalSeen.get(hashes[idx]);
194
+ if (existing !== void 0) {
195
+ hashes[idx] = `${hashes[idx]}${idx.toString(16)}`;
171
196
  } else {
172
- seen.set(hash, idx);
173
- hashes[idx] = hash;
197
+ finalSeen.set(hashes[idx], idx);
174
198
  }
175
199
  }
176
200
  const annotatedLines = lines.map((line, idx) => {
@@ -185,12 +209,16 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
185
209
  function stripHashes(content, prefix) {
186
210
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
187
211
  const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
188
- let hashLinePattern = stripRegexCache.get(escapedPrefix);
189
- if (!hashLinePattern) {
190
- hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
191
- stripRegexCache.set(escapedPrefix, hashLinePattern);
212
+ let cached = stripRegexCache.get(escapedPrefix);
213
+ if (!cached) {
214
+ cached = {
215
+ hashLine: new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`),
216
+ rev: new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`)
217
+ };
218
+ stripRegexCache.set(escapedPrefix, cached);
192
219
  }
193
- const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
220
+ const hashLinePattern = cached.hashLine;
221
+ const revPattern = cached.rev;
194
222
  const lineEnding = detectLineEnding(content);
195
223
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
196
224
  const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
@@ -339,10 +367,10 @@ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
339
367
  content: rangeLines.join(lineEnding)
340
368
  };
341
369
  }
342
- function replaceRange(startRef, endRef, content, replacement, hashLen) {
370
+ function replaceRange(startRef, endRef, content, replacement, hashLen, safeReapply) {
343
371
  const lineEnding = detectLineEnding(content);
344
372
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
345
- const range = resolveRange(startRef, endRef, normalized, hashLen);
373
+ const range = resolveRange(startRef, endRef, normalized, hashLen, safeReapply);
346
374
  const lines = normalized.split("\n");
347
375
  const before = lines.slice(0, range.startLine - 1);
348
376
  const after = lines.slice(range.endLine);
@@ -453,7 +481,7 @@ function shouldExclude(filePath, patterns) {
453
481
  return patterns.some((pattern) => matchesGlob(filePath, pattern));
454
482
  }
455
483
  function getByteLength(content) {
456
- return textEncoder.encode(content).length;
484
+ return Buffer.byteLength(content, "utf-8");
457
485
  }
458
486
  function detectLineEnding(content) {
459
487
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -521,7 +549,7 @@ function createHashline(config) {
521
549
  }
522
550
  };
523
551
  }
524
- var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache, textEncoder;
552
+ var import_picomatch, DEFAULT_EXCLUDE_PATTERNS, DEFAULT_PREFIX, DEFAULT_CONFIG, HashlineError, modulusCache, stripRegexCache, HashlineCache, globMatcherCache;
525
553
  var init_hashline = __esm({
526
554
  "src/hashline.ts"() {
527
555
  "use strict";
@@ -692,7 +720,6 @@ var init_hashline = __esm({
692
720
  }
693
721
  };
694
722
  globMatcherCache = /* @__PURE__ */ new Map();
695
- textEncoder = new TextEncoder();
696
723
  }
697
724
  });
698
725
 
@@ -701,12 +728,14 @@ var src_exports = {};
701
728
  __export(src_exports, {
702
729
  HashlinePlugin: () => HashlinePlugin,
703
730
  createHashlinePlugin: () => createHashlinePlugin,
704
- default: () => src_default
731
+ default: () => src_default,
732
+ sanitizeConfig: () => sanitizeConfig
705
733
  });
706
734
  module.exports = __toCommonJS(src_exports);
707
735
  var import_fs3 = require("fs");
708
736
  var import_path3 = require("path");
709
737
  var import_os2 = require("os");
738
+ var import_crypto = require("crypto");
710
739
  var import_url = require("url");
711
740
 
712
741
  // src/hooks.ts
@@ -716,18 +745,22 @@ var import_os = require("os");
716
745
  init_hashline();
717
746
  var DEBUG_LOG = (0, import_path.join)((0, import_os.homedir)(), ".config", "opencode", "hashline-debug.log");
718
747
  var MAX_PROCESSED_IDS = 1e4;
719
- function createBoundedSet(maxSize) {
720
- const set = /* @__PURE__ */ new Set();
721
- const originalAdd = set.add.bind(set);
722
- set.add = (value) => {
723
- if (set.size >= maxSize) {
724
- const first = set.values().next().value;
725
- if (first !== void 0) set.delete(first);
748
+ var BoundedSet = class {
749
+ constructor(maxSize) {
750
+ this.maxSize = maxSize;
751
+ }
752
+ set = /* @__PURE__ */ new Set();
753
+ has(value) {
754
+ return this.set.has(value);
755
+ }
756
+ add(value) {
757
+ if (this.set.size >= this.maxSize) {
758
+ const first = this.set.values().next().value;
759
+ if (first !== void 0) this.set.delete(first);
726
760
  }
727
- return originalAdd(value);
728
- };
729
- return set;
730
- }
761
+ this.set.add(value);
762
+ }
763
+ };
731
764
  var debugEnabled = false;
732
765
  function setDebug(enabled) {
733
766
  debugEnabled = enabled;
@@ -762,7 +795,7 @@ function createFileReadAfterHook(cache, config) {
762
795
  const resolved = config ?? resolveConfig();
763
796
  const hashLen = resolved.hashLength || 0;
764
797
  const prefix = resolved.prefix;
765
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
798
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
766
799
  return async (input, output) => {
767
800
  debug("tool.execute.after:", input.tool, "args:", input.args);
768
801
  if (input.callID) {
@@ -771,6 +804,8 @@ function createFileReadAfterHook(cache, config) {
771
804
  return;
772
805
  }
773
806
  processedCallIds.add(input.callID);
807
+ } else {
808
+ debug("no callID \u2014 deduplication disabled for this call");
774
809
  }
775
810
  if (!isFileReadTool(input.tool, input.args)) {
776
811
  debug("skipped: not a file-read tool");
@@ -809,13 +844,16 @@ function createFileReadAfterHook(cache, config) {
809
844
  function createFileEditBeforeHook(config) {
810
845
  const resolved = config ?? resolveConfig();
811
846
  const prefix = resolved.prefix;
812
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
847
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
813
848
  return async (input, output) => {
814
849
  if (input.callID) {
815
850
  if (processedCallIds.has(input.callID)) {
851
+ debug("skipped: duplicate callID (edit)", input.callID);
816
852
  return;
817
853
  }
818
854
  processedCallIds.add(input.callID);
855
+ } else {
856
+ debug("no callID \u2014 deduplication disabled for this edit call");
819
857
  }
820
858
  const toolName = input.tool.toLowerCase();
821
859
  const isFileEdit = FILE_EDIT_TOOLS.some(
@@ -1067,14 +1105,39 @@ ${error.toDiagnostic()}`);
1067
1105
 
1068
1106
  // src/index.ts
1069
1107
  var CONFIG_FILENAME = "opencode-hashline.json";
1108
+ var tempDirs = /* @__PURE__ */ new Set();
1109
+ var exitListenerRegistered = false;
1110
+ function registerTempDir(dir) {
1111
+ tempDirs.add(dir);
1112
+ if (!exitListenerRegistered) {
1113
+ exitListenerRegistered = true;
1114
+ process.on("exit", () => {
1115
+ for (const d of tempDirs) {
1116
+ try {
1117
+ (0, import_fs3.rmSync)(d, { recursive: true, force: true });
1118
+ } catch {
1119
+ }
1120
+ }
1121
+ });
1122
+ }
1123
+ }
1124
+ function writeTempFile(tempDir, content) {
1125
+ const name = `hl-${(0, import_crypto.randomBytes)(16).toString("hex")}.txt`;
1126
+ const tmpPath = (0, import_path3.join)(tempDir, name);
1127
+ const fd = (0, import_fs3.openSync)(tmpPath, import_fs3.constants.O_WRONLY | import_fs3.constants.O_CREAT | import_fs3.constants.O_EXCL, 384);
1128
+ try {
1129
+ (0, import_fs3.writeFileSync)(fd, content, "utf-8");
1130
+ } finally {
1131
+ (0, import_fs3.closeSync)(fd);
1132
+ }
1133
+ return tmpPath;
1134
+ }
1070
1135
  function sanitizeConfig(raw) {
1071
1136
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
1072
1137
  const r = raw;
1073
1138
  const result = {};
1074
1139
  if (Array.isArray(r.exclude)) {
1075
- result.exclude = r.exclude.filter(
1076
- (p) => typeof p === "string" && p.length <= 512
1077
- );
1140
+ result.exclude = r.exclude.filter((p) => typeof p === "string" && p.length <= 512).slice(0, 1e3);
1078
1141
  }
1079
1142
  if (typeof r.maxFileSize === "number" && Number.isFinite(r.maxFileSize) && r.maxFileSize >= 0) {
1080
1143
  result.maxFileSize = r.maxFileSize;
@@ -1126,14 +1189,13 @@ function loadConfig(projectDir, userConfig) {
1126
1189
  }
1127
1190
  function createHashlinePlugin(userConfig) {
1128
1191
  return async (input) => {
1129
- const projectDir = input.directory;
1130
- const worktree = input.worktree;
1192
+ const { directory: projectDir, worktree } = input;
1131
1193
  const fileConfig = loadConfig(projectDir, userConfig);
1132
1194
  const config = resolveConfig(fileConfig);
1133
1195
  const cache = new HashlineCache(config.cacheSize);
1134
1196
  setDebug(config.debug);
1135
- const { appendFileSync: writeLog } = await import("fs");
1136
1197
  const debugLog = (0, import_path3.join)((0, import_os2.homedir)(), ".config", "opencode", "hashline-debug.log");
1198
+ const writeLog = import_fs3.appendFileSync;
1137
1199
  if (config.debug) {
1138
1200
  try {
1139
1201
  writeLog(debugLog, `[${(/* @__PURE__ */ new Date()).toISOString()}] plugin loaded, prefix: ${JSON.stringify(config.prefix)}, maxFileSize: ${config.maxFileSize}, projectDir: ${projectDir}
@@ -1141,17 +1203,8 @@ function createHashlinePlugin(userConfig) {
1141
1203
  } catch {
1142
1204
  }
1143
1205
  }
1144
- const tempFiles = /* @__PURE__ */ new Set();
1145
- const cleanupTempFiles = () => {
1146
- for (const f of tempFiles) {
1147
- try {
1148
- (0, import_fs3.unlinkSync)(f);
1149
- } catch {
1150
- }
1151
- }
1152
- tempFiles.clear();
1153
- };
1154
- process.on("exit", cleanupTempFiles);
1206
+ const instanceTmpDir = (0, import_fs3.mkdtempSync)((0, import_path3.join)((0, import_os2.tmpdir)(), "hashline-"));
1207
+ registerTempDir(instanceTmpDir);
1155
1208
  return {
1156
1209
  tool: {
1157
1210
  hashline_edit: createHashlineEditTool(config, cache)
@@ -1194,9 +1247,7 @@ function createHashlinePlugin(userConfig) {
1194
1247
  if (config.maxFileSize > 0 && getByteLength2(content) > config.maxFileSize) continue;
1195
1248
  const cached = cache.get(filePath, content);
1196
1249
  if (cached) {
1197
- const tmpPath2 = (0, import_path3.join)((0, import_os2.tmpdir)(), `hashline-${p.id}.txt`);
1198
- (0, import_fs3.writeFileSync)(tmpPath2, cached, "utf-8");
1199
- tempFiles.add(tmpPath2);
1250
+ const tmpPath2 = writeTempFile(instanceTmpDir, cached);
1200
1251
  p.url = `file://${tmpPath2}`;
1201
1252
  if (config.debug) {
1202
1253
  try {
@@ -1207,10 +1258,9 @@ function createHashlinePlugin(userConfig) {
1207
1258
  }
1208
1259
  continue;
1209
1260
  }
1210
- const annotated = formatFileWithHashes2(content, hashLen || void 0, prefix);
1261
+ const annotated = formatFileWithHashes2(content, hashLen || void 0, prefix, config.fileRev);
1211
1262
  cache.set(filePath, content, annotated);
1212
- const tmpPath = (0, import_path3.join)((0, import_os2.tmpdir)(), `hashline-${p.id}.txt`);
1213
- (0, import_fs3.writeFileSync)(tmpPath, annotated, "utf-8");
1263
+ const tmpPath = writeTempFile(instanceTmpDir, annotated);
1214
1264
  p.url = `file://${tmpPath}`;
1215
1265
  if (config.debug) {
1216
1266
  try {
@@ -1238,5 +1288,6 @@ var src_default = HashlinePlugin;
1238
1288
  // Annotate the CommonJS export names for ESM import in node:
1239
1289
  0 && (module.exports = {
1240
1290
  HashlinePlugin,
1241
- createHashlinePlugin
1291
+ createHashlinePlugin,
1292
+ sanitizeConfig
1242
1293
  });
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
- import { H as HashlineConfig } from './hashline-A7k2yn3G.cjs';
3
- export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-A7k2yn3G.cjs';
2
+ import { H as HashlineConfig } from './hashline-DWndArr4.cjs';
3
+ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-DWndArr4.cjs';
4
4
 
5
5
  /**
6
6
  * opencode-hashline — Hashline plugin for OpenCode
@@ -14,6 +14,13 @@ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as Ha
14
14
  * constants, import from "opencode-hashline/utils".
15
15
  */
16
16
 
17
+ /**
18
+ * Sanitize and validate a raw parsed config object.
19
+ * Accepts only known keys with expected types; silently drops invalid values.
20
+ * This prevents prototype pollution, type confusion, and prompt injection via
21
+ * a malicious or hand-crafted config file.
22
+ */
23
+ declare function sanitizeConfig(raw: unknown): HashlineConfig;
17
24
  /**
18
25
  * Create a Hashline plugin instance with optional user configuration.
19
26
  *
@@ -45,4 +52,4 @@ declare function createHashlinePlugin(userConfig?: HashlineConfig): Plugin;
45
52
  */
46
53
  declare const HashlinePlugin: Plugin;
47
54
 
48
- export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default };
55
+ export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default, sanitizeConfig };
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
- import { H as HashlineConfig } from './hashline-A7k2yn3G.js';
3
- export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-A7k2yn3G.js';
2
+ import { H as HashlineConfig } from './hashline-DWndArr4.js';
3
+ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as HashEditResult, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult } from './hashline-DWndArr4.js';
4
4
 
5
5
  /**
6
6
  * opencode-hashline — Hashline plugin for OpenCode
@@ -14,6 +14,13 @@ export { C as CandidateLine, a as HashEditInput, b as HashEditOperation, c as Ha
14
14
  * constants, import from "opencode-hashline/utils".
15
15
  */
16
16
 
17
+ /**
18
+ * Sanitize and validate a raw parsed config object.
19
+ * Accepts only known keys with expected types; silently drops invalid values.
20
+ * This prevents prototype pollution, type confusion, and prompt injection via
21
+ * a malicious or hand-crafted config file.
22
+ */
23
+ declare function sanitizeConfig(raw: unknown): HashlineConfig;
17
24
  /**
18
25
  * Create a Hashline plugin instance with optional user configuration.
19
26
  *
@@ -45,4 +52,4 @@ declare function createHashlinePlugin(userConfig?: HashlineConfig): Plugin;
45
52
  */
46
53
  declare const HashlinePlugin: Plugin;
47
54
 
48
- export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default };
55
+ export { HashlineConfig, HashlinePlugin, createHashlinePlugin, HashlinePlugin as default, sanitizeConfig };
@@ -3,19 +3,20 @@ import {
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook,
5
5
  setDebug
6
- } from "./chunk-7KUPGN4M.js";
6
+ } from "./chunk-3ED7MDEC.js";
7
7
  import {
8
8
  HashlineCache,
9
9
  HashlineError,
10
10
  applyHashEdit,
11
11
  getByteLength,
12
12
  resolveConfig
13
- } from "./chunk-DOR4YDIS.js";
13
+ } from "./chunk-C323JLG3.js";
14
14
 
15
15
  // src/index.ts
16
- import { readFileSync as readFileSync2, realpathSync as realpathSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
16
+ import { readFileSync as readFileSync2, realpathSync as realpathSync2, writeFileSync as writeFileSync2, appendFileSync, mkdtempSync, openSync, closeSync, rmSync, constants as fsConstants } from "fs";
17
17
  import { join, resolve as resolve2, sep as sep2 } from "path";
18
18
  import { homedir, tmpdir } from "os";
19
+ import { randomBytes } from "crypto";
19
20
  import { fileURLToPath } from "url";
20
21
 
21
22
  // src/hashline-tool.ts
@@ -140,14 +141,39 @@ ${error.toDiagnostic()}`);
140
141
 
141
142
  // src/index.ts
142
143
  var CONFIG_FILENAME = "opencode-hashline.json";
144
+ var tempDirs = /* @__PURE__ */ new Set();
145
+ var exitListenerRegistered = false;
146
+ function registerTempDir(dir) {
147
+ tempDirs.add(dir);
148
+ if (!exitListenerRegistered) {
149
+ exitListenerRegistered = true;
150
+ process.on("exit", () => {
151
+ for (const d of tempDirs) {
152
+ try {
153
+ rmSync(d, { recursive: true, force: true });
154
+ } catch {
155
+ }
156
+ }
157
+ });
158
+ }
159
+ }
160
+ function writeTempFile(tempDir, content) {
161
+ const name = `hl-${randomBytes(16).toString("hex")}.txt`;
162
+ const tmpPath = join(tempDir, name);
163
+ const fd = openSync(tmpPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
164
+ try {
165
+ writeFileSync2(fd, content, "utf-8");
166
+ } finally {
167
+ closeSync(fd);
168
+ }
169
+ return tmpPath;
170
+ }
143
171
  function sanitizeConfig(raw) {
144
172
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
145
173
  const r = raw;
146
174
  const result = {};
147
175
  if (Array.isArray(r.exclude)) {
148
- result.exclude = r.exclude.filter(
149
- (p) => typeof p === "string" && p.length <= 512
150
- );
176
+ result.exclude = r.exclude.filter((p) => typeof p === "string" && p.length <= 512).slice(0, 1e3);
151
177
  }
152
178
  if (typeof r.maxFileSize === "number" && Number.isFinite(r.maxFileSize) && r.maxFileSize >= 0) {
153
179
  result.maxFileSize = r.maxFileSize;
@@ -199,14 +225,13 @@ function loadConfig(projectDir, userConfig) {
199
225
  }
200
226
  function createHashlinePlugin(userConfig) {
201
227
  return async (input) => {
202
- const projectDir = input.directory;
203
- const worktree = input.worktree;
228
+ const { directory: projectDir, worktree } = input;
204
229
  const fileConfig = loadConfig(projectDir, userConfig);
205
230
  const config = resolveConfig(fileConfig);
206
231
  const cache = new HashlineCache(config.cacheSize);
207
232
  setDebug(config.debug);
208
- const { appendFileSync: writeLog } = await import("fs");
209
233
  const debugLog = join(homedir(), ".config", "opencode", "hashline-debug.log");
234
+ const writeLog = appendFileSync;
210
235
  if (config.debug) {
211
236
  try {
212
237
  writeLog(debugLog, `[${(/* @__PURE__ */ new Date()).toISOString()}] plugin loaded, prefix: ${JSON.stringify(config.prefix)}, maxFileSize: ${config.maxFileSize}, projectDir: ${projectDir}
@@ -214,17 +239,8 @@ function createHashlinePlugin(userConfig) {
214
239
  } catch {
215
240
  }
216
241
  }
217
- const tempFiles = /* @__PURE__ */ new Set();
218
- const cleanupTempFiles = () => {
219
- for (const f of tempFiles) {
220
- try {
221
- unlinkSync(f);
222
- } catch {
223
- }
224
- }
225
- tempFiles.clear();
226
- };
227
- process.on("exit", cleanupTempFiles);
242
+ const instanceTmpDir = mkdtempSync(join(tmpdir(), "hashline-"));
243
+ registerTempDir(instanceTmpDir);
228
244
  return {
229
245
  tool: {
230
246
  hashline_edit: createHashlineEditTool(config, cache)
@@ -237,7 +253,7 @@ function createHashlinePlugin(userConfig) {
237
253
  const out = output;
238
254
  const hashLen = config.hashLength || 0;
239
255
  const prefix = config.prefix;
240
- const { formatFileWithHashes, shouldExclude, getByteLength: getByteLength2 } = await import("./hashline-MGDEWZ77.js");
256
+ const { formatFileWithHashes, shouldExclude, getByteLength: getByteLength2 } = await import("./hashline-DDPVX355.js");
241
257
  for (const p of out.parts ?? []) {
242
258
  if (p.type !== "file") continue;
243
259
  if (!p.url || !p.mime?.startsWith("text/")) continue;
@@ -267,9 +283,7 @@ function createHashlinePlugin(userConfig) {
267
283
  if (config.maxFileSize > 0 && getByteLength2(content) > config.maxFileSize) continue;
268
284
  const cached = cache.get(filePath, content);
269
285
  if (cached) {
270
- const tmpPath2 = join(tmpdir(), `hashline-${p.id}.txt`);
271
- writeFileSync2(tmpPath2, cached, "utf-8");
272
- tempFiles.add(tmpPath2);
286
+ const tmpPath2 = writeTempFile(instanceTmpDir, cached);
273
287
  p.url = `file://${tmpPath2}`;
274
288
  if (config.debug) {
275
289
  try {
@@ -280,10 +294,9 @@ function createHashlinePlugin(userConfig) {
280
294
  }
281
295
  continue;
282
296
  }
283
- const annotated = formatFileWithHashes(content, hashLen || void 0, prefix);
297
+ const annotated = formatFileWithHashes(content, hashLen || void 0, prefix, config.fileRev);
284
298
  cache.set(filePath, content, annotated);
285
- const tmpPath = join(tmpdir(), `hashline-${p.id}.txt`);
286
- writeFileSync2(tmpPath, annotated, "utf-8");
299
+ const tmpPath = writeTempFile(instanceTmpDir, annotated);
287
300
  p.url = `file://${tmpPath}`;
288
301
  if (config.debug) {
289
302
  try {
@@ -311,5 +324,6 @@ var src_default = HashlinePlugin;
311
324
  export {
312
325
  HashlinePlugin,
313
326
  createHashlinePlugin,
314
- src_default as default
327
+ src_default as default,
328
+ sanitizeConfig
315
329
  };
package/dist/utils.cjs CHANGED
@@ -263,23 +263,47 @@ function formatFileWithHashes(content, hashLen, prefix, includeFileRev) {
263
263
  const lines = normalized.split("\n");
264
264
  const effectiveLen = hashLen && hashLen >= 3 ? hashLen : getAdaptiveHashLength(lines.length);
265
265
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
266
+ const hashLens = new Array(lines.length).fill(effectiveLen);
266
267
  const hashes = new Array(lines.length);
267
- const seen = /* @__PURE__ */ new Map();
268
- const upgraded = /* @__PURE__ */ new Set();
269
268
  for (let idx = 0; idx < lines.length; idx++) {
270
- const hash = computeLineHash(idx, lines[idx], effectiveLen);
271
- if (seen.has(hash)) {
272
- const longerLen = Math.min(effectiveLen + 1, 8);
273
- const prevIdx = seen.get(hash);
274
- if (!upgraded.has(prevIdx)) {
275
- hashes[prevIdx] = computeLineHash(prevIdx, lines[prevIdx], longerLen);
276
- upgraded.add(prevIdx);
269
+ hashes[idx] = computeLineHash(idx, lines[idx], effectiveLen);
270
+ }
271
+ let dirtyIndices = null;
272
+ let hasCollisions = true;
273
+ while (hasCollisions) {
274
+ hasCollisions = false;
275
+ const seen = /* @__PURE__ */ new Map();
276
+ for (let idx = 0; idx < lines.length; idx++) {
277
+ const h = hashes[idx];
278
+ const group = seen.get(h);
279
+ if (group) {
280
+ group.push(idx);
281
+ } else {
282
+ seen.set(h, [idx]);
283
+ }
284
+ }
285
+ const nextDirty = /* @__PURE__ */ new Set();
286
+ for (const [, group] of seen) {
287
+ if (group.length < 2) continue;
288
+ if (dirtyIndices !== null && !group.some((idx) => dirtyIndices.has(idx))) continue;
289
+ for (const idx of group) {
290
+ const newLen = Math.min(hashLens[idx] + 1, 8);
291
+ if (newLen === hashLens[idx]) continue;
292
+ hashLens[idx] = newLen;
293
+ hashes[idx] = computeLineHash(idx, lines[idx], newLen);
294
+ nextDirty.add(idx);
295
+ hasCollisions = true;
277
296
  }
278
- hashes[idx] = computeLineHash(idx, lines[idx], longerLen);
279
- upgraded.add(idx);
297
+ }
298
+ dirtyIndices = nextDirty;
299
+ }
300
+ const finalSeen = /* @__PURE__ */ new Map();
301
+ for (let idx = 0; idx < lines.length; idx++) {
302
+ const existing = finalSeen.get(hashes[idx]);
303
+ if (existing !== void 0) {
304
+ hashes[idx] = `${hashes[idx]}${idx.toString(16)}`;
280
305
  } else {
281
- seen.set(hash, idx);
282
- hashes[idx] = hash;
306
+ finalSeen.set(hashes[idx], idx);
283
307
  }
284
308
  }
285
309
  const annotatedLines = lines.map((line, idx) => {
@@ -295,12 +319,16 @@ var stripRegexCache = /* @__PURE__ */ new Map();
295
319
  function stripHashes(content, prefix) {
296
320
  const effectivePrefix = prefix === void 0 ? DEFAULT_PREFIX : prefix === false ? "" : prefix;
297
321
  const escapedPrefix = effectivePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
298
- let hashLinePattern = stripRegexCache.get(escapedPrefix);
299
- if (!hashLinePattern) {
300
- hashLinePattern = new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`);
301
- stripRegexCache.set(escapedPrefix, hashLinePattern);
322
+ let cached = stripRegexCache.get(escapedPrefix);
323
+ if (!cached) {
324
+ cached = {
325
+ hashLine: new RegExp(`^([+ \\-])?${escapedPrefix}\\d+:[0-9a-f]{2,8}\\|`),
326
+ rev: new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`)
327
+ };
328
+ stripRegexCache.set(escapedPrefix, cached);
302
329
  }
303
- const revPattern = new RegExp(`^${escapedPrefix}REV:[0-9a-f]{8}$`);
330
+ const hashLinePattern = cached.hashLine;
331
+ const revPattern = cached.rev;
304
332
  const lineEnding = detectLineEnding(content);
305
333
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
306
334
  const result = normalized.split("\n").filter((line) => !revPattern.test(line)).map((line) => {
@@ -449,10 +477,10 @@ function resolveRange(startRef, endRef, content, hashLen, safeReapply) {
449
477
  content: rangeLines.join(lineEnding)
450
478
  };
451
479
  }
452
- function replaceRange(startRef, endRef, content, replacement, hashLen) {
480
+ function replaceRange(startRef, endRef, content, replacement, hashLen, safeReapply) {
453
481
  const lineEnding = detectLineEnding(content);
454
482
  const normalized = lineEnding === "\r\n" ? content.replace(/\r\n/g, "\n") : content;
455
- const range = resolveRange(startRef, endRef, normalized, hashLen);
483
+ const range = resolveRange(startRef, endRef, normalized, hashLen, safeReapply);
456
484
  const lines = normalized.split("\n");
457
485
  const before = lines.slice(0, range.startLine - 1);
458
486
  const after = lines.slice(range.endLine);
@@ -622,9 +650,8 @@ function matchesGlob(filePath, pattern) {
622
650
  function shouldExclude(filePath, patterns) {
623
651
  return patterns.some((pattern) => matchesGlob(filePath, pattern));
624
652
  }
625
- var textEncoder = new TextEncoder();
626
653
  function getByteLength(content) {
627
- return textEncoder.encode(content).length;
654
+ return Buffer.byteLength(content, "utf-8");
628
655
  }
629
656
  function detectLineEnding(content) {
630
657
  return content.includes("\r\n") ? "\r\n" : "\n";
@@ -699,18 +726,22 @@ var import_path = require("path");
699
726
  var import_os = require("os");
700
727
  var DEBUG_LOG = (0, import_path.join)((0, import_os.homedir)(), ".config", "opencode", "hashline-debug.log");
701
728
  var MAX_PROCESSED_IDS = 1e4;
702
- function createBoundedSet(maxSize) {
703
- const set = /* @__PURE__ */ new Set();
704
- const originalAdd = set.add.bind(set);
705
- set.add = (value) => {
706
- if (set.size >= maxSize) {
707
- const first = set.values().next().value;
708
- if (first !== void 0) set.delete(first);
729
+ var BoundedSet = class {
730
+ constructor(maxSize) {
731
+ this.maxSize = maxSize;
732
+ }
733
+ set = /* @__PURE__ */ new Set();
734
+ has(value) {
735
+ return this.set.has(value);
736
+ }
737
+ add(value) {
738
+ if (this.set.size >= this.maxSize) {
739
+ const first = this.set.values().next().value;
740
+ if (first !== void 0) this.set.delete(first);
709
741
  }
710
- return originalAdd(value);
711
- };
712
- return set;
713
- }
742
+ this.set.add(value);
743
+ }
744
+ };
714
745
  var debugEnabled = false;
715
746
  function debug(...args) {
716
747
  if (!debugEnabled) return;
@@ -742,7 +773,7 @@ function createFileReadAfterHook(cache, config) {
742
773
  const resolved = config ?? resolveConfig();
743
774
  const hashLen = resolved.hashLength || 0;
744
775
  const prefix = resolved.prefix;
745
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
776
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
746
777
  return async (input, output) => {
747
778
  debug("tool.execute.after:", input.tool, "args:", input.args);
748
779
  if (input.callID) {
@@ -751,6 +782,8 @@ function createFileReadAfterHook(cache, config) {
751
782
  return;
752
783
  }
753
784
  processedCallIds.add(input.callID);
785
+ } else {
786
+ debug("no callID \u2014 deduplication disabled for this call");
754
787
  }
755
788
  if (!isFileReadTool(input.tool, input.args)) {
756
789
  debug("skipped: not a file-read tool");
@@ -789,13 +822,16 @@ function createFileReadAfterHook(cache, config) {
789
822
  function createFileEditBeforeHook(config) {
790
823
  const resolved = config ?? resolveConfig();
791
824
  const prefix = resolved.prefix;
792
- const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
825
+ const processedCallIds = new BoundedSet(MAX_PROCESSED_IDS);
793
826
  return async (input, output) => {
794
827
  if (input.callID) {
795
828
  if (processedCallIds.has(input.callID)) {
829
+ debug("skipped: duplicate callID (edit)", input.callID);
796
830
  return;
797
831
  }
798
832
  processedCallIds.add(input.callID);
833
+ } else {
834
+ debug("no callID \u2014 deduplication disabled for this edit call");
799
835
  }
800
836
  const toolName = input.tool.toLowerCase();
801
837
  const isFileEdit = FILE_EDIT_TOOLS.some(
package/dist/utils.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.cjs';
2
- export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.cjs';
1
+ import { H as HashlineConfig, f as HashlineCache } from './hashline-DWndArr4.cjs';
2
+ export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-DWndArr4.cjs';
3
3
  import { Hooks } from '@opencode-ai/plugin';
4
4
 
5
5
  /**
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as HashlineConfig, f as HashlineCache } from './hashline-A7k2yn3G.js';
2
- export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-A7k2yn3G.js';
1
+ import { H as HashlineConfig, f as HashlineCache } from './hashline-DWndArr4.js';
2
+ export { C as CandidateLine, D as DEFAULT_CONFIG, g as DEFAULT_EXCLUDE_PATTERNS, h as DEFAULT_PREFIX, a as HashEditInput, b as HashEditOperation, c as HashEditResult, i as HashlineError, d as HashlineErrorCode, e as HashlineInstance, R as ResolvedRange, V as VerifyHashResult, j as applyHashEdit, k as buildHashMap, l as computeFileRev, m as computeLineHash, n as createHashline, o as extractFileRev, p as findCandidateLines, q as formatFileWithHashes, r as getAdaptiveHashLength, s as getByteLength, t as matchesGlob, u as normalizeHashRef, v as parseHashRef, w as replaceRange, x as resolveConfig, y as resolveRange, z as shouldExclude, A as stripHashes, B as verifyFileRev, E as verifyHash } from './hashline-DWndArr4.js';
3
3
  import { Hooks } from '@opencode-ai/plugin';
4
4
 
5
5
  /**
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook,
5
5
  isFileReadTool
6
- } from "./chunk-7KUPGN4M.js";
6
+ } from "./chunk-3ED7MDEC.js";
7
7
  import {
8
8
  DEFAULT_CONFIG,
9
9
  DEFAULT_EXCLUDE_PATTERNS,
@@ -30,7 +30,7 @@ import {
30
30
  stripHashes,
31
31
  verifyFileRev,
32
32
  verifyHash
33
- } from "./chunk-DOR4YDIS.js";
33
+ } from "./chunk-C323JLG3.js";
34
34
  export {
35
35
  DEFAULT_CONFIG,
36
36
  DEFAULT_EXCLUDE_PATTERNS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-hashline",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Hashline plugin for OpenCode — content-addressable line hashing for precise AI code editing",
5
5
  "main": "dist/opencode-hashline.cjs",
6
6
  "module": "dist/opencode-hashline.js",