Plugins
When drsg digest walks a repository, it does not ask a model to guess at the
code’s structure. Each source file is routed to a preprocessor plugin — a
sandboxed WebAssembly component that parses it with a compiler-grade parser and
returns facts: nodes and edges the parser is certain of. An AST does not
infer that parse() calls lex(); it knows.
The plugins live in their own repository, dr-strange-extension, apart from the database on purpose: official does not mean lock-step. A parser ships a fix without waiting for a database release, and the database releases without waiting for ten toolchains. That repository is the extension commons — the official plugins, the canonical contract, and the SDKs for writing your own (the subject of the Coding Agent chapter’s second half).
How a digest run uses plugins
The router assigns files by extension: every installed plugin claims a set
(.rs, .py, .html …), and any file no plugin claims falls back to the
built-in document reader and goes to the model as prose. What the claimed files
become is up to the plugin, in two phases:
parse— the host splits the routed files into fixed-size chunks and runsparseover them in parallel, one fresh sandbox instance per call, sharing nothing. Each call returns a partial: opaque bytes the host shuttles but never reads.assemble— called once, with every partial in chunk order. This is where cross-file resolution lives — imports, headers, barrel re-exports, interface satisfaction — inside the plugin, because it is language semantics and the database refuses to hold any. The result must not depend on where the chunk boundaries fell.
Input arrives by pull, not push. A plugin is handed file paths, and reads
what it needs through the host — which is how a code parser follows an import
into a neighbouring file. A format with no cross-file structure can treat
assemble as concatenation; the SDKs provide exactly that as a default.
The contract
The plugin ↔ host contract is one small WIT
world, drsg:preprocess@1.0.0, canonical at the root of the extensions
repository and vendored by drsg (just check-wit fails when any copy drifts):
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
}
The three host functions are the capability grant: a plugin can reach
exactly what is written there and nothing else. %list returns readable paths
under the digested root, sorted — unsorted directory order would vary the
output between runs, and re-ingesting a tree is meant to yield the same graph.
read returns one file’s bytes, refusing any path that resolves outside the
root (the check is on the resolved path, so .. and symlinks do not walk
through it) — and any path inside it that %list would not have named: the
same .gitignore, dotfile and build-directory rules apply to both, so a
plugin cannot read a .env or an ignored credentials file just by asking for
it. label names the input when its contents cannot.
A plugin’s output is nodes, edges, prose, and a report — counts of facts,
prose characters, and skipped inputs, plus notes in words for whatever could
not be resolved. Counted and named rather than dropped silently: a thin graph
should be explained by its report, not investigated by re-running the ingest.
options carries the plugin’s own settings from the operator’s
[plugins.<name>] config section, passed through uninterpreted.
No LLM in a plugin — the rule
A plugin never calls a language model. Not the official ones, not a
third-party one: the rule applies to every plugin, and it is enforced by the
sandbox rather than requested of authors — no network (wasi:sockets is
refused at load, by name), no environment, no filesystem beyond the three host
functions, so there is no way to reach a provider or the key a call would
need.
The division of labour is the point. A plugin’s job is what a parser can
prove, deterministically; whatever genuinely needs a model — a doc comment’s
meaning, a README, anything the parser cannot claim as fact — is returned as
prose, and the host’s digest pipeline decides whether a model reads it,
under the operator’s keys, budgets, and --mode. A repository that yields
only facts is digested with no model call at all.
The same line is kept visible in the graph itself:
- A parsed fact carries
_generated_byinstead of_model, so it is always distinguishable from a model’s extraction. Where both claim one key, the fact wins and the model’s claim is dropped and counted. The stamp names the plugin, its version, and the artifact that produced the fact —rust@3+fdb01bb6, the suffix being the leading bytes of the wasm’s pinned SHA-256 — so two facts made by the same version of a plugin from different bytes never read as one. - Determinism is part of the contract, not an aspiration. The sandbox freezes the clock, deals entropy from a fixed sequence, and sorts directory listings — so digesting the same tree twice yields byte-identical facts. A model call inside a plugin would break exactly this.
The sandbox
Every plugin is a wasm32-wasip2 component running under a deny-everything
grant. A guest runtime may import wasi:filesystem — Go’s does, before the
plugin’s first line runs — but the preopen table behind it is empty;
wasi:sockets is refused at load, by name; clocks are frozen; entropy is
fixed; and each call runs under instruction and memory budgets, with a
wall-clock deadline as the net beneath them and a process-wide memory budget
across the calls running at once. A trapped
guest’s stderr is captured into the error the operator sees, along with the
trap code itself. Whatever a plugin produces comes back as a return value —
only the host writes to the database.
A parse call handles one file, so a plugin that trips over one is one
file’s worth of loss: it is skipped and counted, and the report names it.
Generated source is the usual culprit — a .pb.go whose descriptor blob is a
thousand-term string concatenation will walk a recursive printer straight off
the stack the plugin was linked with, which is the plugin author’s to fix and
no host setting can raise. A plugin that fails on every file it claimed is a
different matter, and still refuses the run.
assemble carries no such bound, and the difference is worth knowing. It
runs once, over every partial at once, so a trap there is not one file’s loss:
it fails the plugin outright, and with it the routing pass folding the tree.
One file the parser chokes on, in a language that is a rounding error in the
repository, can therefore empty the plane — and under drsg init --rebuild, which drops
the plane before folding it again, that is destructive rather than a no-op.
Until the host isolates a trapping plugin from the rest of the route, read an
assemble failure as an outage rather than a gap in the report: the operator’s
recourse is to reinstall the plugin’s previous version while the parser is
fixed.
The budgets are tunable in drsg.toml
(Chapter 2):
[plugins]
fuel = 200000000000 # instructions per sandbox call (0 disables the check)
memory_mb = 3072 # linear memory per call, MiB (wasm32 itself allows at most 4096)
deadline_secs = 300 # wall-clock ceiling per call, seconds (0 disables it)
total_memory_mb = 6144 # linear memory all calls in the process may hold together (0 = twice memory_mb)
hostcall_mb = 1024 # what one call may copy out of the sandbox, MiB (0 = wasmtime's 128)
[plugins.rust] # a plugin's own settings pass through untouched
include_source = true
deadline_secs is the wall-clock ceiling on one call (default 300; 0
disables it) — not a budget for work, which fuel is and deterministically so,
but what stops a plugin that never returns once fuel has been switched off.
total_memory_mb is what every call in the process may hold together
(default twice memory_mb): parse runs one call per core, and a per-call
ceiling alone would let a wide machine be filled a store at a time. Both can
be overridden from the shell for one run — DRSG_PLUGINS_DEADLINE_SECS and
DRSG_PLUGINS_TOTAL_MEMORY_MB, same units and same reading of 0 — and the
environment wins over the file, as with every other DRSG_* variable. A
refusal names the ceiling it hit, so the operator knows which knob it was. Turning fuel off
also switches the sandbox to compiling each plugin from its wasm on every
load, since the precompiled artifacts carry the fuel instrumentation; the
first load says so once.
hostcall_mb bounds what one call may copy out of the sandbox — a host
function’s arguments, or an export’s return value. Leave it out (or set 0)
and wasmtime’s own 128 MiB applies. parse returns one file’s partial and stays
far below it; assemble returns the whole tree in one value, so a large
repository is the first to pass it, and the error then names this setting:
plugin `go` copied more out of the sandbox in one call than the 128 MiB limit allows. Raise it in drsg.toml:
[plugins]
hostcall_mb = 1024
There is no environment variable for it.
One boundary follows from the pull model: preprocessing runs where the files
are. The CLI and the stdio MCP server route through it; bytes sent to a shared
drsg serve over the wire stay prose. The deliberate exception is
serve watch, where the operator points the server at a repository on its own
machine — an explicit filesystem grant — so commit folds run through the
installed plugins.
The official catalog
Thirteen official plugins cover the common languages and two cross-cutting sources, each wrapping a mature parser rather than reinventing one:
| Plugin | Claims | Parser underneath |
|---|---|---|
rust | .rs | syn |
go | .go | Go’s own go/parser, compiled with TinyGo |
ts | .ts .tsx .mts .cts .js .jsx .mjs .cjs | swc — ESM and CommonJS |
py | .py .pyi .pyw | ruff’s parser |
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 — one plugin, so class="btn" binds to the stylesheet defining .btn |
toml | .toml | toml |
xml | .xml | quick-xml — by the dialect the file declares |
git | (a git checkout) | git’s own object database, read directly — flate2 for the zlib streams. Dispatched by the source being a checkout rather than by extension, and writes the history plane beside the code one |
deps | (manifests, by name) | toml_edit and quick-xml: package.json, go.mod, pyproject.toml, requirements.txt, pom.xml. A manifest is a filename, not an extension — Cargo.toml stays with the toml plugin |
Each releases at its own pace as a <plugin>-vX.Y.Z tag; CI builds the
component and publishes <plugin>.wasm with its SHA-256 on the
releases page.
The list of those releases — catalog.json — lives in the extensions
repository beside the plugins, not in this binary. That is the point: a
catalog compiled into drsg would make every plugin release a drsg release, for
a fact drsg only repeats. So drsg plugin install fetches it, and a plugin’s
name is enough to install it:
$ drsg plugin install rust # a name from the catalog
$ drsg plugin install # or the interactive list, 0 = all
$ drsg plugin list --available # the catalog, tagged against this store
Fetching the list does not mean trusting it blindly. Each entry pins the
artifact’s SHA-256, checked on download before the bytes are even looked at as
a component; and each entry says which host it is for — contract, the WIT
world it was built against, and min_drsg, the oldest host it claims to work
with. An entry this build cannot honour is listed with the reason, not
hidden: a plugin that silently vanishes from the list is a support question,
while “needs drsg >= 3.0.0” is an answer. Several entries may share a name,
which is how a plugin keeps serving older hosts — each host installs the
newest entry it can run.
Every successful fetch is cached beside the installed plugins, so an offline
drsg plugin install still shows the list and says how old it is. With no
cache and no network it fails naming the URL, because a plugin can still be
installed without a catalog at all — a path or a URL needs no list.
Behind a proxy, or with no network at all
drsg plugin install honours ALL_PROXY, HTTPS_PROXY and HTTP_PROXY (also
read lowercase), or [network] proxy in drsg.toml; see Getting
Started. socks5:// works
as well as http://.
When the machine that runs drsg has no route out at all, install from a file. The catalog is only a lookup table, and nothing about the artifact’s integrity depends on having fetched it — the SHA-256 is checked either way:
# On a machine that can reach GitHub — the catalog names both the artifact
# and its hash, so read them from it rather than guessing a release path.
# A name may have several entries, one per host generation; pick the one
# your drsg can run (`drsg plugin list --available` prints that reasoning).
$ 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
# Carry ts.wasm across, then on the offline machine:
$ drsg plugin install ./ts.wasm
Installing from a path pins that file’s SHA-256 in the store exactly as a catalog install would, so every later load re-checks it. Verify the checksum yourself before installing: an install from a path cannot compare against a catalog entry it never fetched, so that step is the one the catalog would otherwise have done for you.
Install pins the artifact’s SHA-256 in the store; every later load re-checks it, so a file that changes on disk is refused rather than silently run. Installing a name again is the upgrade path.
A plugin installed after the graph was built
Installing a plugin does not reach back into a graph that already exists. A watched repository folds one commit at a time, so a new parser applies to the files the next commits touch and every untouched file keeps whatever the old set made of it — half the tree parsed one way, half the other. Nothing is corrupt; the graph is simply two parses.
So a plane records the plugin builds installed when it was built, and says so when they no longer match:
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
That line is on every answer read from the plane, including “no symbol matches” — the answer where it matters most, because an absence may mean the symbol is not there or that its file was never parsed.
The comparison is on the full identity, name@version+build, so a plugin rebuilt
at the same version counts as a change: the same version built differently can
parse differently. Both directions are reported — added since for a parser
whose files were never read, no longer installed for facts in the graph that
came from something the store no longer has.
The cure is a rebuild, which re-reads the whole tree:
$ drsg init --rebuild
An agent that reads the warning can do it without leaving the conversation, with
the rebuild MCP tool — it takes only a plane name and reads that plane’s own
recorded directory, so it cannot be aimed somewhere else. Either way the warning
clears itself once the graph is one parse again.
A plane built before drsg recorded this says nothing: unknown is not drifted, and warning about every older plane would teach readers to ignore the warning.
Install also compiles the plugin once and keeps the compiled form beside the
wasm, pinned by a hash of its own. A load — every digest, every commit
serve watch folds — deserializes that in milliseconds instead of compiling,
which is seconds of CPU and hundreds of MiB for the official parsers. The
compiled form belongs to the drsg build that made it: after drsg update, or
in a store from before it existed, the first load compiles as before and
stores the result for the next one.
What every official parser promises
The ten parsers are one family, built to one discipline:
- Keys are the language’s own qualified names —
crate::module::fn,pkg.Type.Method,file.c::func,index.html#map— never invented ids. - Everything carries its location: a definition its
fileandline, an edge the line it is written on. - Resolved edges say how they were resolved: each carries
_resolved_by(which rule matched),_confidence(a band, not a decimal), and_ref(the text as written at the call site). - What cannot be known is counted, never guessed. A call whose receiver
type the source does not declare becomes an
UnresolvedRefledger entry with a_reason— queryable in the graph and rendered incontext— rather than a plausible edge. A wrong edge sends an agent somewhere wrong; a missing one, honestly declared, sends it togrep. - External things are stand-ins, keyed by the path as written and labeled
External: “this code uses that” is recorded without pretending to have read code it never saw.
Those promises are what the Coding Agent chapter builds on — and what a community plugin should keep, which that chapter’s second half walks through.