npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-fs-encoding

v1.4.2

Published

Encoding-governed read/write/edit tools for DeepSeek Harness (dsh): UTF-8 BOM fidelity and byte-exact round-trips for GBK, Big5, Shift-JIS, EUC-KR, Windows-1251 and UTF-16 text files.

Readme

dsh-fs-encoding — DSH 文件编码守护

为 DeepSeek Harness(DSH)提供的文件编码治理插件:让 AI 读写文件时不再弄坏 BOM 与多字节编码

🌏 中文 | English

dsh · dsh-plugin · plugin · encoding · BOM · GBK · Big5 · Shift-JIS · UTF-16 · AI agent · 编码 · 文件编码 · 乱码

简介

DSH 自带的 read / write / edit 三个文件工具只认 UTF-8,遇到中文、日文、韩文项目里常见的编码就会出问题:

  • GBK、Big5、Shift-JIS 等编码的文件根本读不了,直接报 invalid UTF-8 text,AI 只能干看着;
  • 带 BOM 的 UTF-8 文件能读,但 BOM 会被悄悄吃掉——AI 随手改一次,文件开头那三个字节就没了。对 PHP、旧版编译器、部分 Windows 软件来说,文件头少了 BOM 就可能解析出错或直接乱码;
  • 更麻烦的是没有任何警告:你只会在某天发现文件坏了,却不知道是哪次编辑弄坏的。

本插件接管这三个工具,在完整保留原有行为(沙箱围栏、读后写保护、版本校验、diff 展示)的前提下,让非 UTF-8 文件和 BOM 都能正确读写:文件原来是什么编码,改完还是什么编码。

功能特性

  • 字节级保真:文件编码在首次读取时确定一次,之后每次保存都按原编码写回。改一个 GBK 文件,它仍然是一个 GBK 文件,不会被偷偷转成 UTF-8。
  • BOM 保真:原本有 BOM 就精确还原,原本没有就绝不擅自添加——两个方向都不会出错。
  • 换行符保真:CRLF / LF / CR 在读取时识别、保存时还原,Windows 项目不会被改成 LF。
  • 绝不静默损坏:如果目标编码表示不了新内容(比如往 GBK 文件里写 emoji),插件会直接拒绝写入并说明原因,文件保持原样——而不是写进去一堆 ? 把文件毁掉。
  • 读不了会告诉你怎么办:遇到非 UTF-8 文件时,插件会列出最可能的几种编码和各自的解码效果,AI(或你)选一个重新读即可,就像 VS Code 的「通过编码重新打开」。
  • 十九种编码:UTF-8(含 BOM)、UTF-16、UTF-32,以及 GBK、Big5、Shift-JIS、EUC-KR 和 Windows-125x 全系(西欧、中欧、西里尔、希腊、土耳其、希伯来、阿拉伯、波罗的海)。
  • 零学习成本:read / write / edit 三个工具的参数、返回格式与原版完全一致,是直接替换,原有提示词和工作习惯都不用改;read 和 write 各多了一个可选的 encoding 参数(write 的只用于新建文件,见下)。
  • 能建指定编码的新文件:write 加一个 encoding 就能直接生成 GBK、Shift-JIS 等编码的文件,不用先写 UTF-8 再转码。
  • 多三个工具:insert(按行号插入,原生没有这个能力)、undo_last_edit(撤销上一次编辑,内容与编码一并还原)和 str_replace_editor(4 个子命令的兼容层,同样带编码治理)。见下方「另外三个工具」。
  • 配置简单:一个 YAML 文件,几项开关,都能用环境变量覆盖。

使用

开箱即用

装好就能用,不需要任何配置。 插件没有必填项、没有初始化步骤,也不会改动你的项目——装上之后,AI 照常用 read / write / edit,编码的事插件自己处理:

  • 读 UTF-8 文件(含带 BOM 的):和原版完全一样,BOM 不会再被吃掉。
  • 读带 BOM 的 UTF-16 / UTF-32 文件:自动识别。
  • 改 GBK、Shift-JIS 等老编码文件:按原编码写回,不会被悄悄转成 UTF-8。
  • 写新文件:默认 UTF-8,需要别的编码时加一个参数即可。

这些都不需要你做任何事,也不需要 AI 改调用习惯。日常使用中,AI 的调用方式与原版完全一致。

AI 的调用区别

只有一种情况会不一样:无 BOM 的非 UTF-8 文件(典型是 GBK / Big5 / Shift-JIS 的老文件)。此时插件不会擅自猜,而是停下来让 AI 用显式编码重读一次:

