opencode-hashline 1.0.1 → 1.0.3

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.md CHANGED
@@ -5,8 +5,12 @@
5
5
  **Content-addressable line hashing for precise AI code editing**
6
6
 
7
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
+ [![Release](https://github.com/izzzzzi/opencode-hashline/actions/workflows/release.yml/badge.svg)](https://github.com/izzzzzi/opencode-hashline/actions/workflows/release.yml)
8
9
  [![npm version](https://img.shields.io/npm/v/opencode-hashline.svg?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/opencode-hashline)
10
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-hashline.svg?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/opencode-hashline)
11
+ [![GitHub release](https://img.shields.io/github/v/release/izzzzzi/opencode-hashline?style=flat&colorA=18181B&colorB=28CF8D)](https://github.com/izzzzzi/opencode-hashline/releases)
9
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat&colorA=18181B&colorB=28CF8D)](LICENSE)
13
+ [![semantic-release](https://img.shields.io/badge/semantic--release-auto-e10079?style=flat&colorA=18181B)](https://github.com/semantic-release/semantic-release)
10
14
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat&colorA=18181B&colorB=3178C6)](https://www.typescriptlang.org/)
11
15
  [![Node.js](https://img.shields.io/badge/Node.js-ESM-green?style=flat&colorA=18181B&colorB=339933)](https://nodejs.org/)
12
16
 
package/README.ru.md CHANGED
@@ -5,8 +5,12 @@
5
5
  **Контентно-адресуемое хеширование строк для точного редактирования кода с помощью AI**
6
6
 
7
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
+ [![Release](https://github.com/izzzzzi/opencode-hashline/actions/workflows/release.yml/badge.svg)](https://github.com/izzzzzi/opencode-hashline/actions/workflows/release.yml)
8
9
  [![npm version](https://img.shields.io/npm/v/opencode-hashline.svg?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/opencode-hashline)
10
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-hashline.svg?style=flat&colorA=18181B&colorB=28CF8D)](https://www.npmjs.com/package/opencode-hashline)
11
+ [![GitHub release](https://img.shields.io/github/v/release/izzzzzi/opencode-hashline?style=flat&colorA=18181B&colorB=28CF8D)](https://github.com/izzzzzi/opencode-hashline/releases)
9
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat&colorA=18181B&colorB=28CF8D)](LICENSE)
13
+ [![semantic-release](https://img.shields.io/badge/semantic--release-auto-e10079?style=flat&colorA=18181B)](https://github.com/semantic-release/semantic-release)
10
14
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat&colorA=18181B&colorB=3178C6)](https://www.typescriptlang.org/)
11
15
  [![Node.js](https://img.shields.io/badge/Node.js-ESM-green?style=flat&colorA=18181B&colorB=339933)](https://nodejs.org/)
12
16
 
@@ -11,6 +11,19 @@ import { appendFileSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { homedir } from "os";
13
13
  var DEBUG_LOG = join(homedir(), ".config", "opencode", "hashline-debug.log");
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);
22
+ }
23
+ return originalAdd(value);
24
+ };
25
+ return set;
26
+ }
14
27
  function debug(...args) {
15
28
  const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
16
29
  `;
@@ -20,7 +33,7 @@ function debug(...args) {
20
33
  }
21
34
  }
22
35
  var FILE_READ_TOOLS = ["read", "file_read", "read_file", "cat", "view"];
23
- var FILE_EDIT_TOOLS = ["write", "file_write", "file_edit", "edit", "edit_file", "patch", "apply_patch", "multiedit"];
36
+ var FILE_EDIT_TOOLS = ["write", "file_write", "file_edit", "edit", "edit_file", "patch", "apply_patch", "multiedit", "batch"];
24
37
  function isFileReadTool(toolName, args) {
25
38
  const lower = toolName.toLowerCase();
26
39
  const nameMatch = FILE_READ_TOOLS.some(
@@ -40,8 +53,16 @@ function createFileReadAfterHook(cache, config) {
40
53
  const resolved = config ?? resolveConfig();
41
54
  const hashLen = resolved.hashLength || 0;
42
55
  const prefix = resolved.prefix;
56
+ const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
43
57
  return async (input, output) => {
44
58
  debug("tool.execute.after:", input.tool, "args:", input.args);
59
+ if (input.callID) {
60
+ if (processedCallIds.has(input.callID)) {
61
+ debug("skipped: duplicate callID", input.callID);
62
+ return;
63
+ }
64
+ processedCallIds.add(input.callID);
65
+ }
45
66
  if (!isFileReadTool(input.tool, input.args)) {
46
67
  debug("skipped: not a file-read tool");
47
68
  return;
@@ -79,14 +100,21 @@ function createFileReadAfterHook(cache, config) {
79
100
  function createFileEditBeforeHook(config) {
80
101
  const resolved = config ?? resolveConfig();
81
102
  const prefix = resolved.prefix;
103
+ const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
82
104
  return async (input, output) => {
105
+ if (input.callID) {
106
+ if (processedCallIds.has(input.callID)) {
107
+ return;
108
+ }
109
+ processedCallIds.add(input.callID);
110
+ }
83
111
  const toolName = input.tool.toLowerCase();
84
112
  const isFileEdit = FILE_EDIT_TOOLS.some(
85
113
  (name) => toolName === name || toolName.endsWith(`.${name}`)
86
114
  );
87
115
  if (!isFileEdit) return;
88
116
  if (!output.args || typeof output.args !== "object") return;
89
- const contentFields = [
117
+ const contentFields = /* @__PURE__ */ new Set([
90
118
  "content",
91
119
  "new_content",
92
120
  "old_content",
@@ -96,13 +124,26 @@ function createFileEditBeforeHook(config) {
96
124
  "text",
97
125
  "diff",
98
126
  "patch",
99
- "patchText"
100
- ];
101
- for (const field of contentFields) {
102
- if (typeof output.args[field] === "string") {
103
- output.args[field] = stripHashes(output.args[field], prefix);
127
+ "patchText",
128
+ "body"
129
+ ]);
130
+ function stripDeep(obj) {
131
+ for (const key of Object.keys(obj)) {
132
+ const val = obj[key];
133
+ if (typeof val === "string" && contentFields.has(key)) {
134
+ obj[key] = stripHashes(val, prefix);
135
+ } else if (Array.isArray(val)) {
136
+ for (const item of val) {
137
+ if (item && typeof item === "object" && !Array.isArray(item)) {
138
+ stripDeep(item);
139
+ }
140
+ }
141
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
142
+ stripDeep(val);
143
+ }
104
144
  }
105
145
  }
146
+ stripDeep(output.args);
106
147
  };
107
148
  }
108
149
  function createSystemPromptHook(config) {
package/dist/index.cjs CHANGED
@@ -467,6 +467,19 @@ var import_path = require("path");
467
467
  var import_os = require("os");
468
468
  init_hashline();
469
469
  var DEBUG_LOG = (0, import_path.join)((0, import_os.homedir)(), ".config", "opencode", "hashline-debug.log");
470
+ var MAX_PROCESSED_IDS = 1e4;
471
+ function createBoundedSet(maxSize) {
472
+ const set = /* @__PURE__ */ new Set();
473
+ const originalAdd = set.add.bind(set);
474
+ set.add = (value) => {
475
+ if (set.size >= maxSize) {
476
+ const first = set.values().next().value;
477
+ if (first !== void 0) set.delete(first);
478
+ }
479
+ return originalAdd(value);
480
+ };
481
+ return set;
482
+ }
470
483
  function debug(...args) {
471
484
  const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
472
485
  `;
@@ -476,7 +489,7 @@ function debug(...args) {
476
489
  }
477
490
  }
478
491
  var FILE_READ_TOOLS = ["read", "file_read", "read_file", "cat", "view"];
479
- var FILE_EDIT_TOOLS = ["write", "file_write", "file_edit", "edit", "edit_file", "patch", "apply_patch", "multiedit"];
492
+ var FILE_EDIT_TOOLS = ["write", "file_write", "file_edit", "edit", "edit_file", "patch", "apply_patch", "multiedit", "batch"];
480
493
  function isFileReadTool(toolName, args) {
481
494
  const lower = toolName.toLowerCase();
482
495
  const nameMatch = FILE_READ_TOOLS.some(
@@ -496,8 +509,16 @@ function createFileReadAfterHook(cache, config) {
496
509
  const resolved = config ?? resolveConfig();
497
510
  const hashLen = resolved.hashLength || 0;
498
511
  const prefix = resolved.prefix;
512
+ const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
499
513
  return async (input, output) => {
500
514
  debug("tool.execute.after:", input.tool, "args:", input.args);
515
+ if (input.callID) {
516
+ if (processedCallIds.has(input.callID)) {
517
+ debug("skipped: duplicate callID", input.callID);
518
+ return;
519
+ }
520
+ processedCallIds.add(input.callID);
521
+ }
501
522
  if (!isFileReadTool(input.tool, input.args)) {
502
523
  debug("skipped: not a file-read tool");
503
524
  return;
@@ -535,14 +556,21 @@ function createFileReadAfterHook(cache, config) {
535
556
  function createFileEditBeforeHook(config) {
536
557
  const resolved = config ?? resolveConfig();
537
558
  const prefix = resolved.prefix;
559
+ const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
538
560
  return async (input, output) => {
561
+ if (input.callID) {
562
+ if (processedCallIds.has(input.callID)) {
563
+ return;
564
+ }
565
+ processedCallIds.add(input.callID);
566
+ }
539
567
  const toolName = input.tool.toLowerCase();
540
568
  const isFileEdit = FILE_EDIT_TOOLS.some(
541
569
  (name) => toolName === name || toolName.endsWith(`.${name}`)
542
570
  );
543
571
  if (!isFileEdit) return;
544
572
  if (!output.args || typeof output.args !== "object") return;
545
- const contentFields = [
573
+ const contentFields = /* @__PURE__ */ new Set([
546
574
  "content",
547
575
  "new_content",
548
576
  "old_content",
@@ -552,13 +580,26 @@ function createFileEditBeforeHook(config) {
552
580
  "text",
553
581
  "diff",
554
582
  "patch",
555
- "patchText"
556
- ];
557
- for (const field of contentFields) {
558
- if (typeof output.args[field] === "string") {
559
- output.args[field] = stripHashes(output.args[field], prefix);
583
+ "patchText",
584
+ "body"
585
+ ]);
586
+ function stripDeep(obj) {
587
+ for (const key of Object.keys(obj)) {
588
+ const val = obj[key];
589
+ if (typeof val === "string" && contentFields.has(key)) {
590
+ obj[key] = stripHashes(val, prefix);
591
+ } else if (Array.isArray(val)) {
592
+ for (const item of val) {
593
+ if (item && typeof item === "object" && !Array.isArray(item)) {
594
+ stripDeep(item);
595
+ }
596
+ }
597
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
598
+ stripDeep(val);
599
+ }
560
600
  }
561
601
  }
602
+ stripDeep(output.args);
562
603
  };
563
604
  }
564
605
  function createSystemPromptHook(config) {
@@ -646,15 +687,21 @@ function createHashlineEditTool(config, cache) {
646
687
  async execute(args, context) {
647
688
  const { path, operation, startRef, endRef, replacement } = args;
648
689
  const absPath = (0, import_path2.isAbsolute)(path) ? path : (0, import_path2.resolve)(context.directory, path);
649
- const normalizedAbs = (0, import_path2.resolve)(absPath);
650
- const normalizedWorktree = (0, import_path2.resolve)(context.worktree);
651
- if (normalizedAbs !== normalizedWorktree && !normalizedAbs.startsWith(normalizedWorktree + import_path2.sep)) {
690
+ let realAbs;
691
+ try {
692
+ realAbs = (0, import_fs2.realpathSync)(absPath);
693
+ } catch {
694
+ realAbs = (0, import_path2.resolve)(absPath);
695
+ }
696
+ const realWorktree = (0, import_fs2.realpathSync)((0, import_path2.resolve)(context.worktree));
697
+ if (realAbs !== realWorktree && !realAbs.startsWith(realWorktree + import_path2.sep)) {
652
698
  throw new Error(`Access denied: "${path}" resolves outside the project directory`);
653
699
  }
700
+ const normalizedAbs = (0, import_path2.resolve)(absPath);
654
701
  const displayPath = (0, import_path2.relative)(context.worktree, absPath) || path;
655
702
  let current;
656
703
  try {
657
- current = (0, import_fs2.readFileSync)(absPath, "utf-8");
704
+ current = (0, import_fs2.readFileSync)(realAbs, "utf-8");
658
705
  } catch (error) {
659
706
  const reason = error instanceof Error ? error.message : String(error);
660
707
  throw new Error(`Failed to read "${displayPath}": ${reason}`);
@@ -681,14 +728,15 @@ function createHashlineEditTool(config, cache) {
681
728
  throw new Error(`Hashline edit failed for "${displayPath}": ${reason}`);
682
729
  }
683
730
  try {
684
- (0, import_fs2.writeFileSync)(absPath, nextContent, "utf-8");
731
+ (0, import_fs2.writeFileSync)(realAbs, nextContent, "utf-8");
685
732
  } catch (error) {
686
733
  const reason = error instanceof Error ? error.message : String(error);
687
734
  throw new Error(`Failed to write "${displayPath}": ${reason}`);
688
735
  }
689
736
  if (cache) {
690
- cache.invalidate(absPath);
737
+ cache.invalidate(realAbs);
691
738
  cache.invalidate(normalizedAbs);
739
+ cache.invalidate(absPath);
692
740
  if (path !== absPath) cache.invalidate(path);
693
741
  if (displayPath !== absPath) cache.invalidate(displayPath);
694
742
  }
@@ -736,6 +784,7 @@ function loadConfig(projectDir, userConfig) {
736
784
  function createHashlinePlugin(userConfig) {
737
785
  return async (input) => {
738
786
  const projectDir = input.directory;
787
+ const worktree = input.worktree;
739
788
  const fileConfig = loadConfig(projectDir, userConfig);
740
789
  const config = resolveConfig(fileConfig);
741
790
  const cache = new HashlineCache(config.cacheSize);
@@ -746,6 +795,17 @@ function createHashlinePlugin(userConfig) {
746
795
  `);
747
796
  } catch {
748
797
  }
798
+ const tempFiles = /* @__PURE__ */ new Set();
799
+ const cleanupTempFiles = () => {
800
+ for (const f of tempFiles) {
801
+ try {
802
+ (0, import_fs3.unlinkSync)(f);
803
+ } catch {
804
+ }
805
+ }
806
+ tempFiles.clear();
807
+ };
808
+ process.on("exit", cleanupTempFiles);
749
809
  return {
750
810
  tool: {
751
811
  hashline_edit: createHashlineEditTool(config, cache)
@@ -767,6 +827,17 @@ function createHashlinePlugin(userConfig) {
767
827
  filePath = (0, import_url.fileURLToPath)(p.url);
768
828
  }
769
829
  if (!filePath) continue;
830
+ if (worktree) {
831
+ try {
832
+ const realFile = (0, import_fs3.realpathSync)(filePath);
833
+ const realWorktree = (0, import_fs3.realpathSync)((0, import_path3.resolve)(worktree));
834
+ if (realFile !== realWorktree && !realFile.startsWith(realWorktree + import_path3.sep)) {
835
+ continue;
836
+ }
837
+ } catch {
838
+ continue;
839
+ }
840
+ }
770
841
  if (shouldExclude2(filePath, config.exclude)) continue;
771
842
  let content;
772
843
  try {
@@ -779,6 +850,7 @@ function createHashlinePlugin(userConfig) {
779
850
  if (cached) {
780
851
  const tmpPath2 = (0, import_path3.join)((0, import_os2.tmpdir)(), `hashline-${p.id}.txt`);
781
852
  (0, import_fs3.writeFileSync)(tmpPath2, cached, "utf-8");
853
+ tempFiles.add(tmpPath2);
782
854
  p.url = `file://${tmpPath2}`;
783
855
  writeLog(debugLog, `[${(/* @__PURE__ */ new Date()).toISOString()}] chat.message annotated (cached): ${filePath}
784
856
  `);
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  createFileEditBeforeHook,
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook
5
- } from "./chunk-C2EVIAGV.js";
5
+ } from "./chunk-OJ2CHKQK.js";
6
6
  import {
7
7
  HashlineCache,
8
8
  applyHashEdit,
@@ -10,13 +10,13 @@ import {
10
10
  } from "./chunk-IVZSANZ4.js";
11
11
 
12
12
  // src/index.ts
13
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
14
- import { join } from "path";
13
+ import { readFileSync as readFileSync2, realpathSync as realpathSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
14
+ import { join, resolve as resolve2, sep as sep2 } from "path";
15
15
  import { homedir, tmpdir } from "os";
16
16
  import { fileURLToPath } from "url";
17
17
 
18
18
  // src/hashline-tool.ts
19
- import { readFileSync, writeFileSync } from "fs";
19
+ import { readFileSync, realpathSync, writeFileSync } from "fs";
20
20
  import { isAbsolute, relative, resolve, sep } from "path";
21
21
  import { z } from "zod";
22
22
  function createHashlineEditTool(config, cache) {
@@ -32,15 +32,21 @@ function createHashlineEditTool(config, cache) {
32
32
  async execute(args, context) {
33
33
  const { path, operation, startRef, endRef, replacement } = args;
34
34
  const absPath = isAbsolute(path) ? path : resolve(context.directory, path);
35
- const normalizedAbs = resolve(absPath);
36
- const normalizedWorktree = resolve(context.worktree);
37
- if (normalizedAbs !== normalizedWorktree && !normalizedAbs.startsWith(normalizedWorktree + sep)) {
35
+ let realAbs;
36
+ try {
37
+ realAbs = realpathSync(absPath);
38
+ } catch {
39
+ realAbs = resolve(absPath);
40
+ }
41
+ const realWorktree = realpathSync(resolve(context.worktree));
42
+ if (realAbs !== realWorktree && !realAbs.startsWith(realWorktree + sep)) {
38
43
  throw new Error(`Access denied: "${path}" resolves outside the project directory`);
39
44
  }
45
+ const normalizedAbs = resolve(absPath);
40
46
  const displayPath = relative(context.worktree, absPath) || path;
41
47
  let current;
42
48
  try {
43
- current = readFileSync(absPath, "utf-8");
49
+ current = readFileSync(realAbs, "utf-8");
44
50
  } catch (error) {
45
51
  const reason = error instanceof Error ? error.message : String(error);
46
52
  throw new Error(`Failed to read "${displayPath}": ${reason}`);
@@ -67,14 +73,15 @@ function createHashlineEditTool(config, cache) {
67
73
  throw new Error(`Hashline edit failed for "${displayPath}": ${reason}`);
68
74
  }
69
75
  try {
70
- writeFileSync(absPath, nextContent, "utf-8");
76
+ writeFileSync(realAbs, nextContent, "utf-8");
71
77
  } catch (error) {
72
78
  const reason = error instanceof Error ? error.message : String(error);
73
79
  throw new Error(`Failed to write "${displayPath}": ${reason}`);
74
80
  }
75
81
  if (cache) {
76
- cache.invalidate(absPath);
82
+ cache.invalidate(realAbs);
77
83
  cache.invalidate(normalizedAbs);
84
+ cache.invalidate(absPath);
78
85
  if (path !== absPath) cache.invalidate(path);
79
86
  if (displayPath !== absPath) cache.invalidate(displayPath);
80
87
  }
@@ -122,6 +129,7 @@ function loadConfig(projectDir, userConfig) {
122
129
  function createHashlinePlugin(userConfig) {
123
130
  return async (input) => {
124
131
  const projectDir = input.directory;
132
+ const worktree = input.worktree;
125
133
  const fileConfig = loadConfig(projectDir, userConfig);
126
134
  const config = resolveConfig(fileConfig);
127
135
  const cache = new HashlineCache(config.cacheSize);
@@ -132,6 +140,17 @@ function createHashlinePlugin(userConfig) {
132
140
  `);
133
141
  } catch {
134
142
  }
143
+ const tempFiles = /* @__PURE__ */ new Set();
144
+ const cleanupTempFiles = () => {
145
+ for (const f of tempFiles) {
146
+ try {
147
+ unlinkSync(f);
148
+ } catch {
149
+ }
150
+ }
151
+ tempFiles.clear();
152
+ };
153
+ process.on("exit", cleanupTempFiles);
135
154
  return {
136
155
  tool: {
137
156
  hashline_edit: createHashlineEditTool(config, cache)
@@ -153,6 +172,17 @@ function createHashlinePlugin(userConfig) {
153
172
  filePath = fileURLToPath(p.url);
154
173
  }
155
174
  if (!filePath) continue;
175
+ if (worktree) {
176
+ try {
177
+ const realFile = realpathSync2(filePath);
178
+ const realWorktree = realpathSync2(resolve2(worktree));
179
+ if (realFile !== realWorktree && !realFile.startsWith(realWorktree + sep2)) {
180
+ continue;
181
+ }
182
+ } catch {
183
+ continue;
184
+ }
185
+ }
156
186
  if (shouldExclude(filePath, config.exclude)) continue;
157
187
  let content;
158
188
  try {
@@ -165,6 +195,7 @@ function createHashlinePlugin(userConfig) {
165
195
  if (cached) {
166
196
  const tmpPath2 = join(tmpdir(), `hashline-${p.id}.txt`);
167
197
  writeFileSync2(tmpPath2, cached, "utf-8");
198
+ tempFiles.add(tmpPath2);
168
199
  p.url = `file://${tmpPath2}`;
169
200
  writeLog(debugLog, `[${(/* @__PURE__ */ new Date()).toISOString()}] chat.message annotated (cached): ${filePath}
170
201
  `);
package/dist/utils.cjs CHANGED
@@ -451,6 +451,19 @@ var import_fs = require("fs");
451
451
  var import_path = require("path");
452
452
  var import_os = require("os");
453
453
  var DEBUG_LOG = (0, import_path.join)((0, import_os.homedir)(), ".config", "opencode", "hashline-debug.log");
454
+ var MAX_PROCESSED_IDS = 1e4;
455
+ function createBoundedSet(maxSize) {
456
+ const set = /* @__PURE__ */ new Set();
457
+ const originalAdd = set.add.bind(set);
458
+ set.add = (value) => {
459
+ if (set.size >= maxSize) {
460
+ const first = set.values().next().value;
461
+ if (first !== void 0) set.delete(first);
462
+ }
463
+ return originalAdd(value);
464
+ };
465
+ return set;
466
+ }
454
467
  function debug(...args) {
455
468
  const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
456
469
  `;
@@ -460,7 +473,7 @@ function debug(...args) {
460
473
  }
461
474
  }
462
475
  var FILE_READ_TOOLS = ["read", "file_read", "read_file", "cat", "view"];
463
- var FILE_EDIT_TOOLS = ["write", "file_write", "file_edit", "edit", "edit_file", "patch", "apply_patch", "multiedit"];
476
+ var FILE_EDIT_TOOLS = ["write", "file_write", "file_edit", "edit", "edit_file", "patch", "apply_patch", "multiedit", "batch"];
464
477
  function isFileReadTool(toolName, args) {
465
478
  const lower = toolName.toLowerCase();
466
479
  const nameMatch = FILE_READ_TOOLS.some(
@@ -480,8 +493,16 @@ function createFileReadAfterHook(cache, config) {
480
493
  const resolved = config ?? resolveConfig();
481
494
  const hashLen = resolved.hashLength || 0;
482
495
  const prefix = resolved.prefix;
496
+ const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
483
497
  return async (input, output) => {
484
498
  debug("tool.execute.after:", input.tool, "args:", input.args);
499
+ if (input.callID) {
500
+ if (processedCallIds.has(input.callID)) {
501
+ debug("skipped: duplicate callID", input.callID);
502
+ return;
503
+ }
504
+ processedCallIds.add(input.callID);
505
+ }
485
506
  if (!isFileReadTool(input.tool, input.args)) {
486
507
  debug("skipped: not a file-read tool");
487
508
  return;
@@ -519,14 +540,21 @@ function createFileReadAfterHook(cache, config) {
519
540
  function createFileEditBeforeHook(config) {
520
541
  const resolved = config ?? resolveConfig();
521
542
  const prefix = resolved.prefix;
543
+ const processedCallIds = createBoundedSet(MAX_PROCESSED_IDS);
522
544
  return async (input, output) => {
545
+ if (input.callID) {
546
+ if (processedCallIds.has(input.callID)) {
547
+ return;
548
+ }
549
+ processedCallIds.add(input.callID);
550
+ }
523
551
  const toolName = input.tool.toLowerCase();
524
552
  const isFileEdit = FILE_EDIT_TOOLS.some(
525
553
  (name) => toolName === name || toolName.endsWith(`.${name}`)
526
554
  );
527
555
  if (!isFileEdit) return;
528
556
  if (!output.args || typeof output.args !== "object") return;
529
- const contentFields = [
557
+ const contentFields = /* @__PURE__ */ new Set([
530
558
  "content",
531
559
  "new_content",
532
560
  "old_content",
@@ -536,13 +564,26 @@ function createFileEditBeforeHook(config) {
536
564
  "text",
537
565
  "diff",
538
566
  "patch",
539
- "patchText"
540
- ];
541
- for (const field of contentFields) {
542
- if (typeof output.args[field] === "string") {
543
- output.args[field] = stripHashes(output.args[field], prefix);
567
+ "patchText",
568
+ "body"
569
+ ]);
570
+ function stripDeep(obj) {
571
+ for (const key of Object.keys(obj)) {
572
+ const val = obj[key];
573
+ if (typeof val === "string" && contentFields.has(key)) {
574
+ obj[key] = stripHashes(val, prefix);
575
+ } else if (Array.isArray(val)) {
576
+ for (const item of val) {
577
+ if (item && typeof item === "object" && !Array.isArray(item)) {
578
+ stripDeep(item);
579
+ }
580
+ }
581
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
582
+ stripDeep(val);
583
+ }
544
584
  }
545
585
  }
586
+ stripDeep(output.args);
546
587
  };
547
588
  }
548
589
  function createSystemPromptHook(config) {
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  createFileReadAfterHook,
4
4
  createSystemPromptHook,
5
5
  isFileReadTool
6
- } from "./chunk-C2EVIAGV.js";
6
+ } from "./chunk-OJ2CHKQK.js";
7
7
  import {
8
8
  DEFAULT_CONFIG,
9
9
  DEFAULT_EXCLUDE_PATTERNS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-hashline",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Hashline plugin for OpenCode — content-addressable line hashing for precise AI code editing",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -45,8 +45,11 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@opencode-ai/plugin": "^1.2.2",
48
+ "@semantic-release/changelog": "^6.0.3",
49
+ "@semantic-release/git": "^10.0.1",
48
50
  "@types/node": "^25.2.3",
49
51
  "@types/picomatch": "^4.0.2",
52
+ "semantic-release": "^25.0.3",
50
53
  "tsup": "^8.5.1",
51
54
  "typescript": "^5.9.3",
52
55
  "vitest": "^4.0.18",
@@ -54,5 +57,27 @@
54
57
  },
55
58
  "dependencies": {
56
59
  "picomatch": "^4.0.3"
60
+ },
61
+ "release": {
62
+ "branches": [
63
+ "main"
64
+ ],
65
+ "plugins": [
66
+ "@semantic-release/commit-analyzer",
67
+ "@semantic-release/release-notes-generator",
68
+ "@semantic-release/changelog",
69
+ "@semantic-release/npm",
70
+ "@semantic-release/github",
71
+ [
72
+ "@semantic-release/git",
73
+ {
74
+ "assets": [
75
+ "package.json",
76
+ "CHANGELOG.md"
77
+ ],
78
+ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
79
+ }
80
+ ]
81
+ ]
57
82
  }
58
83
  }