插件
drsg digest 遍历代码仓库时,不会让模型去猜测代码的结构,而是把每个源码文件分发给
一个预处理插件去处理。插件是一个沙箱化的 WebAssembly 组件,内部跑的是编译器级
的解析器,返回的是事实:解析器确信无疑的节点与边。AST 不需要推断 parse()
调用了 lex(),它本来就知道。
插件位于独立的仓库 dr-strange-extension。 与数据库分开是有意为之:官方身份并不意味着步调必须锁死,解析器修一个缺陷不必等 数据库发版,数据库发版也不必等十条工具链都跟上。这个仓库就是扩展的公共地:官方 插件、契约的规范副本,以及编写自己插件所需的 SDK(后者是编码智能体 一章后半部分的主题)。
一次 digest 如何使用插件
路由器按扩展名分配文件:每个已安装的插件声明自己处理的扩展名集合(.rs、.py、
.html……),没有插件认领的文件回落到内置的文档读取器,作为散文交给模型。被认领
的文件如何处理由插件决定,分两个阶段:
parse——宿主把分派的文件切成固定大小的块,对它们并行运行parse, 每次调用一个全新的沙箱实例,彼此不共享任何状态。每次调用返回一个部分结果 (partial):宿主只负责搬运、从不读取的一段不透明字节。assemble——只调用一次,携带按块序排列的全部部分结果。跨文件解析 (导入、头文件、桶式再导出、接口满足)就发生在这里,在插件内部,因为这是语言 语义,而数据库拒绝持有任何语言语义。结果不得依赖块边界落在何处。
输入是拉取而非推送。插件拿到的是文件路径,需要什么就通过宿主读取,代码
解析器正是这样跟随一条导入进入相邻文件的。没有跨文件结构的格式可以把 assemble
当作拼接;SDK 恰好提供了这样的默认实现。
契约
插件与宿主之间的契约是一个小小的 WIT
world:drsg:preprocess@1.0.0,规范副本位于扩展仓库根目录,drsg 侧存有受校验的
副本(任何副本漂移都会让 just check-wit 失败):
interface host {
%list: func(suffix: string) -> result<list<string>, string>;
read: func(path: string) -> result<list<u8>, string>;
label: func() -> option<string>;
}
interface preprocessor {
describe: func() -> manifest; // name, version, extensions
parse: func(subject: input, options: list<tuple<string, string>>)
-> result<list<u8>, string>; // one chunk → an opaque partial
assemble: func(partials: list<list<u8>>, options: list<tuple<string, string>>)
-> result<output, string>; // all partials, in order → facts
}
这三个 host 函数就是能力授予的全部:插件能触及的只有写在这里的东西。
%list 返回被消化根目录下可读的路径,且已排序:未排序的目录顺序会让两次运行
的输出不同,而重新导入同一棵树本应得到同一张图。read 返回单个文件的字节,任何
解析后越出根目录的路径都会被拒绝(检查落在解析后的路径上,.. 与符号链接都绕不
过去);根目录之内、但 %list 不会列出的路径同样被拒绝——.gitignore、隐藏文件与
构建目录的规则对两者一视同仁,所以插件不能靠直接点名去读 .env 或被忽略的凭据
文件。label 在输入内容无法自述名字时提供一个名字。
插件的 output 包含节点、边、散文,以及一份报告:事实数、散文字符数、跳过
的输入数,外加一段说明什么没能解析、为什么没能解析的文字注记。计数并点名,而不是悄悄丢弃:
一张偏薄的图应当由它的报告来解释,而不是靠换参数重跑导入来排查。options 携带
操作者配置中 [plugins.<name>] 一节的插件自有设置,原样透传。
插件不得调用 LLM——这是规则
插件永远不调用语言模型。官方插件不调用,第三方插件同样不调用:这条规则适用于每
一个插件,而且由沙箱强制执行,而非仅仅要求作者自觉。沙箱没有网络
(wasi:sockets 在加载时按名拒绝)、没有环境变量,除三个宿主函数外也没有文件
系统,因此插件根本无从触及任何模型服务,也拿不到调用所需的密钥。
分工正是意义所在。插件的职责是解析器能证明的东西,且是确定性的;真正需要模型的
内容(一段文档注释的含义、一份 README、任何解析器无法断言为事实的东西)则作为
散文返回,由宿主的 digest 流水线决定是否交给模型,在操作者的密钥、预算与
--mode 之下进行。只产出事实的代码仓库,导入全程不发起一次模型调用。
这条界线在图里同样保持可见:
- 解析得到的事实携带
_generated_by(如rust@3+fdb01bb6:插件名、版本,加上产出 这条事实的构件——后缀是该 wasm 已钉住的 SHA-256 的前几个字节,因此同一版本插件的 不同字节产出的事实不会被读成同一来源)而非_model,因此永远能与 模型的抽取区分开。当两者声称同一个键时,事实获胜,模型的主张被丢弃并计数。 - 确定性是契约的一部分,不是一个愿望。沙箱冻结时钟、按固定序列发放熵、对目录列表 排序,因此同一棵树消化两次,得到逐字节相同的事实。插件内部的模型调用恰恰会破坏 这一点。
沙箱
每个插件都是运行在全面拒绝授权之下的 wasm32-wasip2 组件。插件的语言运行时可以
导入 wasi:filesystem(Go 的运行时在插件第一行代码执行之前就会这么做),但其
背后的预打开表是空的,什么也读不到;wasi:sockets 在加载时按名拒绝;时钟被冻结;
熵是固定的;每次调用都在指令与内存预算之下运行。
插件在沙箱里崩溃时,它写到 stderr 的内容、以及陷入的原因(trap code)本身,都会 一并收进报给操作者的错误信息,方便定位问题。插件的全部产出都以返回值的形式交回 宿主,写数据库的永远只有宿主。
一次 parse 调用只处理一个文件,所以插件在某个文件上栽了跟头,损失也就是这
一个文件:它会被跳过并计数,报告里会点名。最常见的元凶是生成代码——.pb.go 里那个
由上千段字符串拼接而成的描述符,会让递归下降的打印器一路走出插件链接时定下的栈;
这是插件作者要修的,任何宿主配置都抬不高那个栈。但如果插件在它认领的每一个文件
上都失败,那就是另一回事了,这一趟仍然会被判定为失败。
assemble 没有这层边界,这个差别值得记住。 它一次性地在全部 partial 上运行
一遍,因此那里的一次 trap 不是一个文件的损失:它会让整个插件失败,连带正在折叠这
棵树的那趟分发一起失败。于是,一个在仓库中占比微不足道的语言里的单个文件,就足以
让平面变空——而在 drsg init --rebuild 下(它会先丢弃平面再重新折叠),这是破坏性
的,而非一次无操作。在宿主把出错插件与整趟分发隔离开之前,请把 assemble 的失败
读作一次故障,而不是报告里的一处缺口:操作者的应对是先装回该插件的上一个版本,等
解析器修好。
预算可在 drsg.toml 中调整(第 2 章):
[plugins]
fuel = 200000000000 # 每次沙箱调用的指令预算(0 为不设限)
memory_mb = 3072 # 每次调用的线性内存上限,MiB;按照 wasm32 标准,最高支持 4096
deadline_secs = 300 # 单次调用的墙钟上限,秒(0 为不设限)
total_memory_mb = 6144 # 进程内所有调用合计可持有的线性内存,MiB(0 为 memory_mb 的两倍)
hostcall_mb = 1024 # 单次调用最多能从沙箱拷出的数据量,MiB(0 为 wasmtime 默认的 128)
[plugins.rust] # 插件自有设置,原样透传
include_source = true
deadline_secs 是单次调用的墙钟上限(默认 300 秒,0 为不设限)——它不是工作量的预算
(那是 fuel 的职责,且是确定性的),而是关掉 fuel 之后兜住不返回插件的那张网。
total_memory_mb 是进程内所有调用合计可持有的线性内存(默认为 memory_mb 的两倍):
parse 每核一次调用,只有单次上限的话,多核机器会被一个个 store 逐渐填满。两者都可以在
shell 里临时覆盖——DRSG_PLUGINS_DEADLINE_SECS 与 DRSG_PLUGINS_TOTAL_MEMORY_MB,单位与
0 的含义相同——环境变量优先于配置文件,与其他 DRSG_* 变量一致。被拒绝时错误会点名撞到的是哪一个上限。
关掉 fuel 也意味着每次加载都要从 wasm 重新编译插件(预编译产物带有 fuel 插桩),首次加载会提示一次。
hostcall_mb 限制单次调用最多能从沙箱拷出多少数据,包括 host 函数的参数和导出函数的返回值。
不写或写 0 时沿用 wasmtime 自己的 128 MiB。parse 每次只返回一个文件的中间结果,远在上限之下;
assemble 一次返回整棵树,所以大仓库最先撞到它,这时报错会点名这个配置项:
plugin `go` copied more out of the sandbox in one call than the 128 MiB limit allows。
在 drsg.toml 里调大:
[plugins]
hostcall_mb = 1024
这一项没有对应的环境变量。
拉取模型还带来一条边界:预处理在文件所在之处运行。CLI 与 stdio MCP 服务会经过
预处理;经网络送达共享 drsg serve 的字节保持为散文。唯一的刻意例外是
serve watch:操作者把服务端指向其自己机器上的一个仓库(这是一次明确的文件系统
授权),于是提交折叠会经过已安装的插件。
官方目录
十三个官方插件覆盖常用语言与两类横切来源,每个都包装一个成熟的解析器而非重新发明:
| 插件 | 处理的扩展名 | 底层解析器 |
|---|---|---|
rust | .rs | syn |
go | .go | Go 自带的 go/parser,经 TinyGo 编译 |
ts | .ts .tsx .mts .cts .js .jsx .mjs .cjs | swc——同时支持 ESM 与 CommonJS |
py | .py .pyi .pyw | ruff 的解析器 |
java | .java | tree-sitter-java |
kotlin | .kt .kts | tree-sitter-kotlin-ng |
csharp | .cs | tree-sitter-c-sharp |
c | .c .h | tree-sitter-c |
web | .html .htm .css | tree-sitter html/css/js——一个插件同时处理,class="btn" 才能绑定到定义 .btn 的样式表 |
toml | .toml | toml |
xml | .xml | quick-xml——按文件自己声明的方言 |
git | (一个 git 检出目录) | 直接读取 git 自己的对象库,用 flate2 解 zlib 流。它按“来源是一个检出目录”分发,而非按扩展名;写出的历史平面就放在代码平面旁边 |
deps | (清单文件,按文件名) | toml_edit 与 quick-xml:package.json、go.mod、pyproject.toml、requirements.txt、pom.xml。清单是一个文件名而非扩展名——Cargo.toml 仍归 toml 插件 |
每个插件以 <plugin>-vX.Y.Z 标签按自己的节奏发布;CI 构建组件并在
发布页发布
<plugin>.wasm 与其 SHA-256。
这些 Release 的清单——catalog.json——与插件一同放在 extensions 仓库,而不在这个
二进制里。这正是要点:编译进 drsg 的目录会让每次插件发布都变成一次 drsg 发布,而
drsg 只是在重复一个既有事实。因此 drsg plugin install 去抓取它,一个插件名就足
以完成安装:
$ drsg plugin install rust # 目录中的一个名字
$ drsg plugin install # 或交互式列表,0 = 全部
$ drsg plugin list --available # 目录本身,并标注本地安装状态
抓取这份清单并不意味着盲目相信它。每个条目固定制品的 SHA-256,在下载后、把字节
当作组件看待之前就先校验;每个条目还说明自己面向哪种宿主——contract 是它构建时
依据的 WIT world,min_drsg 是它声称可用的最低宿主版本。本构建无法满足的条目会
连同原因一起列出,而不是被隐藏:一个从列表里悄悄消失的插件是一个待答的支持
问题,而「需要 drsg >= 3.0.0」本身就是答案。多个条目可以共用一个名字,这正是一个
插件继续服务旧宿主的方式——每个宿主安装它能运行的最新条目。
每次成功抓取都会缓存在已安装插件旁边,因此离线状态下的 drsg plugin install 仍
会列出清单并说明它有多旧。既无缓存又无网络时,它会报错并给出该 URL——因为不带目录
也一样能装插件:一个路径或一个 URL 不需要任何清单。
在代理之后,或完全没有网络
drsg plugin install 遵循 ALL_PROXY、HTTPS_PROXY 与 HTTP_PROXY(同时按小写
形式读取),也遵循 drsg.toml 中的 [network] proxy;参见
快速开始。socks5:// 与 http://
同样受支持。
当运行 drsg 的机器完全没有出网路径时,改从文件安装。目录只是一张查找表,制品的完整 性并不依赖于是否抓取过它——两种方式都会校验 SHA-256:
# 在一台能访问 GitHub 的机器上——目录同时给出制品地址与其哈希,
# 因此应当从目录里读取,而不是去猜测发布路径。
# 同一名字可能有多个条目,各对应一代宿主;挑选你的 drsg 能运行的那个
# (`drsg plugin list --available` 会打印这一判断过程)。
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange-extension/master/catalog.json \
| jq -r '.plugins[] | select(.name=="ts") | "\(.version)\t\(.url)\t\(.sha256)"'
2.0.0 https://github.com/…/ts.wasm 9f86d081…
$ curl -fLo ts.wasm 'https://github.com/…/ts.wasm'
$ echo '9f86d081… ts.wasm' | sha256sum -c -
ts.wasm: OK
# 把 ts.wasm 带过去,然后在离线机器上:
$ drsg plugin install ./ts.wasm
从路径安装同样会把该文件的 SHA-256 固定在插件库中,与从目录安装别无二致,之后每次 加载都会重新校验。请在安装之前自行核对校验和:从路径安装无法与一份从未抓取过的目录 条目作比对,因此这一步正是目录本来会替你完成的那一步。
安装时会在插件库中固定制品的 SHA-256,之后每次加载都重新校验,磁盘上被改动的文件 会被拒绝而不是被悄悄运行。对同名插件再次安装即是升级路径。
在图建好之后才装上的插件
安装插件不会回头改写一张已经存在的图。被监视的仓库是一个提交一个提交地折叠的, 因此新解析器只作用于此后各次提交所触及的文件,而未被触及的文件仍保留旧插件集的产 物——半棵树是这样解析的,另外半棵是那样。这并不是损坏,这张图只是两次解析的混合。
所以每个 plane 都会记录它建立时所安装的插件构建,并在两者不再一致时说出来:
synced: commit ec31cac58593
plugins changed since this plane was built (added since: ts@2+d99f6db4) — files
the difference claims are parsed only where a later commit touched them; rebuild
to make the graph whole
这一行会出现在从该 plane 读出的每一个回答里,包括「没有符号匹配」——而那正是它最要 紧的地方,因为一次「找不到」既可能意味着那个符号不存在,也可能意味着它所在的文件从 未被解析过。
比较用的是完整身份 name@version+build,因此同一版本重新构建过的插件也算作变化:
同一版本以不同方式构建,解析结果可以不同。两个方向都会报告——added since 指向其
所声明的文件从未被读过的解析器,no longer installed 指向图中那些来自插件库已不再
持有之物的事实。
解药是重建,它会重新读取整棵树:
$ drsg init --rebuild
读到这条警告的 agent 无需离开对话即可执行它,用 MCP 的 rebuild 工具——它只接受一个
plane 名字,并读取该 plane 自己记录的目录,因此无法被指向别处。无论用哪种方式,一旦
图重新成为一次完整的解析,警告都会自行消失。
在 drsg 开始记录这项信息之前建立的 plane 什么也不会说:未知并不等于已偏离,而对每一 张更早的 plane 都发出警告,只会教会读者忽略这条警告。
安装还会把插件编译一次,并将编译产物保存在 wasm 旁边,以它自己的哈希固定。此后
每次加载——每次 digest,serve watch 折叠的每个提交——都在毫秒内反序列化这份
产物,而不再重新编译;对官方解析器而言,编译意味着数秒 CPU 和数百 MiB 内存。
编译产物只属于生成它的那个 drsg 构建:drsg update 之后,或在它出现之前建立的
插件库中,第一次加载仍会像以前一样编译,并把结果保存下来供下一次使用。
每个官方解析器的承诺
十个解析器是一个家族,遵循同一条纪律:
- 键使用语言自己的全限定名:
crate::module::fn、pkg.Type.Method、file.c::func、index.html#map,绝不发明 id。 - 一切都携带位置:定义带
file与line,边带它书写所在的行号。 - 已解析的边说明自己如何被解析:每条边携带
_resolved_by(命中了哪条规则)、_confidence(一个档位,不是小数)与_ref(调用处的原文)。 - 无法确知的东西被计数,绝不猜测。 源码没有声明接收者类型的调用,会成为带
_reason的UnresolvedRef台账条目(可在图中查询、在context中呈现), 而不是一条貌似合理的边。错误的边把智能体引向错误的地方;诚实声明的缺失,把它 引向grep。 - 树外之物用替身表示,以书写原文作键、标记
External:记录"这段代码使用了 那个东西",而不假装读过从未见过的代码。
这些承诺正是编码智能体一章的立足点,也是社区插件应当保持 的,那一章的后半部分会逐步演示。