[E_NOT_TEXT] legacy.txt is not valid UTF-8. Most likely gbk. Re-read with
read({ file_path: "legacy.txt", encoding: "gbk" }) to decode it, or set
autoGuessEncoding: true in the plugin config to decode automatically.
Candidates: gbk("你好,世界"), big5("斕疑"), shift_jis("ト羲")

AI 照着提示里的调用重读一次即可,它会自己完成——你不需要介入。重读之后编码就从「猜测」变成了「已知事实」,后续每次写入都按它进行。

read 和 write 因此各多了一个可选参数:

read({ file_path: "legacy.txt", encoding: "gbk" })
write({ file_path: "run.bat", content: "echo 中文\r\n", encoding: "gbk" })

无 BOM 的 UTF-16 / UTF-32 文件同理,用 read({ file_path: "<路径>", encoding: "utf16le" }) 显式指定即可正常读写(这类文件在 Windows 上较少见,且无 BOM 时无法可靠自动区分字节序,因此不做猜测)。

为什么默认要问一下? GBK、Big5、Shift-JIS 的字节范围在短文本上互相重叠,猜错在界面上是看不出来的——而且会按错误的编码写回,把文件彻底弄坏。所以插件默认选择「宁可失败,不可猜错」。如果你更希望它尽力解码,把 autoGuessEncoding 设为 true 即可。

即使开了 autoGuessEncoding,也有一种情况仍然会报错并列出候选:文件极短(几个字节),且两个独立的识别器给出了不同的答案。此时没有任何依据能判断谁对——实测这种情况下的首选有约 79% 是错的——插件宁可让你从候选里挑一个,也不替你赌一把。只要两者给出相同的答案,就会直接采用;文件稍长一些(几十字节以上)也基本不会再出现这种歧义。

另一种会报错的情况:识别器把文件判成了 ISO-8859-1 / Windows-1252 这类单字节编码,但按它解码出来的文本几乎全是非 ASCII 字符。真实的西文以字母和空格为主,不会长这样;而中文/日文/韩文/俄文的双字节被当成单字节读时,每个字符会变成两个拉丁字符,正好就是这个特征。这时插件不会采用这个判断,而是报错并列出候选——实测这一步能拦下 24 个本会被静默读错的文件,且不会误伤任何原本读对的文件。

新建指定编码的文件

默认新文件是 UTF-8(无 BOM)。要给旧系统生成一个 GBK 文件,加 encoding 即可:

write({ file_path: "run.bat", content: "echo 中文\r\n", encoding: "gbk" })

支持的编码名与 read 相同,别名和大小写都不敏感(cp936、Shift-JIS 都行)。名字里带 BOM 的(utf8bom、utf16le、utf16be、utf32le、utf32be)会写入对应的 BOM;其余都不写 BOM。编码表示不了的字符同样拒绝写入,不会写成 ?。

encoding 只对新建文件有效。 文件已存在时传它会直接报错 E_ENCODING_NOT_APPLICABLE,不会转换——因为「保持文件原有编码」是本插件的核心承诺,而转换会重写整个文件,AI 从返回结果里看不出区别。不要为了转换而先删除文件:删除会绕过「先读后写」闸门,让未读过的内容在返回结果里以 before: null 静默消失;而如果本会话读过该文件,删除后写入会一直失败 FS_STALE_VERSION,该路径在本会话内再也建不回来。本插件不提供编码转换;确实需要一份另一种编码的副本时,请写到新路径上。

另外三个工具

除上面三个之外,插件还注册三个工具,同样带编码治理(写入按文件原有编码落盘)。

insert —— 按行号插入,原生没有这个能力。字面替换只能插在能引用到上下文的地方,「在文件开头加一行」之类就得先读到那一行再原样复述,很不方便:

insert({ file_path: "config.ini", insert_line: 0, new_string: "[core]" })

insert_line 指插入到该行之后:0 插到最前,等于文件行数时追加到末尾。行号与 read 显示的完全一致。

undo_last_edit —— 撤销某个文件的上一次编辑,内容与编码一并还原:

undo_last_edit({ file_path: "config.ini" })

