opencode-gbk-tools 0.1.14 → 0.1.16

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  为 OpenCode 提供一套自动识别编码的文本工具,以及面向 `GBK` / `GB18030` 的专用工具。
4
4
 
5
- 解决 OpenCode 内置工具难以稳定处理非 UTF-8 文本文件的问题,并让所有 agents 默认优先使用可保留原编码的 `text_*` 工具。
5
+ 解决 OpenCode 内置工具难以稳定处理非 UTF-8 文本文件的问题,并通过 plugin 让所有 agents 默认获得这些工具与规则,无需单独切换专属 GBK agent。
6
6
 
7
7
  ---
8
8
 
@@ -17,7 +17,7 @@
17
17
  | `gbk_write` | 写入或追加内容到 GBK 文件(`append=true` 支持追加) |
18
18
  | `gbk_edit` | 精确替换 GBK 文件中的指定文本块 |
19
19
  | `gbk_search` | 在 GBK 文件中搜索关键词,返回行号和上下文 |
20
- | 本地/全局 plugin 规则 | 给所有 agents 注入“优先使用 `text_*`”的系统提示 |
20
+ | 本地/全局 plugin 规则 | 给所有 agents 注入“优先使用 `text_*`”的系统提示,并统一开放 `gbk_*` 工具 |
21
21
 
22
22
  ---
23
23
 
@@ -27,7 +27,15 @@
27
27
  npx opencode-gbk-tools install
28
28
  ```
29
29
 
30
- 安装完成后**重启 OpenCode**,工具立即生效。
30
+ 安装完成后会注册 `opencode-gbk-tools` plugin,由该 plugin 统一向全部 agents 暴露 `text_*` / `gbk_*` 工具与规则。
31
+
32
+ 重启 OpenCode 后,**全部 agents** 都会默认获得:
33
+
34
+ - `text_read` / `text_write` / `text_edit`
35
+ - `gbk_read` / `gbk_write` / `gbk_edit` / `gbk_search`
36
+ - 对文本文件优先使用 `text_*` 的系统提示
37
+
38
+ 不再需要单独安装或切换专属 `gbk-engine` agent。
31
39
 
32
40
  ---
33
41
 
@@ -41,6 +49,8 @@ npx opencode-gbk-tools install
41
49
  npx opencode-gbk-tools uninstall
42
50
  ```
43
51
 
52
+ 卸载后会移除已安装的工具、plugin 与 manifest,所有 agents 恢复为未安装前的默认行为。
53
+
44
54
  ---
45
55
 
46
56
  ## 工具使用说明
