Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

插件

drsg digest 遍历代码仓库时,不会让模型去猜测代码的结构,而是把每个源码文件分发给 一个预处理插件去处理。插件是一个沙箱化的 WebAssembly 组件,内部跑的是编译器级 的解析器,返回的是事实:解析器确信无疑的节点与边。AST 不需要推断 parse() 调用了 lex(),它本来就知道。

插件位于独立的仓库 dr-strange-extension。 与数据库分开是有意为之:官方身份并不意味着步调必须锁死,解析器修一个缺陷不必等 数据库发版,数据库发版也不必等十条工具链都跟上。这个仓库就是扩展的公共地:官方 插件、契约的规范副本,以及编写自己插件所需的 SDK(后者是编码智能体 一章后半部分的主题)。

一次 digest 如何使用插件

路由器按扩展名分配文件:每个已安装的插件声明自己处理的扩展名集合(.rs、.py、 .html……),没有插件认领的文件回落到内置的文档读取器,作为散文交给模型。被认领 的文件如何处理由插件决定,分两个阶段:

  1. parse——宿主把分派的文件切成固定大小的块,对它们并行运行 parse, 每次调用一个全新的沙箱实例,彼此不共享任何状态。每次调用返回一个部分结果 (partial):宿主只负责搬运、从不读取的一段不透明字节。
  2. 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.rssyn
go.goGo 自带的 go/parser,经 TinyGo 编译
ts.ts .tsx .mts .cts .js .jsx .mjs .cjsswc——同时支持 ESM 与 CommonJS
py.py .pyi .pywruff 的解析器
java.javatree-sitter-java
kotlin.kt .ktstree-sitter-kotlin-ng
csharp.cstree-sitter-c-sharp
c.c .htree-sitter-c
web.html .htm .csstree-sitter html/css/js——一个插件同时处理,class="btn" 才能绑定到定义 .btn 的样式表
toml.tomltoml
xml.xmlquick-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:记录"这段代码使用了 那个东西",而不假装读过从未见过的代码。

这些承诺正是编码智能体一章的立足点,也是社区插件应当保持 的,那一章的后半部分会逐步演示。