改错了、或者 AI 发现自己上一次改动不对时用它。几条要点:

  • 只保留最近一次。同一个文件编辑两次,只有第二次能撤销;撤销一次后就没有了(不支持重做)。
  • 编码一起还原。如果那次编辑把 GBK 文件转成了 UTF-8(见 normalizeToUtf8),撤销会把文件变回 GBK,而不只是内容回去。
  • 文件被改过就拒绝。撤销前会核对文件是否仍等于那次编辑写入的内容;如果之后有人改过(你、其他工具、其他会话),撤销会拒绝并说明,绝不覆盖那些改动。
  • 只存在内存里。不写磁盘、不污染仓库,所以 DSH 重启后无法撤销(会明确告诉你没有可撤销的历史,而不是假装成功)。
  • 新建文件不产生撤销点:「撤销一次新建」等于删文件,破坏性太大,请直接删。

str_replace_editor —— 4 个子命令的兼容层,让按这套工具写的提示词与习惯直接可用:

| 命令 | 作用 | |---|---| | view | 显示文件(带行号,可用 view_range 限定范围);路径是目录时列出两层结构,跳过隐藏项、node_modules、__pycache__ | | create | 新建文件,已存在则拒绝 | | str_replace | 替换唯一匹配;old_str 不唯一时拒绝并指出所在行号 | | insert | 同 insert 工具,insert_line 语义一致 | | undo_edit | 同 undo_last_edit,撤销上一次编辑 |

str_replace 额外接受一个可选的 replace_all:不传就是标准行为(要求唯一匹配),传 true 才全部替换。这是本插件加的唯一扩展参数。

原生的 str_replace 与 insert 只认 UTF-8,遇到 GBK 文件会直接失败或把文件转坏——这正是本插件接管它们的意义。

安装

⚠️ 冲突提示:任何在同一个作用域层注册 read / write / edit / insert / str_replace_editor / undo_last_edit 中任一名字的插件都与本插件互斥——同一层重复注册同名工具会直接报错。

插件启动时若发现这些名字已被同一层的其他插件占用,会拒绝安装并指出具体是哪个工具名被占了,不会留下半残的工具集。要解决冲突,要么从 profile 移除本插件,要么关掉那个占用了名字的插件:

# 在 profile 的 cordis.patch.yml 中
- id: <对方的插件 id>
  disabled: true

注意:内建 read / write / edit 位于更外层的宿主/preset 层,不构成冲突——本插件正是要在 agent 自己的层上覆盖它们,这与原生工具的 shadow 机制一致。

方式一:插件市场安装(dsh-market,推荐)

已安装 dsh-market(DSH 插件市场)的用户:打开 设置 → 插件市场(Plugin Market),搜索 dsh-fs-encoding,点卡片上的「安装」并按提示确认,安装完成后重启 dsh web。

市场卡片:https://awesome-dsh-plugin.com/p/MrWeiCodes/dsh-fs-encoding/

方式二:让 AI 安装(最简单)

把本仓库地址告诉 DSH 的 AI 助手即可,例如:「安装 https://github.com/MrWeiCodes/dsh-fs-encoding 这个插件」。AI 会替你完成插件装载、依赖与补丁处理;之后重启 dsh web。

方式三:从 npm 安装(推荐)

dsh plugin --profile web add dsh-fs-encoding

推荐这条路径的原因:npm 包里已包含编译好的 lib/,安装时不执行任何构建脚本——不受 pnpm 构建授权限制的影响,也不依赖你本地的编译环境。之后重启 dsh web。

方式四:从 GitHub 安装

dsh plugin --profile web add -w github:MrWeiCodes/dsh-fs-encoding

从 GitHub 装的是源码,lib/ 由 prepare 脚本现场编译,所以装完可能需要在 profile 的 pnpm-workspace.yaml 里放行构建脚本(pnpm 10 起默认阻止依赖执行构建脚本,按它打印的提示把那一行粘进去再重跑即可)。不想处理这一步就用「方式三」——npm 包已包含编译产物,没有这个环节。

从本地目录安装的已知问题:Windows 上若插件目录与 profile 不在同一个盘符(例如插件在 G:\、profile 在 C:\),pnpm 会把 file: 依赖错误解析成 C:\Users\<用户名>\... 而安装失败。此时请改用「方式五」。

方式五:手动安装

无 pnpm 或离线环境时的备选路径:

  1. 把本仓库克隆到 profile 的插件目录,并在目标目录构建一次(prepare 脚本会生成 lib/):
    # 示例:web profile
    $dst = "$HOME\.dsh\profiles\web\packages\dsh-fs-encoding"
    git clone https://github.com/MrWeiCodes/dsh-fs-encoding.git $dst
    cd $dst
    npm install      # 同时触发 prepare → 生成 lib/
  2. 在 profile 的 package.json 的 dependencies 中加入:
    "dsh-fs-encoding": "file:./packages/dsh-fs-encoding"
  3. 把 cordis.patch.yml 的内容并入 profile 的 cordis.patch.yml(在文件末尾追加)。
  4. 重新安装依赖并重启:pnpm install(或 npm install)、dsh web。