@@ -181,7 +191,7 @@ A:重启 OpenCode。工具在 OpenCode 启动时加载,安装后需要重启
181
191
  A:请先安装 Node.js(https://nodejs.org/),版本需要 18 或以上。
182
192
 
183
193
  **Q:Windows 路径在哪?**
184
- A:全局安装目录为 `C:\Users\你的用户名\.config\opencode\tools\`
194
+ A:全局安装的 plugin 文件位于 `C:\Users\你的用户名\.config\opencode\plugins\opencode-gbk-tools.js`
185
195
 
186
196
  **Q:gbk_edit 提示"未找到需要替换的文本"?**
187
197
  A:检查 `oldString` 是否包含了行号前缀(如 `"3787: "`),去掉行号前缀后重试。若要在文件末尾追加内容,请使用 `gbk_write [append=true]`。
@@ -195,6 +205,8 @@ A:不要。现在优先用 `mode="insertAfter"` 或 `mode="insertBefore"`,
195
205
 
196
206
  | 版本 | 说明 |
197
207
  |------|------|
208
+ | 0.1.16 | 去掉专属 `gbk-engine` agent 安装链路,改为通过 plugin + tools 让全部 agents 统一支持 `text_*` / `gbk_*`;同步更新安装说明 |
209
+ | 0.1.15 | 为 GBK 大文件统一引入行字节索引与流式读/搜/改路径,提升局部编辑、搜索与大块修改时的性能、稳定性与准确性 |
198
210
  | 0.1.14 | 重新发布当前稳定产物,核对 `npm pack --dry-run` 输出包含完整 `dist/`,为下一次公开发布提供一致的打包基线 |
199
211
  | 0.1.13 | `text_write` 在新文件 + `encoding=auto` 下改为显式报错,不再静默默认 `utf8`;同时优化 `text_read` 流式读取与 `text_edit` 预览生成,减少重复读取 |
200
212
  | 0.1.12 | 仅提升发布版本号,用于重新发布当前 `0.1.11` 的稳定内容 |
package/dist/cli/index.js CHANGED
@@ -60,10 +60,19 @@ function resolveTargetBase(target, cwd) {
60
60
  // src/cli/install.ts
61
61
  async function installCommand(args) {
62
62
  const targetBase = resolveTargetBase(args.target, args.cwd);
63
- const allowedArtifacts = new Set(args.artifacts ?? ["tool", "agent", "plugin"]);
63
+ const allowedArtifacts = new Set(args.artifacts ?? ["plugin"]);
64
64
  const releaseManifest = JSON.parse(await fs3.readFile(path3.join(args.packageRoot, "dist", "release-manifest.json"), "utf8"));
65
65
  const selectedArtifacts = releaseManifest.artifacts.filter((artifact) => allowedArtifacts.has(artifact.kind));
66
66
  const existingManifest = await loadInstalledManifest(targetBase);
67
+ const selectedRelativePaths = new Set(selectedArtifacts.map((artifact) => artifact.relativePath));
68
+ if (existingManifest) {
69
+ for (const file of existingManifest.files) {
70
+ if (selectedRelativePaths.has(file.relativePath)) {
71
+ continue;
72
+ }
73
+ await fs3.rm(path3.join(targetBase, file.relativePath), { force: true });
74
+ }
75
+ }
67
76
  for (const artifact of selectedArtifacts) {
68
77
  const targetPath = path3.join(targetBase, artifact.relativePath);
69
78
  if (await pathExists(targetPath) && !existingManifest && !args.force) {
@@ -181,7 +190,7 @@ async function setupCommand(args) {
181
190
  await fs6.mkdir(configBase, { recursive: true });
182
191
  const configPath = await resolveConfigFile(configBase);
183
192
  await ensurePluginConfigured(configPath, pluginName);
184
- const installResult = await installCommand({ ...args, force: true, artifacts: ["agent", "plugin"] });
193
+ const installResult = await installCommand({ ...args, force: true, artifacts: ["plugin"] });
185
194
  return {
186
195
  configPath,
187
196
  targetBase: installResult.targetBase
@@ -16305,6 +16305,14 @@ async function assertPathAllowed(filePath, context, allowExternal = false) {
16305
16305
 
16306
16306
  // src/lib/gbk-file.ts
16307
16307
  var STREAMING_FILE_SIZE_THRESHOLD_BYTES = 1024 * 1024;
16308
+ var STREAM_READ_CHUNK_SIZE_BYTES = 1024 * 1024;
16309
+ var gbkLineIndexCache = /* @__PURE__ */ new Map();
16310
+ function toSafeNumber(value) {
16311
+ return typeof value === "bigint" ? Number(value) : value;
16312
+ }
16313
+ function invalidateGbkLineIndex(filePath) {
16314
+ gbkLineIndexCache.delete(filePath);
16315
+ }
16308
16316
  function assertStringArgument(value, name) {
16309
16317
  if (typeof value !== "string") {
16310
16318
  throw createGbkError("GBK_INVALID_ARGUMENT", `${name} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`);
@@ -16417,6 +16425,18 @@ function detectNewlineStyle(text) {
16417
16425
  }
16418
16426
  return "none";
16419
16427
  }
16428
+ function finalizeNewlineStyle(crlfCount, lfCount) {
16429
+ if (crlfCount > 0 && lfCount === 0) {
16430
+ return "crlf";
16431
+ }
16432
+ if (lfCount > 0 && crlfCount === 0) {
16433
+ return "lf";
16434
+ }
16435
+ if (crlfCount > 0 && lfCount > 0) {
16436
+ return "mixed";
16437
+ }
16438
+ return "none";
16439
+ }
16420
16440
  function applyLineRange(text, startLine, endLine) {
16421
16441
  if (startLine === void 0 && endLine === void 0) {
16422
16442
  return {
@@ -16542,6 +16562,29 @@ function hasLineNumberPrefixes(lines) {
16542
16562
  function stripLineNumberPrefixes(lines) {
16543
16563
  return lines.map((l) => l.replace(/^\d+: /, ""));
16544
16564
  }
16565
+ function parseLineNumberPrefixedBlock(text) {
16566
+ const lines = splitNormalizedLines(text);
16567
+ if (!hasLineNumberPrefixes(lines)) {
16568
+ return null;
16569
+ }
16570
+ const lineNumbers = [];
16571
+ const strippedLines = [];
16572
+ for (const line of lines) {
16573
+ const match = /^(\d+): ?(.*)$/.exec(line);
16574
+ if (!match) {
16575
+ return null;
16576
+ }
16577
+ lineNumbers.push(Number(match[1]));
16578
+ strippedLines.push(match[2]);
16579
+ }
16580
+ return {
16581
+ lineNumbers,
16582
+ strippedText: strippedLines.join("\n"),
16583
+ isContiguous: lineNumbers.every((lineNumber, index) => index === 0 || lineNumber === lineNumbers[index - 1] + 1),
16584
+ startLine: lineNumbers[0],
16585
+ endLine: lineNumbers[lineNumbers.length - 1]
16586
+ };
16587
+ }
16545
16588
  function matchLooseBlock(contentLines, oldLines, newLines, newlineStyle, content) {
16546
16589
  for (let start = 0; start < contentLines.length; start += 1) {
16547
16590
  let contentIndex = start;
@@ -16658,7 +16701,7 @@ async function readWholeGbkTextFile(input) {
16658
16701
  }
16659
16702
  async function visitDecodedTextChunks(input, visitor) {
16660
16703
  const decoder = import_iconv_lite.default.getDecoder(input.encoding);
16661
- const stream = createReadStream(input.filePath);
16704
+ const stream = createReadStream(input.filePath, { highWaterMark: STREAM_READ_CHUNK_SIZE_BYTES });
16662
16705
  try {
16663
16706
  for await (const chunk of stream) {
16664
16707
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -16681,6 +16724,90 @@ async function visitDecodedTextChunks(input, visitor) {
16681
16724
  stream.destroy();
16682
16725
  }
16683
16726
  }
16727
+ async function getGbkLineIndex(input) {
16728
+ const cached2 = gbkLineIndexCache.get(input.filePath);
16729
+ if (cached2 && cached2.fileSize === toSafeNumber(input.stat.size) && cached2.mtimeMs === toSafeNumber(input.stat.mtimeMs)) {
16730
+ return cached2;
16731
+ }
16732
+ const lineStartOffsets = [0];
16733
+ let byteOffset = 0;
16734
+ let previousByteWasCR = false;
16735
+ let crlfCount = 0;
16736
+ let lfCount = 0;
16737
+ const stream = createReadStream(input.filePath, { highWaterMark: STREAM_READ_CHUNK_SIZE_BYTES });
16738
+ try {
16739
+ for await (const chunk of stream) {
16740
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
16741
+ assertNotBinary(buffer);
16742
+ for (let index = 0; index < buffer.length; index += 1) {
16743
+ const byte = buffer[index];
16744
+ if (byte === 10) {
16745
+ if (previousByteWasCR) {
16746
+ crlfCount += 1;
16747
+ } else {
16748
+ lfCount += 1;
16749
+ }
16750
+ lineStartOffsets.push(byteOffset + index + 1);
16751
+ previousByteWasCR = false;
16752
+ continue;
16753
+ }
16754
+ previousByteWasCR = byte === 13;
16755
+ }
16756
+ byteOffset += buffer.length;
16757
+ }
16758
+ } catch (error45) {
16759
+ if (error45 instanceof Error && "code" in error45) {
16760
+ throw error45;
16761
+ }
16762
+ throw createGbkError("GBK_IO_ERROR", `\u8BFB\u53D6\u6587\u4EF6\u5931\u8D25: ${input.filePath}`, error45);
16763
+ } finally {
16764
+ stream.destroy();
16765
+ }
16766
+ const result = {
16767
+ filePath: input.filePath,
16768
+ fileSize: toSafeNumber(input.stat.size),
16769
+ mtimeMs: toSafeNumber(input.stat.mtimeMs),
16770
+ lineStartOffsets,
16771
+ totalLines: lineStartOffsets.length,
16772
+ newlineStyle: finalizeNewlineStyle(crlfCount, lfCount)
16773
+ };
16774
+ gbkLineIndexCache.set(input.filePath, result);
16775
+ return result;
16776
+ }
16777
+ async function readDecodedGbkByteRange(input, start, endExclusive) {
16778
+ if (endExclusive <= start) {
16779
+ return "";
16780
+ }
16781
+ const decoder = import_iconv_lite.default.getDecoder(input.encoding);
16782
+ const stream = createReadStream(input.filePath, {
16783
+ start,
16784
+ end: endExclusive - 1,
16785
+ highWaterMark: STREAM_READ_CHUNK_SIZE_BYTES
16786
+ });
16787
+ const parts = [];
16788
+ try {
16789
+ for await (const chunk of stream) {
16790
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
16791
+ assertNotBinary(buffer);
16792
+ const text = decoder.write(buffer);
16793
+ if (text.length > 0) {
16794
+ parts.push(text);
16795
+ }
16796
+ }
16797
+ const trailingText = decoder.end() ?? "";
16798
+ if (trailingText.length > 0) {
16799
+ parts.push(trailingText);
16800
+ }
16801
+ return parts.join("");
16802
+ } catch (error45) {
16803
+ if (error45 instanceof Error && "code" in error45) {
16804
+ throw error45;
16805
+ }
16806
+ throw createGbkError("GBK_IO_ERROR", `\u8BFB\u53D6\u6587\u4EF6\u5931\u8D25: ${input.filePath}`, error45);
16807
+ } finally {
16808
+ stream.destroy();
16809
+ }
16810
+ }
16684
16811
  async function writeAll(handle, buffer) {
16685
16812
  let offset = 0;
16686
16813
  while (offset < buffer.length) {
@@ -16696,6 +16823,249 @@ async function writeEncodedText(handle, encoding, text) {
16696
16823
  await writeAll(handle, buffer);
16697
16824
  return buffer.byteLength;
16698
16825
  }
16826
+ async function copyFileByteRangeToHandle(sourcePath, handle, start, endExclusive) {
16827
+ if (endExclusive <= start) {
16828
+ return 0;
16829
+ }
16830
+ const stream = createReadStream(sourcePath, {
16831
+ start,
16832
+ end: endExclusive - 1,
16833
+ highWaterMark: STREAM_READ_CHUNK_SIZE_BYTES
16834
+ });
16835
+ let bytesWritten = 0;
16836
+ try {
16837
+ for await (const chunk of stream) {
16838
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
16839
+ await writeAll(handle, buffer);
16840
+ bytesWritten += buffer.byteLength;
16841
+ }
16842
+ return bytesWritten;
16843
+ } catch (error45) {
16844
+ if (error45 instanceof Error && "code" in error45) {
16845
+ throw error45;
16846
+ }
16847
+ throw createGbkError("GBK_IO_ERROR", `\u8BFB\u53D6\u6587\u4EF6\u5931\u8D25: ${sourcePath}`, error45);
16848
+ } finally {
16849
+ stream.destroy();
16850
+ }
16851
+ }
16852
+ function alignTextToNewlineStyle(text, newlineStyle) {
16853
+ return newlineStyle === "crlf" ? text.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n") : newlineStyle === "lf" ? text.replace(/\r\n/g, "\n") : text;
16854
+ }
16855
+ function replaceScopedTextContent(scopeText, oldString, newString, replaceAll, newlineStyle) {
16856
+ const occurrencesBefore = countOccurrences(scopeText, oldString);
16857
+ if (!replaceAll && occurrencesBefore === 0) {
16858
+ const loose = tryLooseBlockReplace(scopeText, oldString, newString);
16859
+ if (loose !== null) {
16860
+ return {
16861
+ replacedText: loose.content,
16862
+ replacements: 1,
16863
+ occurrencesBefore: loose.occurrencesBefore
16864
+ };
16865
+ }
16866
+ }
16867
+ if (replaceAll) {
16868
+ if (occurrencesBefore === 0) {
16869
+ throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scopeText, oldString));
16870
+ }
16871
+ } else if (occurrencesBefore === 0) {
16872
+ throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scopeText, oldString));
16873
+ } else if (occurrencesBefore > 1) {
16874
+ throw createGbkError("GBK_MULTIPLE_MATCHES", `\u627E\u5230\u591A\u4E2A\u5339\u914D\u9879: ${oldString}`);
16875
+ }
16876
+ const alignedNewString = alignTextToNewlineStyle(newString, newlineStyle);
16877
+ return {
16878
+ replacedText: replaceAll ? scopeText.split(oldString).join(alignedNewString) : scopeText.replace(oldString, alignedNewString),
16879
+ replacements: replaceAll ? occurrencesBefore : 1,
16880
+ occurrencesBefore
16881
+ };
16882
+ }
16883
+ async function replaceLargeGbkFileTextInLineRange(input) {
16884
+ const lineIndex = await getGbkLineIndex(input);
16885
+ const requestedStartLine = input.startLine ?? 1;
16886
+ const requestedEndLine = input.endLine ?? lineIndex.totalLines;
16887
+ if (requestedEndLine < requestedStartLine) {
16888
+ throw createGbkError("GBK_INVALID_ARGUMENT", "endLine \u4E0D\u80FD\u5C0F\u4E8E startLine");
16889
+ }
16890
+ const actualStartLine = Math.min(requestedStartLine, lineIndex.totalLines);
16891
+ const actualEndLine = Math.min(requestedEndLine, lineIndex.totalLines);
16892
+ const rangeStart = lineIndex.lineStartOffsets[Math.max(0, actualStartLine - 1)] ?? toSafeNumber(input.stat.size);
16893
+ const rangeEnd = actualEndLine < lineIndex.totalLines ? lineIndex.lineStartOffsets[actualEndLine] : toSafeNumber(input.stat.size);
16894
+ const scopeText = await readDecodedGbkByteRange(input, rangeStart, rangeEnd);
16895
+ const replaced = replaceScopedTextContent(
16896
+ scopeText,
16897
+ input.oldString,
16898
+ input.newString,
16899
+ input.replaceAll,
16900
+ lineIndex.newlineStyle
16901
+ );
16902
+ const tempPath = path2.join(
16903
+ path2.dirname(input.filePath),
16904
+ `${path2.basename(input.filePath)}.opencode-gbk-${crypto.randomUUID()}.tmp`
16905
+ );
16906
+ const handle = await fs2.open(tempPath, "w");
16907
+ let bytesWritten = 0;
16908
+ try {
16909
+ bytesWritten += await copyFileByteRangeToHandle(input.filePath, handle, 0, rangeStart);
16910
+ bytesWritten += await writeEncodedText(handle, input.encoding, replaced.replacedText);
16911
+ bytesWritten += await copyFileByteRangeToHandle(input.filePath, handle, rangeEnd, toSafeNumber(input.stat.size));
16912
+ await handle.close();
16913
+ const mode = typeof input.stat.mode === "bigint" ? Number(input.stat.mode) : input.stat.mode;
16914
+ await fs2.chmod(tempPath, mode);
16915
+ await fs2.rename(tempPath, input.filePath);
16916
+ return {
16917
+ mode: "replace",
16918
+ filePath: input.filePath,
16919
+ encoding: input.encoding,
16920
+ replacements: replaced.replacements,
16921
+ occurrencesBefore: replaced.occurrencesBefore,
16922
+ bytesRead: input.stat.size,
16923
+ bytesWritten
16924
+ };
16925
+ } catch (error45) {
16926
+ await handle.close().catch(() => void 0);
16927
+ await fs2.rm(tempPath, { force: true }).catch(() => void 0);
16928
+ throw error45;
16929
+ }
16930
+ }
16931
+ async function replaceLargeGbkFileByAnchor(input) {
16932
+ const tempPath = path2.join(
16933
+ path2.dirname(input.filePath),
16934
+ `${path2.basename(input.filePath)}.opencode-gbk-${crypto.randomUUID()}.tmp`
16935
+ );
16936
+ const handle = await fs2.open(tempPath, "w");
16937
+ const alignedContent = input.content.replace(/\r\n/g, "\n");
16938
+ const anchorLength = input.anchor.length;
16939
+ const carryLength = Math.max(anchorLength + alignedContent.length, 1);
16940
+ let decoded = "";
16941
+ let scanFrom = 0;
16942
+ let totalMatches = 0;
16943
+ let inserted = false;
16944
+ let skipped = false;
16945
+ let bytesWritten = 0;
16946
+ const flushPrefix = async (preserveTailLength) => {
16947
+ const flushLength = Math.max(0, decoded.length - preserveTailLength);
16948
+ if (flushLength === 0) {
16949
+ return;
16950
+ }
16951
+ bytesWritten += await writeEncodedText(handle, input.encoding, decoded.slice(0, flushLength));
16952
+ decoded = decoded.slice(flushLength);
16953
+ scanFrom = Math.max(0, scanFrom - flushLength);
16954
+ };
16955
+ const finalizeInserted = async () => {
16956
+ bytesWritten += await writeEncodedText(handle, input.encoding, decoded);
16957
+ decoded = "";
16958
+ };
16959
+ try {
16960
+ await visitDecodedTextChunks(input, async (text) => {
16961
+ decoded += text;
16962
+ while (!inserted) {
16963
+ const foundAt = decoded.indexOf(input.anchor, scanFrom);
16964
+ if (foundAt === -1) {
16965
+ break;
16966
+ }
16967
+ totalMatches += 1;
16968
+ const afterAnchor = foundAt + anchorLength;
16969
+ if (totalMatches !== input.occurrence) {
16970
+ scanFrom = afterAnchor;
16971
+ continue;
16972
+ }
16973
+ const before = decoded.slice(0, foundAt);
16974
+ const after = decoded.slice(afterAnchor);
16975
+ const anchorAndAfter = decoded.slice(foundAt);
16976
+ const alreadyExists = input.mode === "insertAfter" ? after.startsWith(alignedContent) : before.endsWith(alignedContent);
16977
+ if (alreadyExists) {
16978
+ if (input.ifExists === "error") {
16979
+ throw createGbkError("GBK_INVALID_ARGUMENT", "\u63D2\u5165\u4F4D\u7F6E\u5DF2\u5B58\u5728\u76F8\u540C\u5185\u5BB9");
16980
+ }
16981
+ if (input.ifExists === "skip") {
16982
+ skipped = true;
16983
+ inserted = true;
16984
+ bytesWritten += await writeEncodedText(handle, input.encoding, decoded);
16985
+ decoded = "";
16986
+ return;
16987
+ }
16988
+ }
16989
+ if (input.mode === "insertAfter") {
16990
+ bytesWritten += await writeEncodedText(handle, input.encoding, before);
16991
+ bytesWritten += await writeEncodedText(handle, input.encoding, input.anchor);
16992
+ bytesWritten += await writeEncodedText(handle, input.encoding, alignedContent);
16993
+ bytesWritten += await writeEncodedText(handle, input.encoding, after);
16994
+ } else {
16995
+ bytesWritten += await writeEncodedText(handle, input.encoding, before);
16996
+ bytesWritten += await writeEncodedText(handle, input.encoding, alignedContent);
16997
+ bytesWritten += await writeEncodedText(handle, input.encoding, anchorAndAfter);
16998
+ }
16999
+ decoded = "";
17000
+ scanFrom = 0;
17001
+ inserted = true;
17002
+ return;
17003
+ }
17004
+ if (!inserted) {
17005
+ await flushPrefix(carryLength);
17006
+ }
17007
+ });
17008
+ if (!inserted) {
17009
+ while (true) {
17010
+ const foundAt = decoded.indexOf(input.anchor, scanFrom);
17011
+ if (foundAt === -1) {
17012
+ break;
17013
+ }
17014
+ totalMatches += 1;
17015
+ const afterAnchor = foundAt + anchorLength;
17016
+ if (totalMatches !== input.occurrence) {
17017
+ scanFrom = afterAnchor;
17018
+ continue;
17019
+ }
17020
+ const before = decoded.slice(0, foundAt);
17021
+ const after = decoded.slice(afterAnchor);
17022
+ const anchorAndAfter = decoded.slice(foundAt);
17023
+ const alreadyExists = input.mode === "insertAfter" ? after.startsWith(alignedContent) : before.endsWith(alignedContent);
17024
+ if (alreadyExists) {
17025
+ if (input.ifExists === "error") {
17026
+ throw createGbkError("GBK_INVALID_ARGUMENT", "\u63D2\u5165\u4F4D\u7F6E\u5DF2\u5B58\u5728\u76F8\u540C\u5185\u5BB9");
17027
+ }
17028
+ if (input.ifExists === "skip") {
17029
+ skipped = true;
17030
+ inserted = true;
17031
+ break;
17032
+ }
17033
+ }
17034
+ decoded = input.mode === "insertAfter" ? `${before}${input.anchor}${alignedContent}${after}` : `${before}${alignedContent}${anchorAndAfter}`;
17035
+ inserted = true;
17036
+ break;
17037
+ }
17038
+ }
17039
+ if (!inserted && totalMatches === 0) {
17040
+ throw createGbkError("GBK_NO_MATCH", `\u672A\u627E\u5230\u951A\u70B9: ${input.anchor}`);
17041
+ }
17042
+ if (!inserted && totalMatches > 0) {
17043
+ throw createGbkError("GBK_NO_MATCH", `\u951A\u70B9 ${input.anchor} \u53EA\u627E\u5230 ${totalMatches} \u5904\uFF0C\u65E0\u6CD5\u4F7F\u7528\u7B2C ${input.occurrence} \u5904`);
17044
+ }
17045
+ await finalizeInserted();
17046
+ await handle.close();
17047
+ const mode = typeof input.stat.mode === "bigint" ? Number(input.stat.mode) : input.stat.mode;
17048
+ await fs2.chmod(tempPath, mode);
17049
+ await fs2.rename(tempPath, input.filePath);
17050
+ invalidateGbkLineIndex(input.filePath);
17051
+ return {
17052
+ mode: input.mode,
17053
+ filePath: input.filePath,
17054
+ encoding: input.encoding,
17055
+ anchor: input.anchor,
17056
+ occurrence: input.occurrence,
17057
+ anchorMatches: totalMatches,
17058
+ inserted: !skipped,
17059
+ skipped,
17060
+ bytesRead: input.stat.size,
17061
+ bytesWritten
17062
+ };
17063
+ } catch (error45) {
17064
+ await handle.close().catch(() => void 0);
17065
+ await fs2.rm(tempPath, { force: true }).catch(() => void 0);
17066
+ throw error45;
17067
+ }
17068
+ }
16699
17069
  async function replaceLargeGbkFileText(input) {
16700
17070
  const tempPath = path2.join(
16701
17071
  path2.dirname(input.filePath),
@@ -16742,6 +17112,7 @@ async function replaceLargeGbkFileText(input) {
16742
17112
  const mode = typeof input.stat.mode === "bigint" ? Number(input.stat.mode) : input.stat.mode;
16743
17113
  await fs2.chmod(tempPath, mode);
16744
17114
  await fs2.rename(tempPath, input.filePath);
17115
+ invalidateGbkLineIndex(input.filePath);
16745
17116
  return {
16746
17117
  filePath: input.filePath,
16747
17118
  encoding: input.encoding,
@@ -16766,8 +17137,18 @@ async function replaceGbkFileText(input) {
16766
17137
  if (mode === "insertAfter" || mode === "insertBefore") {
16767
17138
  assertInsertArguments(input);
16768
17139
  const resolved2 = await resolveReadableGbkFile(normalizedInput);
16769
- const current2 = await readWholeGbkTextFile(resolved2);
16770
17140
  const occurrence = normalizeOptionalPositiveInteger(input.occurrence, "occurrence") ?? 1;
17141
+ if (resolved2.stat.size >= STREAMING_FILE_SIZE_THRESHOLD_BYTES) {
17142
+ return await replaceLargeGbkFileByAnchor({
17143
+ ...resolved2,
17144
+ mode,
17145
+ anchor: input.anchor,
17146
+ content: input.content,
17147
+ occurrence,
17148
+ ifExists: input.ifExists ?? "skip"
17149
+ });
17150
+ }
17151
+ const current2 = await readWholeGbkTextFile(resolved2);
16771
17152
  const insertResult = insertByAnchor(
16772
17153
  current2.content,
16773
17154
  mode,
@@ -16812,24 +17193,47 @@ async function replaceGbkFileText(input) {
16812
17193
  }
16813
17194
  const replaceAll = normalizedInput.replaceAll ?? false;
16814
17195
  const resolved = await resolveReadableGbkFile(normalizedInput);
16815
- const hasScopedRange = normalizedInput.startLine !== void 0 || normalizedInput.endLine !== void 0 || normalizedInput.startAnchor !== void 0 || normalizedInput.endAnchor !== void 0;
17196
+ const prefixedBlock = parseLineNumberPrefixedBlock(input.oldString);
17197
+ const derivedScopedRange = prefixedBlock?.isContiguous ? {
17198
+ startLine: normalizedInput.startLine ?? prefixedBlock.startLine,
17199
+ endLine: normalizedInput.endLine ?? prefixedBlock.endLine,
17200
+ oldString: prefixedBlock.strippedText
17201
+ } : null;
17202
+ const effectiveOldString = derivedScopedRange?.oldString ?? input.oldString;
17203
+ const hasScopedRange = normalizedInput.startLine !== void 0 || normalizedInput.endLine !== void 0 || normalizedInput.startAnchor !== void 0 || normalizedInput.endAnchor !== void 0 || derivedScopedRange !== null;
16816
17204
  if (resolved.stat.size >= STREAMING_FILE_SIZE_THRESHOLD_BYTES && !hasScopedRange) {
16817
17205
  return await replaceLargeGbkFileText({
16818
17206
  ...resolved,
16819
- oldString: input.oldString,
17207
+ oldString: effectiveOldString,
16820
17208
  newString: input.newString,
16821
17209
  replaceAll
16822
17210
  });
16823
17211
  }
17212
+ if (resolved.stat.size >= STREAMING_FILE_SIZE_THRESHOLD_BYTES && normalizedInput.startAnchor === void 0 && normalizedInput.endAnchor === void 0 && (normalizedInput.startLine !== void 0 || normalizedInput.endLine !== void 0 || derivedScopedRange !== null)) {
17213
+ return await replaceLargeGbkFileTextInLineRange({
17214
+ ...resolved,
17215
+ oldString: effectiveOldString,
17216
+ newString: input.newString,
17217
+ replaceAll,
17218
+ startLine: derivedScopedRange?.startLine ?? normalizedInput.startLine,
17219
+ endLine: derivedScopedRange?.endLine ?? normalizedInput.endLine
17220
+ });
17221
+ }
16824
17222
  const current = await readWholeGbkTextFile(resolved);
16825
- const scope = resolveEditScope(current.content, normalizedInput);
16826
- const occurrencesBefore = countOccurrences(scope.selectedText, input.oldString);
17223
+ const scopedInput = derivedScopedRange === null ? normalizedInput : {
17224
+ ...normalizedInput,
17225
+ startLine: derivedScopedRange.startLine,
17226
+ endLine: derivedScopedRange.endLine
17227
+ };
17228
+ const scope = resolveEditScope(current.content, scopedInput);
17229
+ const occurrencesBefore = countOccurrences(scope.selectedText, effectiveOldString);
16827
17230
  if (!replaceAll && occurrencesBefore === 0) {
16828
- const loose = tryLooseBlockReplace(scope.selectedText, input.oldString, input.newString);
17231
+ const loose = tryLooseBlockReplace(scope.selectedText, effectiveOldString, input.newString);
16829
17232
  if (loose !== null) {
16830
17233
  const outputText2 = `${current.content.slice(0, scope.rangeStart)}${loose.content}${current.content.slice(scope.rangeEnd)}`;
16831
17234
  const buffer2 = import_iconv_lite.default.encode(outputText2, current.encoding);
16832
17235
  await fs2.writeFile(current.filePath, buffer2);
17236
+ invalidateGbkLineIndex(current.filePath);
16833
17237
  return {
16834
17238
  mode: "replace",
16835
17239
  filePath: current.filePath,
@@ -16843,19 +17247,20 @@ async function replaceGbkFileText(input) {
16843
17247
  }
16844
17248
  if (replaceAll) {
16845
17249
  if (occurrencesBefore === 0) {
16846
- throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, input.oldString));
17250
+ throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, effectiveOldString));
16847
17251
  }
16848
17252
  } else if (occurrencesBefore === 0) {
16849
- throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, input.oldString));
17253
+ throw createGbkError("GBK_NO_MATCH", buildNoMatchMessage(scope.selectedText, effectiveOldString));
16850
17254
  } else if (occurrencesBefore > 1) {
16851
- throw createGbkError("GBK_MULTIPLE_MATCHES", `\u627E\u5230\u591A\u4E2A\u5339\u914D\u9879: ${input.oldString}`);
17255
+ throw createGbkError("GBK_MULTIPLE_MATCHES", `\u627E\u5230\u591A\u4E2A\u5339\u914D\u9879: ${effectiveOldString}`);
16852
17256
  }
16853
17257
  const fileNewlineStyle = detectNewlineStyle(current.content);
16854
17258
  const alignedNewString = fileNewlineStyle === "crlf" ? input.newString.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n") : fileNewlineStyle === "lf" ? input.newString.replace(/\r\n/g, "\n") : input.newString;
16855
- const replaced = replaceAll ? scope.selectedText.split(input.oldString).join(alignedNewString) : scope.selectedText.replace(input.oldString, alignedNewString);
17259
+ const replaced = replaceAll ? scope.selectedText.split(effectiveOldString).join(alignedNewString) : scope.selectedText.replace(effectiveOldString, alignedNewString);
16856
17260
  const outputText = `${current.content.slice(0, scope.rangeStart)}${replaced}${current.content.slice(scope.rangeEnd)}`;
16857
17261
  const buffer = import_iconv_lite.default.encode(outputText, current.encoding);
16858
17262
  await fs2.writeFile(current.filePath, buffer);
17263
+ invalidateGbkLineIndex(current.filePath);
16859
17264
  return {
16860
17265
  mode: "replace",
16861
17266
  filePath: current.filePath,
@@ -16871,7 +17276,8 @@ async function replaceGbkFileText(input) {
16871
17276
  var gbk_edit_default = tool({
16872
17277
  description: `Edit GBK/GB18030 encoded text files with exact string replacement.
16873
17278
 
16874
- Reads the FULL file content regardless of file size \u2014 not limited by gbk_read's line window.
17279
+ For whole-file replace, large files use a streaming path.
17280
+ For line-scoped replace, large files use a cached line-byte index and only decode the selected line window.
16875
17281
  Safe to use on files with more than 2000 lines.
16876
17282
 
16877
17283
  CRITICAL \u2014 do NOT include line number prefixes in oldString or newString:
@@ -16915,7 +17321,7 @@ Insert mode:
16915
17321
  },
16916
17322
  async execute(args, context) {
16917
17323
  const result = await replaceGbkFileText({ ...args, context });
16918
- const isReplace = !("mode" in result) || result.mode === "replace";
17324
+ const isReplace = "replacements" in result;
16919
17325
  const title = isReplace ? `GBK \u7F16\u8F91 ${result.encoding.toUpperCase()} x${result.replacements}` : result.inserted ? `GBK \u63D2\u5165 ${result.encoding.toUpperCase()} #${result.occurrence}` : `GBK \u8DF3\u8FC7 ${result.encoding.toUpperCase()} #${result.occurrence}`;
16920
17326
  context.metadata({
16921
17327
  title,