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

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:

  1. parse — the host splits the routed files into fixed-size chunks and runs parse over them in parallel, one fresh sandbox instance per call, sharing nothing. Each call returns a partial: opaque bytes the host shuttles but never reads.
  2. 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_by instead 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:

PluginClaimsParser underneath
rust.rssyn
go.goGo’s own go/parser, compiled with TinyGo
ts.ts .tsx .mts .cts .js .jsx .mjs .cjsswc — ESM and CommonJS
py.py .pyi .pywruff’s parser
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 — one plugin, so class="btn" binds to the stylesheet defining .btn
toml.tomltoml
xml.xmlquick-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 file and line, 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 UnresolvedRef ledger entry with a _reason — queryable in the graph and rendered in context — rather than a plausible edge. A wrong edge sends an agent somewhere wrong; a missing one, honestly declared, sends it to grep.
  • 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.