更新

  • 方式一(插件市场安装)的:在插件市场里点更新即可。
  • 方式二(AI 安装)的:直接告诉 AI「更新 dsh-fs-encoding 插件」即可。
  • 方式三(npm 安装)的:
    dsh plugin --profile web add dsh-fs-encoding@latest
    然后重启 dsh web。npm 路径同样不涉及构建步骤。
  • 方式四(GitHub 安装)的:
    dsh plugin --profile web add -w github:MrWeiCodes/dsh-fs-encoding
    若没有拉到最新提交(git 依赖有缓存),先移除再重新添加:
    dsh plugin --profile web remove dsh-fs-encoding
    dsh plugin --profile web add -w github:MrWeiCodes/dsh-fs-encoding
    然后重启 dsh web。更新到新提交后 commit 号会变,若 pnpm 再次要求放行构建,按方式四的说明重做一次即可。
  • 方式五(手动)安装的:
    cd "$HOME\.dsh\profiles\web\packages\dsh-fs-encoding"
    git pull            # 或重新拷贝新版本覆盖
    npm install         # 依赖声明有变化时执行,同时重新触发 prepare
    然后重启 dsh web。

配置($DSH_HOME/plugins/dsh-fs-encoding/config.yaml)在更新后都会保留。

卸载

方式一(插件市场安装)的:插件市场卸载

打开 设置 → 插件市场,在 dsh-fs-encoding 卡片上点「卸载」(两步确认),本会话安装的插件会即时移除。

方式二(AI 安装)的:让 AI 卸载

直接告诉 AI 助手「卸载 dsh-fs-encoding 插件」,AI 会移除插件、清理补丁与依赖。

方式三(npm)/方式四(GitHub)安装的

dsh plugin --profile web remove dsh-fs-encoding

然后重启 dsh web。原生的 read / write / edit 会立刻恢复,无需任何额外操作。

方式五(手动)安装的

  1. 从 profile 的 cordis.patch.yml 中删除本插件的 insert 行。
  2. 从 profile 的 package.json 的 dependencies 中删除 "dsh-fs-encoding": ...。
  3. 重新安装依赖并重启:pnpm install(或 npm install)、dsh web。

残留位置清单(AI 卸载时按此清理)

  • 配置目录:$DSH_HOME/plugins/dsh-fs-encoding/——$DSH_HOME 默认是 ~/.dsh(Windows 下 C:\Users\<用户名>\.dsh),插件的 config.yaml 保存在这里。卸载不会自动删除,如需彻底清理请手动删除整个目录。
  • 插件目录(方式五安装时):$DSH_HOME/profiles/<profile>/packages/dsh-fs-encoding/。
  • 依赖与补丁(方式五安装时):profile 的 package.json 中的 "dsh-fs-encoding": ... 依赖、cordis.patch.yml 中的 insert 行。
  • 无全局注册表、npm 全局包或系统级写入;插件不在会话日志里写入任何自有事件。

配置

默认配置就能用,通常不需要动它。 只有想调整「猜编码」的行为时才需要改。

配置文件位置(首次启动自动生成,内含完整注释):

$DSH_HOME/plugins/dsh-fs-encoding/config.yaml

全部选项

| 选项 | 默认值 | 作用 | |---|---|---| | autoGuessEncoding | false | 遇到读不了的非 UTF-8 文件时:自动猜,还是报错让你选 | | normalizeToUtf8 | false | 保存时是否把 GBK 等老编码转成 UTF-8 | | supportedEncodings | 内置清单 | 参与自动猜测的编码清单 | | excludeEncodings | 空 | 从内置清单里去掉几个编码 | | maxFileBytes | 10 MiB | 单个文件的读取上限 |

常见需求(直接抄)

想让它自己猜,不要每次都来问我

autoGuessEncoding: true

想彻底告别编码问题(GBK 文件第一次保存后就变成 UTF-8)

normalizeToUtf8: true

某个编码老是猜错(例如西里尔文抢走了西欧文本)

excludeEncodings: [windows-1251]

编码清单:两个键有什么区别

插件内置一份用于自动猜测的编码清单。你可以做减法,也可以整个换掉:

| 你的需求 | 该用哪个 | 插件以后新增编码时 | |---|---|---| | 只想去掉一两个 | excludeEncodings | ✅ 仍然自动生效 | | 想完全用自己的清单 | supportedEncodings | ❌ 收不到了 |

为什么建议优先用 excludeEncodings:supportedEncodings 一旦写上,就等于把清单冻结在你的配置文件里——插件以后支持了新编码,你也不会收到,而且它不会替你改回去(插件从不修改已有配置)。

清单只影响"自动猜测",不影响"能不能读"。 任何编码都可以显式指定读取,即使它不在清单里:

read({ file_path: "legacy.txt", encoding: "windows-1253" })

环境变量

适合临时测试或容器部署。环境变量始终优先于配置文件:

| 变量 | 对应选项 | |---|---| | DSH_FS_ENCODING_AUTO_GUESS | autoGuessEncoding | | DSH_FS_ENCODING_NORMALIZE_TO_UTF8 | normalizeToUtf8 | | DSH_FS_ENCODING_SUPPORTED_ENCODINGS | supportedEncodings | | DSH_FS_ENCODING_MAX_FILE_BYTES | maxFileBytes |

excludeEncodings 没有环境变量——它是唯一只能在配置文件里写的选项。

normalizeToUtf8 不会转换 UTF-16 / UTF-32 文件:它们本来就是 Unicode,强行转码反而会破坏依赖它们的程序。

支持的编码

| 类别 | 编码 | |---|---| | Unicode | utf8、utf8bom、utf16le、utf16be、utf32le、utf32be | | 东亚 | gbk(含 gb18030、gb2312、cp936)、big5(含 cp950)、shift_jis(含 sjis、cp932)、euc-kr(含 cp949) | | Windows ANSI | windows-1250(中欧)、windows-1251(西里尔)、windows-1252(西欧)、windows-1253(希腊)、windows-1254(土耳其)、windows-1255(希伯来)、windows-1256(阿拉伯)、windows-1257(波罗的海)——每个都可用 cp12xx 写法 | | 其他 | iso-8859-1(含 latin1) |

关于 windows-1258(越南语):暂不支持。越南语需要组合字符(ế 是一个码点、两个字节),而底层 iconv-lite 的单字节表无法拆分,67 个常用越南语字符中有 52 个会被编码成 ?。加入它会造成「文件读得出来却几乎存不进去」,反而更像 bug,因此暂时不开放。

这张表是"能读写",不是"会自动猜"。 自动猜测只用配置里的那一小份清单(默认 7 种),范围小是为了降低猜错率。表里其他编码照样能用,显式指定即可:read({ file_path: "x.txt", encoding: "windows-1253" })。清单怎么调见上方配置。

编码名大小写与写法都兼容:Shift-JIS、shift_jis、SJIS 指的是同一个编码。

常见错误

| 报错 | 含义与处理 | |---|---| | E_NOT_TEXT | 不是合法 UTF-8 且无 BOM。按提示用 read({ file_path: "...", encoding: "..." }) 重读,或开启 autoGuessEncoding。 | | E_UNMAPPABLE | 目标编码表示不了新内容(如 GBK 里的 emoji)。文件未被修改,请改用能表示该字符的编码,或先做 normalizeToUtf8 迁移(该迁移只作用于已有文件;新建文件请直接换一个编码名)。 | | E_BAD_ENCODING | 编码名不认识,改用上表里的名字。 | | E_ENCODING_NOT_APPLICABLE | 对已存在的文件传了 write 的 encoding。该参数只用于新建文件;文件会保持原有编码,未做任何改动。本插件不提供编码转换——不要为了转换而删除文件(见上文说明),需要另一种编码的副本请写到新路径。 | | E_DECODE_FAILED | 字节无法按指定编码解码,多半是编码选错了,换一个候选重试。 | | FS_STALE_VERSION | 文件在读取之后被改动过(或已被删除)。重新读一次再改,避免覆盖别人的修改。 | | FS_NOT_OBSERVED | 本次会话没有这个文件的编码记录,因此不能重写它:已存在的文件若本会话没读过(或记录已被回收),write 会拒绝而不是按 UTF-8 重写。先 read 一次再写;若它不是文本文件,本插件无法重写它。新建文件不受影响。 | | FS_EDIT_NOT_FOUND | 没找到要替换的 old_string,确认内容与缩进是否一致。 | | FS_AMBIGUOUS_EDIT | old_string 匹配到多处。补足上下文让它唯一,或加 replace_all: true 全部替换。 | | FS_SANDBOX_DENIED | 被 DSH 沙箱拦下(如写入工作区外的路径),属于原有安全策略。 |

兼容性与冲突

  • 零侵入:插件只使用 DSH 的公开接口注册工具,不修改 DSH 原生代码,也不替换 ctx.fs 文件系统本身;卸载后原生工具立即恢复,不留痕迹。
  • 原有保障全部保留:沙箱围栏、读后写保护、版本校验、观察记录等原生行为一项不少——写入前该拦的照样拦,该问的照样问。
  • 与任何注册同名工具的插件互斥:本插件在作用域层注册 read / write / edit / insert / str_replace_editor / undo_last_edit,而同一层重复注册同名工具会直接报错。判定依据是这些名字在该 agent 自己的层上是否已被占用,与对方是谁无关——发现冲突即拒绝安装并指出被占用的工具名(见上方安装说明)。宿主/preset 层的内建工具不属于冲突。
  • 会话状态:编码信息保存在内存中、按会话隔离,不写入磁盘、不污染仓库。DSH 重启后首次读取会重新识别编码。
  • 对外提供服务:插件安装后会提供 fsEncoding 服务,供其他插件复用同一套编码判定(见下方给插件开发者)。

给插件开发者

DSH 的 ctx.fs 只认 UTF-8:它用严格的 TextDecoder 解码,所以一个 GBK 文件在它看来是 FS_NOT_TEXT。本插件的工具层解决了模型读文件的问题,但其他插件直接调 ctx.fs 取内容时仍然会撞上这个限制,于是要么对一个内容完全可以读的文件报错,要么自己再实现一套猜测——同一份部署的两处对同一个文件给出不同解读。

本插件因此提供一个服务,把「这些字节是什么文本」这件事收在一处:

// 消费方:用 ctx.get 读取,无需 inject,插件没装时返回 undefined
const fsEncoding = ctx.get("fsEncoding");
// 注意:名字被别的插件占用时,ctx.get 返回的是那个插件的对象而不是 undefined,
// 所以判"有没有"要按能力判,不能只判 undefined。
// isFsEncodingService 校验你点名的方法(不点名就是 tryDecode + decode 两个)——
// 手写守卫时同样的规则:只查 tryDecode 会放过"只有 tryDecode"的异物,
// 于是后面调 decode 时抛 TypeError,守卫就白写了
if (!isFsEncodingService(fsEncoding)) {
  // 插件未安装(或服务名被占用):按原来的方式处理(通常是显示"不是 UTF-8")
}

// 自己负责读盘(沙箱、路径解析都是调用方的上下文)
const target = await ctx.fs.resolve(path, { cwd });
// 三个参数都是必需的:maxBytes 是 readBytes 唯一的上限,漏传不会报错,
// 而是把整个文件无界读进内存。signal 与 maxBytes 都取自你自己的执行上下文
// (例如 ctx.fsEncoding 之外,你手里应当已有 exec 与一个自定的上限)
const bytes = await ctx.fs.readBytes(target, exec?.signal, maxBytes);

const outcome = await fsEncoding.tryDecode(bytes, { displayPath: path });
if (outcome.ok) {
  outcome.result.text;      // 解码后的文本(BOM 已去掉)
  outcome.result.encoding;  // 例如 "gbk"
  outcome.result.decided;   // "bom" | "utf8" | "hint" | "guessed"
  outcome.result.hasBOM;
  outcome.result.lineEnding;
} else {
  outcome.refusal.message;    // 可直接展示的说明(与 read 工具的措辞一致)
  outcome.refusal.candidates; // 候选编码,可供用户挑选
  outcome.refusal.ranked;     // 候选是否按证据排序(false 表示顺序无意义)
  outcome.refusal.adoptable;  // 首位是否可信到可以采用
  outcome.refusal.autoGuessEnabled; // 是"没尝试猜"还是"猜了但没结果"
}

三个设计要点:

  • decided 是必须看的字段。 只有 "bom" / "utf8" / "hint" 是确定的,"guessed" 是概率性猜测。面向人展示内容时应当说明这一点——把一个猜测当成文件真实编码展示,正是本插件要消除的问题。
  • 拒绝是返回值,不是异常。 tryDecode 对任意输入都不会抛错(包括 encoding 传了非字符串这类参数错误,会作为拒绝返回);需要与 read 工具完全一致的报错文本时用 decode。
  • 服务不读文件、不写记录。 读盘由调用方负责;解码不写入会话的编码记录,所以预览不会替你"读过"某个文件、进而授权后续写入。(它可以读记录,见下一节——读记录与写记录是两件事。)

与工具保持一致:recordedEncoding

上面那套是"这些字节是什么文本"。但如果你的消费者要和工具对齐,光靠解码字节是不够的:edit、insert、str_replace_editor 没有 encoding 参数,它们的编码来自本插件的内部记录。你按候选猜一个,可能落在与工具不同的页上,于是你描述的文本与工具实际改的完全不是同一份(实测:记录为 big5 时工具按 big5 解,而候选首位 gbk 解出的是另一份文档)。

// sessionId 必须是你自己的执行所属会话:工具写盘用的记录就挂在这个会话上。
// 拿不到会话(传 undefined 或空串)读的是"无 agent"匿名桶,与真实会话互不可见,
// 于是这里必然返回 undefined——不要用它当基准,宁可什么都不显示。
const sessionId = exec?.agent?.session?.id;

const info = await ctx.fs.stat(target);
if (info === undefined) {
  // 文件不存在:没有可对齐的基准
} else {
  const rec = fsEncoding.recordedEncoding(sessionId, target, info.version);
  if (rec) {
    // rec.encoding 就是工具将要使用的编码
    const bytes = await ctx.fs.readBytes(target, exec?.signal, maxBytes);
    // 用 tryDecode:拒绝是返回值,不是异常。
    // 注意 maxBytes 是你给 readBytes 的**内存保护上限**,与服务的 maxFileBytes
    // (默认 10 MiB,属部署策略)是两回事——所以「你读得进来」不等于「服务肯解码」:
    // 10–64 MiB 的文件会以 E_TOO_LARGE 拒绝,必须按 refusal 处理,而不是让它抛出来。
    // (确有必要时可以用 maxBytes 提高单次解码上限,但那是在覆盖部署策略,
    // 别拿自己的内存保护上限去顶替它——取小了会把服务本来能解码的文件提前拦掉。)
    const out = await fsEncoding.tryDecode(bytes, {
      encoding: rec.encoding,
      displayPath: path,
    });
    if (out.ok) {
      out.result.text;         // 与工具同基准的文本
      rec.decided;             // 真实来源,见下
      rec.hasBOM;              // 保存时会还原的 BOM
      rec.lineEnding;          // 保存时会还原的行尾
    } else {
      // out.refusal.code 为 E_TOO_LARGE 时表示超过了服务的 maxFileBytes
      out.refusal.message;     // 可直接展示的说明
    }
  } else {
    // 该会话没有可用记录:退回按字节解码(并如实标注为猜测)
  }
}
  • rec.decided 必须用返回值,不能自己再解一次。 你带着 rec.encoding 去调 decode,服务会回答 "hint"("调用方已指定")——那是你的决定,不是文件的真实来源。记录里是猜测的文件,这样会被呈现成确定,正是本插件要消除的那个失败。
  • 能力要按需判:isFsEncodingService(fsEncoding, "recordedEncoding")。 不传方法名时它校验 tryDecode + decode(历史行为);把你要调的方法名列出来,它只回答那一件事。必须列出来:服务名先注册者胜出,ctx.get 可能给你一个更早挂载的旧实例(本方法在 1.4.0 才加入,更早的版本没有),不列就会「守卫通过、调用却抛 TypeError」。反过来也别图省事去查一堆用不到的方法——那会把一个解码入口完好的实例判成不可用。这个判断是布尔值,所以你能区分「实例太旧、没这个能力」与「这个会话确实没读过该文件」(后者是 recordedEncoding 返回 undefined),前者应当告警而不是静默降级。不能 import 本包的消费者手写等价判定:typeof fsEncoding?.recordedEncoding === "function"。问号不能省:插件没装时 ctx.get 返回 undefined,少了它守卫本身就抛 TypeError——正是这个守卫要防的事(isFsEncodingService 对 undefined 返回 false 而不是抛错)。
  • sessionId 必须是你自己的执行所属会话,且必传。 工具写盘用的记录挂在调用会话上(exec.agent.session.id);传 undefined/空串读的是"无 agent"匿名桶,与真实会话互不可见,于是必然返回 undefined。查不到时不要拿别的会话或猜测顶上——宁可什么都不显示。
  • stale 判定交给方法,不要自己比版本。 传入你刚 stat 到的 version,记录若已过期就返回 undefined。省略该参数不是"跳过判定",而是与写盘路径对"没有版本"的读法一致(invalidateIfStale(…, undefined) 会删掉带版本的记录):带版本的记录会被判为不可用。拿不到版本时(stat 没返回东西)如实当"无法确认",别把记录当新鲜用。确实想看"记录本身、不管新鲜与否"(比如展示历史)才显式传 null——只有这一种写法会跳过判定,于是"仅仅拿不到版本"不会意外走到这里。版本号是内部记账细节,方法替你判,两边用同一个比较函数,不会出现"预览认为记录有效、工具认为失效"的分歧。
  • undefined 把"从没记录"与"记录已失效"合并了——两者的应对相同(退回解码并标注为猜测),区分它们就得暴露版本号,而那正是这个方法要挡在契约外的内部细节。
  • 可以跨会话查询。 插件是宿主平面注册的,一个进程服务多个会话;传别的 sessionId 能读到那个会话的记录。这不会影响写入——写盘用哪个编码只由调用方自己的会话决定,读到别人的记录最多影响显示。undefined 表示无会话调用方,它有自己的桶,与任何真实会话互不可见。
  • 它不会替你写记录,也不会让写盘变得可能。 一条记录既决定写盘字节、又是写盘闸的判据,所以能创建记录的方法等于能授权一次会话从未读过的写入。本方法只读:不改状态、不发事件、不 arm 闸门,可以随便调。

其他方法:isUtf8(bytes)(廉价判断这些字节是不是合法 UTF-8——注意这不等于 ctx.fs.readText 一定成功,后者还会把前 8 KiB 含 NUL 的内容判为二进制)、supportedEncodings()(本部署实际会尝试的集合,做选择器时用它,而不是自己列一份)、knownEncodings()(全部可用的编码名)、autoGuessEnabled()、recordedEncoding(sessionId, target, currentVersion?)(见上)、isFsEncodingService(value, ...required?)(按能力判断 ctx.get 拿到的是不是本服务;required 写你要调的方法名,省略即 tryDecode + decode)。

tryDecode / decode 接受 maxBytes 选项限制单次解码的字节数,默认取本插件的 maxFileBytes;超限会以拒绝(E_TOO_LARGE)返回,而不是对超大输入做全量候选排序。注意没有「无上限」的写法:省略 maxBytes 表示用本部署的 maxFileBytes,而 Infinity / NaN / 非正数这类不可用的值会以 E_BAD_ENCODING 拒绝,不会被静默当成默认值(否则错误信息会建议你调大一个刚被忽略的参数)。

tryDecode 对任意输入都不抛错,参数错误一律作为拒绝返回:encoding / displayPath 传了非字符串、bytes 不是 Uint8Array、maxBytes 不可用,都会拒绝。

opts 本身传错也会拒绝,而不是被当成"没传"。 只有整个参数省略(或传 undefined)才表示用默认值;null、字符串、数字等一律拒绝:

await fsEncoding.tryDecode(bytes);                          // 默认值
await fsEncoding.tryDecode(bytes, {});                      // 默认值
await fsEncoding.tryDecode(bytes, { encoding: "gbk" });     // 指定编码

await fsEncoding.tryDecode(bytes, "gbk");   // 拒绝:少写了一对花括号
await fsEncoding.tryDecode(bytes, null);    // 拒绝:null 不算"没传"

这一条是刻意的:在 JavaScript 里读一个非对象的属性不会报错,只会得到 undefined——和"没传"完全一样。所以 tryDecode(bytes, "gbk") 这种漏写花括号的写法,曾经会静默丢掉你指定的编码,退回自动猜测,解码结果可能和你要的完全不同却毫无提示。null 同样拒绝,因为它是 JSON 和数据库里"缺值"的样子,放行等于把调用方的 bug 藏起来。

开发

npm install
npm run typecheck   # 对 src 与 test 跑 tsc --noEmit
npm test            # 运行测试套件
npm run build       # src/ → lib/

测试既覆盖编码算法本身,也用真实的文件系统与沙箱组件做集成验证,包括全部编码的字节级往返、BOM / CRLF 保真、不可映射字符拒绝、陈旧写入拒绝与沙箱围栏。

如需自定义插件功能或修改插件,直接使用 DSH 的 Creator mode 即可快速进行开发修改。

致谢

本插件的编码处理层最初从 dsh-better-edit 中抽出——BOM 保真、多字节编码的读写往返等基础能力源自那里。独立成插件后,围绕「读到的字节与写回的字节一致」这一目标重写并扩展:Windows ANSI 全系、可配置的猜测清单、候选排序的证据判定、ctx.fsEncoding 服务等。感谢该项目打下的基础。

两者都在作用域层注册 read / write / edit,因此不能同时启用(原因见上方兼容性与冲突)。

许可

MIT