Introduction
🌐 English · 中文版
Dr Strange is an AI-native embedded graph database, written in Rust.
It is a graph database designed from the outset for AI workloads: embeddings are a first-class value type, similarity search operates alongside graph traversal, and the engine exposes primitives suited to agents — natural-language queries, a live change feed, and time-travel — rather than adding AI features to a conventional graph database after the fact.
Like SQLite, it is embedded: a library linked into an application, backed by
a single on-disk database, with no server to operate. Unlike SQLite, it can also
serve — drsg serve exposes a JSON-RPC 2.0 API, a browser dashboard, and a
WebSocket change feed, with client SDKs in six languages.
Intended audience
- Application developers constructing a knowledge graph, a GraphRAG pipeline, or an agent’s long-term memory, requiring graph and vector storage in a single system.
- AI and agent engineers who expect the database to perform more of the work: compiling a question into a query, pushing changes as they commit, and reconstructing historical state on demand.
- Readers interested in the construction of an AI-native store.
Organization
The early chapters establish the fundamentals: what Dr Strange is, how to build it, and what its AI-native design entails. The middle chapters are a reference for the query language and each access surface — the web UI, the language SDKs, the command line, and the MCP server for LLM agents. Two chapters then cover the code-intelligence half: the parser plugin system, and what it gives a coding agent. The final chapter documents the architecture.
Status. This book began alongside the v1.0 release and is kept current as features land.
What is Dr Strange?
Dr Strange is an AI-native embedded graph database, written in Rust. This sentence makes three claims, examined in turn below: it stores a graph, it is embedded, and it is AI-native — designed for AI workloads from the outset rather than retrofitted.
The data model: a property graph, in planes
Dr Strange stores a property graph:
- Nodes are the entities — a person, a document, a product. Every node has a
set of labels (its types, e.g.
Person,Doc) and a free-form map of properties. - Edges are directed, typed relationships between nodes —
(alice) -[:KNOWS]-> (bob). Edges carry properties too. - Properties are typed values: strings, numbers, booleans, byte blobs, lists, nested maps — and, crucially, vectors (embeddings), which are a first-class value type, not an afterthought.
Nodes can carry a caller-supplied external key (a stable, human-meaningful id, unique within its plane) alongside the engine’s internal numeric id.
Data is organized into planes — independent, named graph namespaces that
live in one database file. A plane is like a schema or a namespace: you might
keep one graph per project, per tenant, or per experiment. Ids are globally
unique across the whole database, but every query runs in the context of one
plane, so planes remain cleanly separated. A new database always contains a
startup plane.
The AI-native thesis
Most databases that add AI capabilities pair a conventional engine with a vector column and a call to an external model. Dr Strange inverts this arrangement: the capabilities an AI application requires are core engine features:
- Embeddings are first-class. A vector is a property value like any other, indexed natively with HNSW, so similarity search is fast and lives inside the query engine.
- Retrieval fuses signals. Vector similarity, BM25 keyword relevance, and graph proximity combine into a single ranked result — the core of the GraphRAG pattern.
- The engine talks to agents. Ask a question in plain language and get a query plan back, run it, and receive a connected subgraph. Subscribe to a plane and receive mutations as they commit. Read the graph as of any past point.
A concrete example: GraphRAG
Suppose you have ingested a corpus of documents as a graph — Doc nodes with
an embedding property, linked by CITES and MENTIONS edges to Concept
nodes. A GraphRAG lookup is a single query:
SEARCH (d:Doc) ON embedding NEAR "how does time-travel work" TOPK 5 RETURN d
This embeds the question, retrieves the five most similar documents, and — because the results are graph nodes — traversal continues directly from them, into the concepts they mention and the documents that cite them, assembling grounded context beyond the reach of a standalone vector store.
Where Dr Strange fits
- Like SQLite, it is embedded: a library and a single database on disk, with
no server to run. You can also start a server (
drsg serve) when you want a dashboard, an HTTP/WebSocket API, and multi-client access. - Unlike a classic graph database (e.g. Neo4j), vectors and hybrid retrieval are built into the core, and the surface is designed around agents — natural language, change feeds, time-travel.
- Unlike a pure vector database, the unit of storage is a graph: you get relationships, traversal, and graph algorithms, not just nearest neighbors.
For applications built around a knowledge graph, a GraphRAG pipeline, or an agent’s long-term memory, Dr Strange is intended to serve as the single store for all of it.
Feature tour
| Capability | What it gives you |
|---|---|
| Planes | Many independent graphs in one database |
| First-class embeddings | Vector properties, natively HNSW-indexed |
| Hybrid retrieval | Fused vector + keyword (BM25) + graph-proximity search |
| Query language | A serializable logical plan and an openCypher subset |
| Graph algorithms | PageRank, connected components, shortest path, Louvain |
| Natural-language query | Ask in plain language → plan → run |
| Time-travel | Read the graph as of a past commit or timestamp |
| Change feed | Subscribe to a plane and receive mutations live |
| Code digestion | Sandboxed wasm parser plugins turn a repository into a resolved call graph (8 official languages) |
| Commit-synced watch | serve watch folds every commit into the plane |
| Agent tools | context · search · describe · grep · trace · impact · fathom · snippet · history · recall · wire — one round trip each |
| Backup / restore | Consistent, id-faithful whole-database snapshots |
| Read-only replicas | serve --follow mirrors a running server for read-scaling across a cluster |
| Interfaces | Web UI, six language SDKs, a CLI, and an MCP server |
What v1.0 is
v1.0 is a complete, embedded, AI-native graph database with all of the above, one storage engine (a hand-rolled LSM, with a legacy alternative), and a stable JSON-RPC wire protocol that the SDKs are generated from. The chapters that follow show you how to use each part.
What v2.0 provides
v2.0 adds a repository’s own source tree as a second kind of graph. Sandboxed
wasm parser plugins turn source files into a resolved call graph — symbols,
callers with call sites, and every other structural edge — with no model in
the loop, covering eight languages out of the box. serve watch keeps the
resulting plane synced to the repository’s own commit history, folding each
commit’s changes in as they land. Twelve agent verbs — context, search,
describe, grep, trace, impact, fathom, snippet, history,
deployed, recall, wire — turn that graph into
single-round-trip answers for a coding agent, each one stating the bounds of
what it found: candidates instead of a guessed match, an UnresolvedRef
ledger entry instead of a silently wrong edge. drsg init bootstraps all of
it — digest, watch, and MCP configuration for whichever host tool is in use —
in one command. Plugins and Coding Agent
cover this ground in depth.
Reading in another language? 中文版见 Chinese edition。
Getting Started
This chapter covers installing Dr Strange — from a released binary or from source — initializing a database, issuing queries from the command line, and running the server, both locally and as a container image.
Prerequisites
Installing a released binary requires nothing beyond curl (or PowerShell on
Windows). The remaining prerequisites apply only to the other routes:
- Building from source: a current Rust toolchain (stable channel), installed via rustup.
- The web dashboard: bun to compile the single-page application, and optionally just as the task runner.
- The container workflow: Docker (Engine 24+; BuildKit is only needed if you build the image yourself).
The build links TLS through rustls/ring; no OpenSSL toolchain is required.
Installing a released binary
Every tagged release publishes binaries for Linux, macOS, and Windows. The
installer selects the archive matching the host platform, verifies its published
SHA-256, and places the binary on the PATH. Two binaries are available: the
command-line tool and server, drsg, and the MCP server for LLM agents,
drsg-mcp (Chapter 8).
Linux
# CLI and server — drsg
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh
# MCP server — drsg-mcp
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp
macOS — the same script; both Apple silicon and Intel are published.
# CLI and server — drsg
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh
# MCP server — drsg-mcp
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp
Windows, in PowerShell. The second form runs the script as a block because a
script piped into iex cannot receive arguments.
# CLI and server — drsg
PS> irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1 | iex
# MCP server — drsg-mcp
PS> & ([scriptblock]::Create((irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1))) -Bin drsg-mcp
Three options adjust the installation, each with an environment-variable equivalent for non-interactive use:
| Option (Windows) | Environment variable | Effect |
|---|---|---|
--bin drsg-mcp (-Bin) | DRSG_INSTALL_BIN | which binary — drsg (default), drsg-mcp, or all |
--version v1.1.0 (-Version) | DRSG_VERSION | pin a release instead of the latest |
--dir <path> (-Dir) | DRSG_INSTALL_DIR | destination directory |
The destination defaults to ~/.local/bin, and to
%LOCALAPPDATA%\Programs\drsg\bin on Windows, where the installer also adds the
directory to the user PATH. On Linux and macOS, add it to the shell profile if
it is not already present:
$ export PATH="$HOME/.local/bin:$PATH"
Upgrading
drsg update resolves the newest release the way the installer does — through
the redirect on releases/latest, rather than the API, which is rate-limited
for unauthenticated callers — and compares it with the running build. When
there is nothing to do it says so and stops:
$ drsg update
drsg 2.27.1 is the latest release — nothing to do
When there is, it prints the command it is about to run and then becomes it: the process is replaced by the same installer a first install runs, so the exit status is the installer’s own and there is no parent left waiting in a binary that has just been overwritten.
$ drsg update
drsg 2.27.0 -> 2.27.1
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/v2.27.1/scripts/install.sh | sh -s -- --bin 'drsg' --version 'v2.27.1' --dir '/home/me/.local/bin'
Dr Strange v2.27.1 (x86_64-unknown-linux-gnu)
downloading dr-strange-v2.27.1-x86_64-unknown-linux-gnu.tar.gz
checksum verified
installed /home/me/.local/bin/drsg
Both halves cross the network, and both honour a proxy: the version check is a
drsg request (see Working behind an HTTP
proxy), and the installer it hands over to is
curl, which reads the same variables. On a network that needs a proxy the
version check is what fails first, so an update that cannot reach GitHub
never reaches the installer at all.
The installer it runs is the one tagged with the release being installed,
not the copy on master, and the release is passed along as --version: the
script that verifies the archive is the script reviewed and shipped with it,
and moving a branch cannot change what an upgrade executes. The archive’s
.sha256 sidecar is required by both installers — a missing, malformed or
mismatching checksum is a hard failure, as is having no sha256sum/shasum
to check with. --insecure-skip-checksum (-InsecureSkipChecksum in
PowerShell; DRSG_INSECURE_SKIP_CHECKSUM=1 for either, and for drsg update)
installs anyway, with a warning: it exists for a mirror that publishes no
sidecar, and its name says what it costs.
The destination is the directory the running binary is in, not the installer’s
default — an upgrade must replace the copy on the PATH, not add a newer one
somewhere else and leave the old one being run. --dir overrides it for a
drsg installed somewhere unwritable. A drsg-mcp in that same directory is
updated alongside drsg without being asked — the two binaries are one
release, and an agent host launching last release’s server against this
release’s drsg would have nothing to tell it so. --bin names exactly what
to update when that is not wanted: drsg, drsg-mcp, or all.
A build newer than the latest release — from source, or from a branch ahead
of the last tag — is told it is ahead and nothing is installed: update never
moves backwards. On Windows nothing is run at all, because the running
executable is locked against being overwritten; the command to paste into a
fresh terminal is printed instead.
The archives and their checksums may also be downloaded directly from the
releases page; the
installers are a convenience over the same assets, and both scripts live in
scripts/ for
inspection before use.
Building from source
Dr Strange is a Cargo workspace. Compile the command-line binary, drsg:
$ cargo build --release -p drsg
The artifact is target/release/drsg. The default build selects the native LSM
storage engine; the legacy redb backend remains available behind a feature flag.
The web dashboard is embedded into the binary at compile time by the web crate’s build script. To embed the compiled dashboard rather than a placeholder page, build the single-page application before the binary:
$ just web-build # bun install && vite build
$ cargo build --release -p drsg
The MCP server for LLM agents (Chapter 8) is a separate binary,
drsg-mcp:
$ cargo build --release -p dr-strange-mcp
The artifact is target/release/drsg-mcp. Place it on the PATH, or reference
it by absolute path in the host configuration.
On-disk layout
The --db argument selects the database path. Under the native backend the
database is a directory — the write-ahead log and the sorted SST files reside
within it — accompanied by two sidecar files that hold the search indexes:
graph.drsg/ database (WAL + SST files)
graph.drsg.hnsw vector-index sidecar
graph.drsg.bm25 keyword-index sidecar
The database is created on first access; there is no separate initialization step.
Creating a graph
Create a plane, then insert data. The openCypher subset compiles to the same logical plan the engine executes directly:
$ drsg --db graph.drsg plane create social
$ drsg --db graph.drsg cypher --plane social \
'CREATE (a:Person {name:"Ada"}),
(b:Person {name:"Alan"}),
(a)-[:KNOWS]->(b)'
Query the result:
$ drsg --db graph.drsg cypher --plane social \
'MATCH (p:Person)-[:KNOWS]->(q:Person) RETURN q'
Inspect the resulting shape and soft schema:
$ drsg --db graph.drsg stats
$ drsg --db graph.drsg catalog --plane social
Vector index and similarity search
Vectors are ordinary property values. Declare an index on a (label, property)
pair, then query it. Embedding a text query is performed server-side and
therefore requires a provider key in the process environment (for example,
OPENAI_API_KEY); a query against a literal vector requires no provider.
$ drsg --db graph.drsg index ensure Doc embedding --plane social
$ OPENAI_API_KEY=… drsg --db graph.drsg cypher --plane social \
'SEARCH (d:Doc) ON embedding NEAR "a friendly greeting" TOPK 5 RETURN d'
Because the results are graph nodes, traversal can continue from them — the GraphRAG pattern introduced in Chapter 1.
Running the server
$ drsg --db graph.drsg serve
This starts the JSON-RPC 2.0 API, the WebSocket change feed, and the embedded
dashboard, and reports the bound address (default 127.0.0.1:7700).
Authentication. With no token configured, only the same-origin browser UI is
authorized to call the API. To permit programmatic access from the SDKs or
curl, configure a shared token and present it as a bearer credential:
$ DRSG_TOKEN=please-change-me drsg --db graph.drsg serve
A token is required for any bind address other than loopback: drsg serve --addr 0.0.0.0:7700 without DRSG_TOKEN (or [server] token) refuses to
start, because off loopback the same-origin check is no protection at all. On
such an address the served dashboard does not carry the token either — the
browser asks for it once per tab (see the Web UI chapter) — and the dashboard’s
own origin must be named in allowed_origins. Send the token as
Authorization: Bearer <token> on HTTP and, preferably, on the WebSocket
upgrade as well; ?token= on /ws exists for browsers, which cannot set
headers there, and a query string is what proxies and browsers tend to log.
A peer that presents a wrong token more than five times is answered 429
with a Retry-After that doubles up to five minutes; a correct token clears
it.
Over the API a provider name is one of the presets or exactly the server’s
configured embed_provider; a base URL is accepted from the CLI and the
configuration file, never from a request.
Configuration file
Server, logging, and provider settings may be supplied through a TOML
configuration file instead of individual flags and environment variables. The
file is resolved from --config <path>, then $DRSG_CONFIG, then ./drsg.toml
if present. Unknown keys are rejected.
[server]
addr = "0.0.0.0:7700" # bind address (CLI --addr overrides)
token = "please-change-me" # shared API token (→ DRSG_TOKEN); prefer exporting it, see below
max_concurrent = 1024 # ceiling on in-flight requests
write_timeout_secs = 30 # how long a write waits for the single writer slot before failing retryably (0 waits forever)
query_timeout_secs = 60 # how long one request's queries may run before stopping the same way (0 runs to completion)
retain_commits = 20 # commits of history time-travel can reach; older versions are reclaimed (0 keeps all). Applies to every drsg command, not only serve
source_root = "/srv/myrepo" # source tree behind the grep/snippet agent tools (serve watch sets it from --dir)
mount_root = "/srv/repos" # the one directory `drsg mount` may open a database from (default: your home directory); a plane inside it is also readable by grep/snippet
allowed_origins = ["https://app.example.com"] # additional browser origins
# page_token = false # never write the token into the page (→ DRSG_PAGE_TOKEN); for a loopback bind behind a reverse proxy, see the Web UI chapter
# /mcp behind a hostname: DRSG_ALLOWED_HOSTS=memory.example.com (env; honoured only with a token — see the MCP chapter)
[server.tls] # present ⇒ serve HTTPS
cert = "/etc/drsg/cert.pem" # PEM certificate chain
key = "/etc/drsg/key.pem" # PEM private key
[logging]
dir = "/var/log/drsg" # directory for the rolling log file
[llm] # provider keys, exported to the environment
OPENAI_API_KEY = "sk-…"
DEEPSEEK_API_KEY = "…"
DASHSCOPE_API_KEY = "…"
DRSG_CHAT_OMIT = "temperature,max_tokens" # optional: chat fields to leave out, for a model that rejects them
[digest] # server-side AIgest tuning
concurrency = 8 # per-chunk extraction calls in flight
chunk_chars = 4000 # target chunk size
embed_provider = "openai" # embedding provider for search / write_nodes / watch re-vectorization
embed_model = "text-embedding-3-small" # its model (each provider has a default)
embed_key_env = "OPENAI_API_KEY" # env var holding its key
ignore_files = ["gitignore"] # whose ignore files decide what is source; [] honours neither
tracked_only = false # true reads only what git tracks
code_only = false # true reads only what a handler claims (no Markdown/PDF)
[plugins] # preprocessor sandbox tuning (all optional)
fuel = 200000000000 # instruction budget per sandbox call (0 disables)
memory_mb = 3072 # guest linear memory per call, MiB (wasm32 allows at most 4096)
deadline_secs = 300 # wall-clock ceiling per call, seconds (0 disables)
total_memory_mb = 6144 # linear memory all calls may hold together, MiB (0 = twice memory_mb)
hostcall_mb = 1024 # what one call may copy out of the sandbox, MiB (0 = wasmtime's 128)
[fetch] # URL ingestion (Chapter 3)
enabled = true # false refuses URL fetching outright
max_pages = 10 # ceiling on pages kept per crawl
max_depth = 3 # ceiling on link-following depth a request may ask for
concurrency = 4 # requests in flight
allow_private = [] # see below — normally left empty
[network] # where this binary's own requests go
proxy = "http://127.0.0.1:7897" # or socks5://…; overridden by ALL_PROXY / HTTPS_PROXY / HTTP_PROXY
no_proxy = ".internal, 192.168.1.10" # NO_PROXY-style bypass list; overridden by NO_PROXY
[fetch] changes the server’s network posture, and is worth reading before
enabling anything in it. With URL ingestion on — the default — a client can name
an address that the server then connects to. The server’s position on the
network is usually the more privileged one, so every non-routable address is
refused: loopback, RFC-1918 private space, link-local (169.254.0.0/16, where
cloud instance metadata services answer credentials), and the rest. The check is
made on the resolved address, not the hostname, and repeated at every
redirect hop; an IPv6 address that carries an IPv4 one inside it (v4-mapped,
6to4, Teredo, NAT64’s 64:ff9b::/96) is judged as that IPv4 address.
allow_private re-permits specific CIDR blocks — ["10.0.0.0/8"] to read an
intranet wiki — and is the one deliberate exception to that. It is not a switch
that turns the guard off, and a server reachable by untrusted clients should
leave it empty. To refuse URL fetching entirely, set enabled = false.
Working behind an HTTP proxy
[network] governs the requests drsg makes on your behalf: drsg plugin install, drsg update, drsg digest <url>, the LLM provider, and a replica’s
snapshot fetch. Set it in the file, or export any of ALL_PROXY, HTTPS_PROXY
or HTTP_PROXY (each also read lowercase). socks5:// works as well as
http://.
Three rules are worth knowing, because each one is a decision rather than an accident:
A URL a caller names is never proxied. /digest/fetch and plugin.install
over JSON-RPC take a URL from whoever is calling, and the address guard
described above is what stops that URL naming the server’s own loopback or a
cloud metadata endpoint. A proxy resolves the name and connects on the server’s
behalf, so the guard would have no address left to judge — it cannot be enforced
through a proxy, and drsg does not pretend otherwise. Those requests stay direct
and guarded. drsg serve says so once at startup when a proxy is configured.
Loopback is never proxied, whatever is configured. A local model server —
the ollama preset is http://localhost:11434/v1 — is not what anyone setting
https_proxy for the outside world meant to redirect. Any other address obeys
the configuration; only localhost and the loopback ranges are automatic.
The environment wins. A variable that is set overrides the file, so a single command can go through a different proxy without editing anything:
https_proxy=http://127.0.0.1:1080 drsg plugin install ts # this run only
https_proxy= drsg plugin install ts # set but empty: go direct
no_proxy takes a NO_PROXY-style list, comma- or space-separated: an exact
host, a .suffix or *.suffix wildcard, an IP literal, an entry with a port
(example.com:8080), or * for everything. CIDR blocks are not supported.
NO_PROXY in the environment replaces the file’s list rather than adding to
it.
Precedence is fixed: an environment variable already set in the process always
takes precedence over the corresponding file value, and the --addr flag
overrides [server].addr. Providing [server.tls] switches the server to
HTTPS.
Secrets in the file become process environment. token and every [llm]
key are applied with setenv at startup, so they are inherited by every
process drsg starts — the detached serve watch that drsg init spawns,
plugin hosts, anything a hook runs — and are readable in /proc by the same
user. That is what makes the provider layer’s “look the key up by env-var
name” work, and it is why the shipped drsg.example.toml leaves token
commented out: a copied example with a well-known token is a server with no
token. Keep a drsg.toml holding secrets out of version control, or export
them in the launching environment and leave them out of the file.
retain_commits is read by every drsg command that opens the database,
not only serve: a store driven from the command line — import, cypher,
digest, vectorize — lives by the same window (and the same default of 20)
as it would under the server, so its history is reclaimed at compaction rather
than kept forever. The stdio drsg-mcp binary reads no config file but opens
with the same default; DRSG_RETAIN_COMMITS changes it there (Chapter
8).
Read-only replicas (serve --follow)
A second drsg serve, started with --follow, mirrors a running one
read-only — for scaling reads across a cluster of agents without funnelling
every query through one process:
$ drsg --db replica.drsg serve --addr 127.0.0.1:7701 \
--follow ws://master-host:7700 --follow-token please-change-me
Every write RPC is refused regardless of token. On startup — and again after
any disconnect — the replica pulls a full, consistent snapshot from the
master’s /snapshot endpoint, then tails its /ws/wal for new commits;
every reconnect resyncs from scratch (arch/01 §9), so --db must name an
empty directory or one this same replica already owns (a .drsg-follower
marker records that) — anything else is refused rather than silently wiped.
--follow-token (or DRSG_FOLLOW_TOKEN) is the credential presented to the
master; it is independent of this replica’s own DRSG_TOKEN, which still
gates its own downstream clients. Point --follow at wss:// rather than
ws:// for a master reachable over anything but loopback — the bearer token
otherwise travels in plaintext, the same requirement [server.tls] already
carries for inbound connections.
Container image
A multi-arch image (linux/amd64 and linux/arm64) is published to the GitHub
Container Registry. Pull and run it — no build required:
$ docker run -p 7700:7700 -v drsg-data:/data \
-e DRSG_TOKEN=please-change-me \
ghcr.io/wangyingsm/dr-strange:latest
docker run pulls the image on first use. Pin a release with a version tag —
ghcr.io/wangyingsm/dr-strange:1.0.2 — instead of :latest for reproducible
deployments. The runtime image binds to 0.0.0.0:7700 and stores the database on
the /data volume (the native backend database is a directory, which the volume
persists). Provider keys are supplied as environment variables.
DRSG_TOKEN is required in a container: the image binds 0.0.0.0, and
drsg serve refuses a non-loopback bind without a token rather than serve the
API and the dashboard to whoever reaches the port (the container exits with the
message naming the variable). Every client presents that token — the SDKs and
curl as a bearer, the dashboard through its prompt. Opening the dashboard at
http://localhost:7700 on the Docker host works as is; a browser reaching the
container by any other name — http://graph.example:7700 — must have that origin
listed in DRSG_ALLOWED_ORIGINS (comma-separated, or [server] allowed_origins),
because off loopback the served page carries no token and an unlisted origin is
refused as cross-site.
For a persistent deployment, docker-compose.yml pulls the same image and defines
a named volume. It marks DRSG_TOKEN as required, so an unset token stops
docker compose up with a message instead of starting a container that exits;
DRSG_ALLOWED_ORIGINS is passed through when set:
$ DRSG_TOKEN=please-change-me docker compose up
$ DRSG_TOKEN=please-change-me DRSG_ALLOWED_ORIGINS=http://graph.example:7700 docker compose up
To build the image locally instead, the repository ships a multi-stage
Dockerfile that compiles the dashboard, embeds it in the binary, and produces a
minimal runtime image: docker build -t dr-strange:latest ..
Next steps
- Chapter 3 — AI Native: embeddings, hybrid retrieval, natural-language querying, and document ingestion.
- Chapter 4 — Query Language: the openCypher subset and the underlying logical plan.
- By interface: Chapter 6 — SDK (application code), Chapter 7 — Embedded CLI (operations), Chapter 8 — MCP (LLM agents).
AI Native
This chapter documents the capabilities that distinguish Dr Strange as a database designed for AI workloads: native vector and keyword indexes, fused hybrid retrieval, natural-language querying, and LLM-driven document ingestion. Time-travel and the change feed — equally central to agent workloads — are covered in Chapter 4 and referenced at the end of this chapter.
Vector properties and the HNSW index
A property whose value is a vector is an embedding, indexed natively so that similarity search executes inside the query engine rather than in an external service.
An index is declared on a (label, property) pair with a distance metric. The
engine builds an HNSW index over every node
of that label carrying that vector property, and keeps it coherent as writes
commit:
$ drsg --db graph.drsg index ensure Doc embedding --plane social
$ drsg --db graph.drsg index ensure Doc embedding --plane social --metric dot
The metric is cosine (default), dot, or l2. Declaration is idempotent;
re-declaring the same pair with a different metric is an error. The built index
is persisted beside the database in the .hnsw sidecar and reloaded on open.
A query consults the index through the SEARCH … NEAR … TOPK form; when no index
is declared, the engine falls back to an exact brute-force scan, which is the
oracle the index is validated against.
Keyword (BM25) indexes
Lexical relevance complements semantic similarity. A keyword index is declared
on a (label, property) pair over a string property, and ranks documents by
Okapi BM25:
$ drsg --db graph.drsg index keyword Doc body --plane social --lang english
Each index carries an analyzer language (english/en, french/fr, and
the other Snowball languages), which selects the stemmer and stopword set;
values are lowercased, tokenized, stopword-filtered, and stemmed before
indexing. The built index is persisted in the .bm25 sidecar.
Hybrid retrieval
Effective retrieval rarely depends on a single signal. Hybrid search combines up to three channels and returns one ranked result:
- Vector — embedding similarity over a declared vector property,
- Keyword — BM25 relevance over a declared keyword property,
- Graph — proximity to the strongest vector/keyword hits, expanded outward and decayed per hop.
$ drsg --db graph.drsg hybrid "how does time-travel work" \
--plane social --label Doc \
--vector embedding --keyword body \
--graph-hops 1 --k 10 --embed openai
Each channel contributes a candidate pool; the scores are min-max normalized
within their channel and combined as a weighted sum (default weights: vector 1,
keyword 1, graph 0.5), and the top k are returned. Enabling a channel is a
matter of naming its property; the graph channel is enabled by --graph-hops.
The keyword channel requires a label, since a BM25 index is label-scoped.
Natural-language querying
plane.ask compiles a plain-language question into a query, runs it, and returns
the connected subgraph. The model is grounded on the plane’s soft schema (its
labels, property types, and edge-type connectivity) and instructed to emit a
serializable logical plan.
$ drsg --db graph.drsg ask "which companies does Ada work for?" \
--plane social --chat deepseek --embed qwen
The procedure is an agentic loop rather than a single call:
- Decomposition. A compound question is split into independent sub-questions, one plan per sub-question.
- Grounded tools. The model may call embedding-backed tools —
find_edgeandfind_entity— that search the plane’s own edge descriptors and entities by similarity, so it selects real edge types and seed nodes instead of guessing. - Repair. A plan that fails to parse or execute is returned to the model with the error for a bounded number of repair attempts.
Querying is read-only: the logical plan has no write operators, so ask
cannot mutate the graph. --dry-run returns the generated plan without executing
it; the result also carries a trace of the tool calls and repairs for
inspection.
Document ingestion (AIgest)
Ingestion turns unstructured text into graph structure. A document is chunked; an LLM extracts entities and typed relations from each chunk; the extraction is cleaned up (below); the entities are embedded; each is linked to an existing node when one is found (avoiding duplicates) or created otherwise; and the result is written through the bulk path.
# Preview only (the default): report the entities and relations that would be written.
$ drsg --db graph.drsg digest notes.md --plane social --chat openai --embed openai
# Commit the extraction.
$ drsg --db graph.drsg digest notes.md --plane social --apply
Extraction precision
Chunks are extracted independently, so nothing makes them converge: the same kind of thing acquires several labels, the same relationship several edge types, the same entity several names — and because chunks merge positionally, an entity’s properties are fixed by whichever chunk mentioned it first, with every later and better mention discarded.
Three clean-up passes follow the extraction. They are not configured individually; one option selects how many of them run, since they form a cost ordering:
--mode | passes | what it buys |
|---|---|---|
coarse | vocabulary reconciliation | one label per kind, one edge type per relationship |
fine (default) | + identity resolution | one node per entity |
super | + per-entity refinement | properties drawn from the whole document |
- Vocabulary reconciliation canonicalizes the label set and the edge-type set — as sets, so it costs a constant number of model calls however long the document is. Names differing only in case or separators fold with no model involved.
- Identity resolution merges entities that name the same thing, rewrites edge endpoints onto the survivor, and collapses the duplicate edges and self-loops that produces. It also checks each key against the plane exactly, so re-digesting a document links rather than duplicating.
- Per-entity refinement re-reads each entity against every passage
mentioning it, together with its relations, and asks for its properties again
with the full picture in view. Entities with nothing to read beyond the chunks
that produced them are skipped without a call. This is the pass that repairs
first-chunk-wins — and it is expensive: roughly 15× the input tokens of
fine, because each eligible entity carries its passages into a request of its own.
Wherever a name is canonicalized or merged, the form the document actually used
is kept beside it as _label_as_written / _type_as_written /
_key_as_written. Underscore-prefixed properties are provenance: they are
hidden from the schema summary the model reads, so an alias costs the read paths
nothing while keeping the document’s own words recoverable.
The command-line tool ingests text and Markdown; the dashboard’s AIgest page additionally converts Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV and PDF to Markdown, and previews the proposed entities and relations before writing. Committing a preview performs no further model calls.
Reading from a URL
A document may be named by address instead of uploaded. The server fetches the page, converts it to Markdown, follows its links under a budget, and assembles one document — which then digests exactly as a pasted one does.
$ drsg --db graph.drsg digest https://example.com/paper \
--plane papers --topic "attention mechanism" --pages 6 --apply
A page’s outbound links are a curated bibliography: the author already decided what is related. The difficulty is that they also point at cookie policies and navigation. So relevance is decided twice, and hop count decides nothing — depth measures how far the crawl walked, not whether a page is about anything.
- Before fetching, each link is scored on what is already in hand: its anchor
text, its
title, and the words in its URL path. No network, no model. Only the best candidates cost a request. - After fetching, the page is scored again on its actual text, and one that does not hold up is dropped having cost exactly one request.
Both use the same analyzer as the BM25 index, so a link saying “transformers”
matches a target term of “Transformer”. A typed --topic sharpens the target;
left empty, the page’s own subject is what the crawl looks for. Hop decay
survives only as a tiebreak toward the root.
Each page arrives with its address recorded in the text, and a page boundary always begins a new chunk, so no chunk ever mixes two documents:
<!-- drsg:source https://example.com/paper -->
# Attention Is All You Need
…
Budgets — pages, depth, response size, total download, time — are the real control, and whatever a budget drops is reported. The CLI prints what it kept and what it discarded; the dashboard shows the same list with checkboxes.
Fetching is enabled by default. What is not a default is reaching the private
network: the server refuses to connect to loopback, private, link-local (where
cloud metadata services live) or otherwise non-routable addresses, checks the
resolved address rather than the hostname, and re-checks it at every redirect
hop. An operator who needs an intranet source re-permits exactly that block, and
this is the one exception; see Chapter 2.
robots.txt is respected, the crawler identifies itself, and requests to one
host are spaced.
Known limitation: a page whose text is assembled by JavaScript in the browser returns a shell with no prose. There is no headless renderer.
Reading a codebase
Point digest at a directory and it walks the tree, routing each file to
whatever handles it. Source files are parsed rather than read: the result is
facts a compiler-grade parser is certain of, not a model’s reading of the text.
Parsers are plugins — sandboxed WebAssembly components you install once and use anywhere:
$ drsg plugin install https://github.com/…/releases/download/rust-v2.0.0/rust.wasm
installed rust@3 sha256:fdb01bb6967f
handles: .rs
The artifact’s SHA-256 is pinned at install and re-checked at every load, so a
file that changes on disk is refused rather than silently run. Inside the
sandbox a plugin can reach exactly three things — list files, read files,
ask the tree’s name — all rooted at the directory being ingested and checked
on the resolved path. No filesystem, no network, no environment, frozen
clocks, and hard instruction and memory budgets ([plugins] in drsg.toml).
A component that so much as imports a filesystem interface is refused by name
at install.
$ drsg --db graph.drsg digest ./crates/dr-strange-core/src --plane code --apply
preprocessed by rust@3 (5528 facts)
no prose left to read — digested without a model call
0 chat request(s); tokens 0 in / 0 out / 0 embed
applied: wrote 1139 nodes, 2739 edges
Read that third line again: no API key was set and no request was made. An
AST does not infer that parse() calls lex() — it knows — so handing the file
to a model as prose would spend tokens to get a worse answer than the one
already in hand. Where a parser is certain, the model is not consulted.
Each handler brings a vocabulary of its own, fixed by the handler rather than
invented per document — which is why nothing needs reconciling afterwards, and
why the labels and properties a handler emits are documented by that handler,
not here. Ask the one you are using; drsg digest --handler <name> names it.
Every fact carries _generated_by naming the handler and its version, so a
parsed fact is always distinguishable from a model’s guess. Where both claim one
key, the parser wins and the model’s version is dropped and counted.
The example above points at src/ rather than the crate root, and that is the
difference between a run that calls no model and one that does: a crate root
holds a Cargo.toml and usually a README, and those are prose that genuinely
needs reading. Pointed at a src/ directory, the crate is still named after the
directory holding it, so two crates ingested into one plane stay two crates.
A real repository is not one language, so a tree is routed per file: the Rust is parsed, the Markdown and the configuration become prose for the model, and anything unreadable is skipped and counted. What the parser could not resolve is reported rather than quietly omitted — a call into another crate is not an edge, and a name matching two functions equally is left alone rather than guessed at:
note: 553 call(s) named nothing defined here — calls into other crates and
the standard library are not edges
--handler rust forces a handler instead of routing by extension. A plugin’s
own settings live under [plugins.<name>] in drsg.toml and pass through
uninterpreted — [plugins.rust] include_source = true stores each function’s
source on its node for retrieval, off by default because it is roughly a copy
of the codebase in the graph.
The walk honours .gitignore — a project’s own statement of what is derived
rather than source, and better than a list this tool could guess at — and always
skips target/, node_modules/ and their kin. Running it twice on an unchanged
tree yields the same graph, byte for byte.
.dockerignore is not read by default. It answers a different question —
what belongs in the build context sent to the Docker daemon — and the answers
routinely differ: a front end built in CI and shipped as dist/ is right to keep
src/ out of its image, and that says nothing about whether the source is
derived. --ignore-files chooses:
$ drsg digest . # .gitignore only (the default)
$ drsg digest . --ignore-files gitignore,dockerignore
$ drsg digest . --ignore-files '' # neither; read the tree as it sits
[digest] ignore_files in drsg.toml sets it for every run, and the same flag
is accepted by drsg init and drsg serve watch. Whatever is chosen, drsg’s own
floor stands: dotfiles, the build directories above, and drsg’s own store and
logs/ are skipped regardless.
The working tree, not a commit
The walk reads the working tree. An ignore file says what a project considers derived; git’s index says what it has taken responsibility for, and those are not the same list. A generated report sitting untracked is neither ignored nor source, and by default it is read.
--tracked-only reads only what git tracks:
$ drsg digest . --tracked-only
A tracked file with uncommitted edits is still read as it stands — the flag
filters paths, not content, which is one git ls-files rather than a git cat-file per file. Outside a git repository it refuses rather than quietly
reading nothing.
Either way, a plane records how far the tree had drifted from the commit it names, and every answer read from it says so:
synced: commit ec31cac58593 — parsed from a working tree with 2 modified and
1 untracked, so the graph holds what that commit does not
Silence there means the tree matched — or that the plane was written before drsg recorded this, which is the same silence on purpose: claiming “clean” for a plane that never measured would be the overclaim this replaced.
Code, or the documents beside it
Markdown, PDFs, spreadsheets and any extension no installed handler claims are
read by the built-in document reader, as prose. That is the point in a graph of
a document; in a graph of a codebase it is how a plane goes from hundreds of
nodes to thousands. --code-only reads only what a handler claims:
$ drsg digest . --code-only
A useful side effect: prose is what needs a model, so a code-only digest of a
pure-code tree makes no chat call at all. read is deliberately not narrowed
— a handler pulls the files around the one it was given, a manifest or an
included header, and those need not be claimed by anyone.
[digest] tracked_only and [digest] code_only set both for every run. Each
flag only turns its setting on: not passing it is not a request for the
opposite, so the file still decides.
Knowing what was read
A tree that declares most of itself away digests to almost nothing, and the count alone is not something anyone reads. So an ingest that withholds more than half of what it saw says so unprompted, naming the file responsible:
$ drsg digest .
warning: 5 of 7 files were withheld by ignore rules — .dockerignore (5). If that
is not what you meant, `--ignore-files` chooses which of them apply.
-v asks for the accounting whether or not anything looks wrong, and each
further v says more:
| (none) | silent, except the warning above |
-v | read 812 of 840 files (96.7%) |
-vv | and each withheld file with the rule that withheld it |
-vvv | and every file read, with the handler that read it |
The denominator is what drsg’s own floor left, not every file on disk: counting a
node_modules/ of 80,000 would make the percentage meaningless. drsg serve watch reports the same thing through its log, since its output is a log rather
than a terminal.
This is deliberately local-only — drsg on your own machine and the stdio
MCP server, never a shared drsg serve. What makes parsing worth its cost is a
handler pulling the files around the one it was handed, and the only
filesystem a shared server could offer is its own.
Providers and keys
The LLM features — semantic search embedding, ask, and ingestion — call an
external provider. Presets are provided for OpenAI, DeepSeek, Qwen,
and Ollama; any other endpoint is addressed by base URL. The chat provider
and the embedding provider are chosen independently, which matters when a
provider offers one but not the other (DeepSeek, for instance, is chat-only —
pair it with Qwen for embeddings).
Keys are read from the server’s environment, never from a request. Each
provider reads its conventional key variable (OPENAI_API_KEY,
DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, …); these may also be supplied through
the [llm] section of the configuration file (see
Chapter 2).
Primitives for agents
Two further capabilities, documented elsewhere, complete the agent-facing surface and compose with the above:
- Time-travel — read any past state by commit sequence or timestamp, to audit or reproduce an earlier result. See Chapter 4.
- Change feed — subscribe to a plane and receive mutations as they commit, so an agent reacts to the graph instead of polling it. See Chapter 6.
Query Language
Every read and write in Dr Strange executes as a logical plan — an explicit pipeline of operators. A plan may be authored directly as a serializable structure, or written in an openCypher-subset language that compiles to the same plan. The two are equivalent; the language is a surface over the plan.
This chapter explains what each construct is for. Appendix B states the complete grammar — every clause, every default, and everything that is deliberately not supported.
The logical plan
A plan is a source followed by a sequence of steps. Each row flows through the pipeline carrying a current node and the path that produced it.
Sources:
| Source | Rows produced |
|---|---|
ScanAll | every node in the plane |
ScanLabel(label) | nodes carrying label |
SeekKeys(keys) | nodes resolved from external keys |
VectorTopK{…} | the k nodes nearest a query vector |
KeywordTopK{…} | the k nodes best matching a text query (BM25) |
Hybrid{…} | the k nodes ranked by fused vector + keyword + proximity |
Algo{…} | the nodes a graph algorithm produced, each carrying its result |
Every source yields rows the remaining steps then process, so retrieval and algorithms are not terminal operations: a keyword hit, a fused hit, or a PageRank result can be traversed from, filtered, sorted, and bounded like any scanned node.
Steps:
| Step | Effect |
|---|---|
Expand{dir, edge_type} | traverse one hop (direction, optional edge type) |
ExpandVar{…} | traverse a variable number of hops |
Filter(expr) | keep rows whose predicate holds |
Distinct | deduplicate by current node |
Sort(keys) | order rows |
Skip(n) / Limit(n) | offset / bound the result |
A plan may end in a projection: a list of named columns, each either an
expression evaluated per row or an aggregate folded over a group of rows, with
its own distinct, order_by (by column index), skip and limit. It is a
tail rather than a step because it turns node rows into value rows — nothing
can expand or filter a node after it. A plan without one returns nodes, exactly
as it always has.
The plan is serializable. MATCH (:Person)-[:KNOWS]->(q) RETURN q LIMIT 50
corresponds to:
{
"source": { "ScanLabel": "Person" },
"steps": [
{ "Expand": { "dir": "Out", "edge_type": "KNOWS" } },
{ "Limit": 50 }
]
}
A plan in this form runs directly:
$ drsg --db graph.drsg query - --plane social < plan.json
The Cypher subset
Reads are expressed with MATCH … RETURN, optionally refined by WHERE,
ORDER BY, SKIP, LIMIT, and DISTINCT:
MATCH (p:Person)-[:KNOWS]->(q:Person)
WHERE p.age >= 18
RETURN q
ORDER BY q.name
LIMIT 50
A MATCH is a node pattern, optionally chained through relationship patterns
(-[:TYPE]->, <-[:TYPE]-, or undirected). WHERE predicates combine property
comparisons (=, <>, <, <=, >, >=), label tests (n:Label), string
predicates (CONTAINS, STARTS WITH, ENDS WITH), membership (IN), and the
boolean operators AND, OR, NOT. A read returns the matched subgraph — the
nodes and edges on every matching path, not merely the final nodes.
Text and membership predicates
MATCH (d:Doc) WHERE d.title CONTAINS "graph" RETURN d
MATCH (p:Person) WHERE p.name STARTS WITH "Al" RETURN p
MATCH (f:File) WHERE f.path ENDS WITH ".pdf" RETURN f
Matching is byte-wise: no case folding, the same posture = takes. Non-string
scalars promote to their text form, so d.year STARTS WITH "20" works whether
year was stored as 2026 or "2026" — which matters on soft-schema data,
where the same field may differ in type from node to node. Values with no
canonical text form (Null, byte strings, vectors, lists, maps) simply do not
match.
IN is membership, and is deliberately not spelled CONTAINS: for a list of
strings, “contains” could mean either “has this element” or “some element has
this substring”, and nothing in the syntax would pick one.
MATCH (d:Doc) WHERE "graph" IN d.tags RETURN d -- element of a list property
MATCH (d:Doc) WHERE "author" IN d.meta RETURN d -- key of a map property
MATCH (n) WHERE n.year IN [2020, 2021] RETURN n -- literal list
A list is tested by element, using the same equality = uses, so 7 matches a
stored 7.0. A map is tested by key, not value. A literal list on the right
is expanded into equalities at compile time; any other right-hand side is
evaluated per row.
Missing properties
A predicate over a property the node lacks is not satisfied — and neither is
its negation. d.year <> 2020, NOT d.year = 2020, NOT d.year IN [2020]
and NOT (d.title CONTAINS "x") all skip a node without the property, as in
openCypher; d.year = null matches nothing. Ask about absence with IS NULL
/ IS NOT NULL, and combine when both are wanted:
MATCH (d:Doc) WHERE d.year <> 2020 OR d.year IS NULL RETURN d
The engine reaches this by guarding <> and NOT with IS NOT NULL on the
properties they read, rather than with three-valued logic, so one case is
stricter than openCypher: under NOT, a compound predicate whose false branch
would have absorbed the null — NOT (d.a = 1 AND d.b = 2) keeps a node with
b = 3 and no a in openCypher, and drops it here.
Anchoring on a known entity
key(n) reads a node’s external key — the stable identifier it was created
with. It is an ordinary term, usable anywhere an expression is, but an equality
(or IN) on the query’s first variable is compiled into a SeekKeys source:
an index lookup rather than a scan followed by a filter.
MATCH (n:Doc) WHERE key(n) = "paper-42" RETURN n
MATCH (n) WHERE key(n) IN ["ada", "alan"] RETURN n
This is the shape that matters for a graph whose identity lives in the key rather than in a property — the common case for LLM-ingested material — and it anchors a traversal from a specific entity:
MATCH (n)-[:CITES]->(p:Paper)
WHERE key(n) = "paper-42"
RETURN p
Projections and aggregation
RETURN n (or *) hands back whole records — nodes with their properties. Any
other RETURN projects: each item becomes a column, named by its AS alias
or, without one, by the item as written.
MATCH (f:Fn) RETURN f.file, f.line AS line, key(f)
Because a row carries the whole path that produced it, a column may read any variable the pattern bound, not only the last:
MATCH (a:Author)-[:WROTE]->(p:Paper) RETURN a.name, p.year
An aggregate folds a group of rows into one value: count, sum, avg,
min, max, and collect (which keeps the group’s values as a list). The
grouping key is every column that is not an aggregate — Cypher’s implicit
GROUP BY, so what a query lists beside its aggregates is what it is broken
down by:
MATCH (a:Author)-[:WROTE]->(p:Paper)
RETURN a.name, count(*) AS papers, collect(DISTINCT p.year) AS years
ORDER BY papers DESC
LIMIT 10
count(*) counts rows; every other fold reads a value and skips the rows whose
value is null — or is something the fold cannot use, such as a year one node
stored as text, which is what keeps an aggregate over soft-schema data
answering rather than failing. Over a group that gave it nothing, count and
sum are 0, avg, min and max are null (the mean of no values is
unanswerable, not zero), and collect is the empty list. A projection with no
grouping key at all is one group over the whole match, so RETURN count(*)
answers 0 rather than nothing when the match found nothing.
In a projecting query, DISTINCT, ORDER BY, SKIP and LIMIT apply to the
projected rows: DISTINCT compares whole tuples (two nodes sharing a file are
one row), and ORDER BY names a column — by its alias, by the expression it
returned, or by the aggregate it folds (ORDER BY count(*) DESC finds the
count column however the RETURN spelled or aliased it).
MATCH (f:Fn) RETURN DISTINCT f.file ORDER BY f.file SKIP 10 LIMIT 10
MATCH (a:Author)-[:WROTE]->(p:Paper) RETURN a.name, count(*) ORDER BY count(*) DESC
A node cannot share a RETURN with columns, since a node is not a value:
return its properties instead. Every surface renders a projected result the
same way — as columns and rows rather than nodes and edges.
Writes
The write clauses mutate the plane and report change counts rather than rows:
| Clause | Effect |
|---|---|
CREATE | create nodes and edges |
MERGE | match an existing pattern or create it |
SET | add or overwrite properties or labels |
REMOVE | remove properties or labels |
DELETE | delete a node or edge |
DETACH DELETE | delete a node together with its incident edges |
CREATE (a:Person {name:"Ada"})
MERGE (b:Person {name:"Alan"})
CREATE (a)-[:KNOWS {since: 1936}]->(b)
Values may be supplied as $name parameters rather than interpolated into the
query text, which keeps the query stable and avoids escaping. A key: may be
a parameter too — MERGE (n:Person {key: $k}) upserts on the string $k
resolves to (a non-string is an error, not a property):
MATCH (p:Person) WHERE p.age >= $min RETURN p
MERGE (n:Person {key: $k}) ON CREATE SET n.seen = 1
Literals and identifiers
Strings take either quote and the usual escapes — \', \", \\, \n,
\t, \r, \b, \f, \uXXXX — so a value that contains a quote is written inside the
literal, never around it; an unknown escape or an unterminated string is a
syntax error. Numbers are ints (42) or floats (3.5, 1e9, 2.5E-3).
Identifiers (variables, labels, types, property keys) are Unicode words
(n.名字, café), or anything between backticks when the plain form cannot
spell the name (n.`first name`, (:`order`)). Keywords and function
names are case-insensitive (COUNT(*), Score()). Expressions may nest at
most 64 levels deep, where parentheses, NOT, unary - and each operator of
an AND/OR/+ -/* / chain count one level (so a chain of 64 conjuncts
is the limit); deeper is a syntax error rather than a crash. An IN [...]
list is not a chain and may be as long as the query body allows.
What the subset leaves out
Each of these is refused with an error that names the rewrite; none is silently misread:
- A second
MATCH,OPTIONAL MATCH,UNION,UNWIND,WITH, and a pattern with several paths (MATCH (a)-->(b), (a)-->(c)): a query holds one linear path, ending in oneRETURN. Run one query per pattern or branch and combine the results in the caller. - Relationship variables (
-[r:KNOWS]->): an edge cannot be bound, returned or filtered on. Drop the variable:-[:KNOWS]->. - Inline property predicates in a
MATCHnode ((n:Person {name: "x"})): write them inWHERE(WHERE n.name = "x", orkey(n) = "…"). InCREATEandMERGEthe same map is the node’s properties. - List and map literals as values (
RETURN [1, 2],n.x = {a: 1}): a list is only the right side ofIN(or a vector afterNEAR), a map only aCREATE/SETproperty map. - The
%and^operators; arithmetic is+ - * /. - Cross-variable predicates (
p.year < q.year), returning the rows of an earlier variable (RETURN pafter a hop — projectp.nameinstead), and unbounded variable-length hops (*,*2..).
A syntax error is reported near its cause: a typo late in a CREATE names
that spot, not the statement’s first word.
Similarity search in a query
The SEARCH clause makes similarity a source of rows, so a semantic lookup and a
traversal compose in one statement:
SEARCH (d:Doc) ON embedding NEAR "how does time-travel work" TOPK 10 RETURN d
ON <property> selects the vector property and may be omitted — every NEAR
defaults to embedding, the property the document-ingestion pipeline writes, so
SEARCH (d:Doc) NEAR "…" is the short form. TOPK <k> bounds the result. A text
argument (NEAR "…") is embedded server-side; a literal vector (NEAR $vec)
requires no provider. This clause compiles to a VectorTopK source, from which
traversal may continue:
SEARCH (d:Doc) ON embedding NEAR "time travel" TOPK 10
-[:CITES]->(p:Paper)
WHERE p.year >= 2020
RETURN p
Keyword search in a query
The same verb searches words instead of meaning when NEAR is replaced by
MATCHING. It compiles to a KeywordTopK source over the BM25 index declared
on that (label, property) pair, and each row carries its relevance as
score():
SEARCH (d:Doc) ON body MATCHING "graph database" TOPK 10
RETURN d ORDER BY score() DESC
Both a label and ON <property> are required here. The keyword index is
declared per (label, property), so there is nothing to search without them —
and unlike the vector property, keyword properties follow no convention worth
defaulting to. Unlike vector search, which falls back to an exact scan, keyword
search returns nothing when no index is declared.
Hybrid retrieval in a query
HYBRID fuses up to three ranked channels into a single ordering. Each channel
is optional (at least one of VECTOR or KEYWORD is required), may carry a
WEIGHT, and they may appear in any order. Only the channel’s defining part is
required — HOPS for GRAPH, a query for the other two — so VECTOR NEAR "…"
and GRAPH HOPS 2 are the short forms; the vector property defaults to
embedding and the per-hop decay to 0.5, matching the RPC, MCP and CLI:
HYBRID (d:Doc)
VECTOR ON embedding NEAR "graph database internals" METRIC cosine WEIGHT 1.0
KEYWORD ON body MATCHING "LSM storage engine" WEIGHT 1.0
GRAPH HOPS 2 DECAY 0.5 WEIGHT 0.5
CANDIDATES 100 TOPK 10
RETURN d ORDER BY score() DESC
This is the plane.hybrid retrieval of Chapter 3 expressed in
the language, running through the same fusion engine: each channel is
min-max-normalized before the weighted sum, and score() carries the fused
result.
Graph algorithms
Graph algorithms are read-only, transient computations over a single snapshot of a plane. They are available as a query source, so their output feeds the rest of the pipeline:
CALL pagerank(damping: 0.85, iterations: 20) ON (n:Paper)
RETURN n ORDER BY score() DESC LIMIT 10
CALL shortest_path(from: "ada", to: "alan", dir: "both") ON (n)
RETURN n
CALL components() ON (n:Doc) RETURN n
CALL louvain(max_levels: 10) ON (n:Doc) RETURN n
ON (v[:Label]) does double duty: it scopes the algorithm to a label’s induced
subgraph (omit the label for the whole plane) and binds the variable the rest of
the query names. Every argument is optional and defaults to the engine’s own,
and an unknown algorithm or argument is an error rather than a silently ignored
setting.
Because the row model carries one current node, each algorithm reports its per-node result through the score channel:
| Algorithm | Row order | score() |
|---|---|---|
pagerank | most important first | the rank |
components | grouped by component | a dense 0-based component index |
louvain | grouped by community | a dense 0-based community index |
shortest_path | source → target | the node’s position along the path |
shortest_path takes its endpoints as external keys (a string) or node ids (a
whole number), optionally a dir of out/in/both and a weight naming a
numeric edge property. An unknown endpoint or an unreachable target yields no
rows rather than an error.
Since an algorithm is a source, its result composes:
CALL pagerank() ON (n:Paper)
-[:CITES]->(q:Paper)
WHERE q.year >= 2020
RETURN q ORDER BY score() DESC LIMIT 10
The same algorithms are also available as a direct command, which reports the raw result rather than a row stream:
$ drsg --db graph.drsg algo pagerank --plane social --top 10
$ drsg --db graph.drsg algo components --plane social
$ drsg --db graph.drsg algo shortest-path --plane social --src 1 --dst 42
$ drsg --db graph.drsg algo louvain --plane social
Time-travel
Any read may be pinned to a past point — a commit sequence or a timestamp — and observe the graph exactly as it was then. It is inherently read-only: it selects the snapshot a query reads, and cannot alter history.
In the language this is the AS OF clause, written last so it reads as a
modifier over the whole query. It accepts a commit sequence, an RFC-3339
instant, or TIME followed by unix-epoch milliseconds, and it applies to every
source — a scan, a retrieval seed, or an algorithm:
MATCH (p:Paper)-[:CITES]->(q:Paper) RETURN q LIMIT 10 AS OF 41337
SEARCH (d:Doc) ON body MATCHING "outage" TOPK 5
RETURN d AS OF "2026-07-01T00:00:00Z"
CALL pagerank() ON (n:Paper) RETURN n AS OF TIME 1782864000000
The same address is available outside the language: as an as_of (commit
sequence) or as_of_ms (timestamp) argument on the read methods of the RPC API
and the SDKs, as PlaneHandle::as_of(…) in the embedded API, and as the
Time-travel slider and historical search in the dashboard. Every form uses
“at or before” semantics: a value between two commits resolves to the latest
commit not after it.
A time-travelling read cannot use the vector or keyword indexes, which are built from the latest commit; its similarity searches scan the pinned snapshot and its keyword and hybrid searches compute BM25 over it instead — correct (the matches and scores are the snapshot’s own, so a node deleted or edited after the point is still found as it was), but unindexed. Time-travel requires the native backend.
The queryable window is reported by plane.history as an oldest/latest pair of
commit sequences. History is retained without bound by default; a retention
window may be configured to bound it, in which case a read older than the window
is rejected.
Because a change event carries the commit sequence at which it landed, an agent
can read as_of(seq) and as_of(seq - 1) to reconstruct the exact before and
after of any committed change (see Chapter 6).
Web UI
drsg serve serves a single-page dashboard, embedded in the binary, for
inspecting and operating a database from a browser. It uses the same JSON-RPC API
as the SDKs over HTTP, and a WebSocket for live updates. Being embedded, it makes
no external network requests: all assets are served from the same origin, and all
rendering is local.
Access and authentication
Open the address reported by drsg serve (default http://127.0.0.1:7700). On a
loopback address, the UI needs no setup: with no token configured, the
same-origin check authorizes it; with DRSG_TOKEN set, the server writes the
token into the page it serves to a loopback browser (as a <meta> element the
app reads — never as a script), and the UI presents it as a bearer credential.
Cross-origin requests are refused regardless.
Served on any other address (--addr 0.0.0.0:7700, the container image), the
server requires a token to start at all and never writes it into the page,
because that page goes to anyone who can reach the port. The first request the
server answers unauthorized opens a prompt; paste DRSG_TOKEN there. It is
kept in the tab’s session storage (gone when the tab closes) and sent as
Authorization: Bearer on every call and as ?token= on the WebSocket, the only
form a browser socket can carry. The dashboard’s own origin must also be listed
in allowed_origins / DRSG_ALLOWED_ORIGINS there, since it is not loopback.
A loopback bind behind a reverse proxy on the same machine (or a forwarded
port) is a network deployment, not a local one: every client on the internet
reaches the server from a loopback peer address. The server treats it as
such wherever it can tell — it never writes the token into a page whose
request a proxy forwarded (Forwarded, X-Forwarded-*, X-Real-IP, Via)
or addressed to a name other than this machine (Host), and listing the
proxy’s origin in allowed_origins, which the dashboard needs to work
behind one, turns injection off for every page and makes a token mandatory
at startup (an allowed origin off loopback never counts as the local UI
either). A proxy that adds no header and keeps a loopback Host cannot be
told from a local browser, so for that shape set DRSG_PAGE_TOKEN=0
([server] page_token = false), which serves the page bare regardless; the
dashboard then asks for the token as it does on any other address. Set a
token whenever anything stands in front of the listener.
Every response carries a Content-Security-Policy under which scripts and styles
load only from the server itself; the dashboard is built to satisfy it, and a
reverse proxy in front should pass it through rather than replace it.
A wrong token may be presented five times; after that the server answers that
client’s requests carrying a token 429 Too Many Requests with a
Retry-After that doubles per further failure, up to five minutes, until a
correct token is presented. Requests with no token are neither counted nor
blocked, so a tokenless local dashboard never trips it and the page and
/health keep answering for everyone else. The client is the connection’s
address; behind a reverse proxy on the same machine it is the address the
proxy names in X-Forwarded-For, so one guesser does not lock out everyone
the proxy serves. A JSON-RPC batch holds at most 64 requests.
Wherever the interface asks for a provider — semantic search, natural-language
queries, AIgest — it offers the presets (openai, deepseek, qwen,
ollama), and the API accepts exactly those, or the one provider the server was
configured with ([server] embed_provider). A base URL is refused over the
wire, because the server would be calling it from its own network on behalf of
whoever holds a credential; an operator who wants a local or self-hosted
endpoint configures it on the server. Errors that would describe the server’s
own disk or its provider’s reply come back as a category and a reference
(storage error (ref 00002a)); the detail is in the server’s log under that
reference.
The interface has three views, selected from the header: Dashboard, Explore, and AIgest.
Dashboard
The Dashboard presents database health and plane management.
- Health. A grid of statistics — planes, nodes, edges, labels, edge types, declared indexes, average degree, commits (the commit sequence), and on-disk size. These are pushed live over the WebSocket, so they update as the database changes; a connection indicator shows the live/offline state.
- Plane cards. One card per plane, showing its name, description, and
node/edge counts. A card selects the plane (the app-wide context), exports it
as JSONL (a download the CLI’s
importreads back), or deletes it behind a type-to-confirm dialog. A “New plane” card creates one. - Provenance. A card states where its plane came from and what it may ask.
A plane served from a mounted database carries the mount’s name; a plane that
attaches others (
plane.attach) carries one tag per attachment, those on another instance marked remote with the URL in the tooltip. A plane that is neither mounted nor attaching anything shows no such row. - Mounted databases. When this process serves other stores alongside its
own (
db.mount), a section below the plane cards lists each one: its name, the path it was opened from, how many planes it contributes, and whether those planes carry the mount’s name as a prefix — which they do only when two mounts offered the same plane name. Each plane is a button that selects it. Nothing is copied and nothing is re-digested; the planes keep their own names and appear among the cards above. The section states the directory mounts are confined to, and is absent when nothing is mounted.
Explore
Explore is an interactive graph canvas driven by a tabbed toolbar. Selecting a node or edge opens an inspector showing its labels/type and properties (vectors are collapsed behind a control rather than printed); double-clicking a node expands its neighborhood; dragging from one node to another opens the new-edge dialog with the endpoints prefilled.
Reading a dense graph
A plane of any size is more than a canvas can show at once, so Explore draws the skeleton and lets you ask for the rest.
- Ranked seeding. The opening view is the 40 most connected nodes and the
edges among them, not the first 40 the scan reaches — a legible shape rather
than an arbitrary sample. Show more widens it to 100, 200, 500. Ranking is
by degree;
graph.seedalso offers PageRank, but PageRank pools rank in sinks, so a hub can score below its own neighbours. - The legend is a filter. Click a label to hide that category, click again to bring it back. Hidden, not faded: a category you switched off is not part of the question.
- Crowded hubs fold. When a node has more than twenty leaves — neighbours attached to it and to nothing else — they collapse into one bead labelled with the count. Click the bead to open it. Nothing is discarded, and a leaf you have selected is never folded away.
- Fans are grouped. Below that threshold, a hub’s leaves are arranged around it in arcs by label, so a ring reads as regions rather than a smear.
- Importance opens the layout. Edges touching a well-connected node are laid out longer, so a busy neighbourhood has room to be read.
- A large layout runs off the main thread. Past 400 nodes — Show all on a real plane — the force layout runs in a web worker for a bounded few seconds while the page stays responsive; the picture converges in view and settles when the run ends.
- Expand one hop is batched. Growing the frontier asks for at most 300 nodes a click, in JSON-RPC batches of 64, and says how many were left for a second click and how many calls failed.
- Selection focuses. The selection and its immediate neighbours stay at full strength, the next ring dims, and everything beyond recedes and drops its label. Selecting an edge focuses both of its endpoints, since an edge is a statement about two things.
Node size tracks how connected a node is (counting anything folded beneath it), and the legend maps colors to labels.
The toolbar tabs:
- Filters / Operations — seed the canvas from a label (or the whole plane).
- Algorithms — run PageRank, connected components, shortest path, or Louvain and overlay the result on the current graph (scores as node size/color, components/communities as color groups).
- Hybrid — fused vector + keyword + graph-proximity search, with the channels and label selected in the bar.
- Ask — a natural-language question; the generated plan is shown (with a copy control) and its connected result is plotted.
- Time-travel — a slider over commit history (see below).
- Live — the change feed (see below).
Query
A view of its own, beside Dashboard and Explore, and the one place a query is written: Explore draws the graph, this view asks it questions and reads the answers (Chapter 4).
The editor is several lines, since a query worth a page is rarely one: **⌘/Ctrl
- Enter** runs it, plain Enter is a newline, and Tab accepts a keyword completion ghost-hint as you type. Example queries sit under the editor as starting points, and the text survives a reload.
The result takes the shape the query asked for:
- a projection (
RETURN n.file, count(*) AS n) comes back as a table, with its row and column counts, the time it took, and a copy control that puts the whole thing on the clipboard as tab-separated text — header included, so it pastes into a spreadsheet; - a query returning whole records (
RETURN n) lists them by key, labels and the properties that fit; - a write reports its change counts.
A query that cannot compile shows the parser’s own message, which names the construct rather than a character position.
Search
The header carries a quick search over the current plane, in two modes:
- Text — substring matching across keys, labels, and string properties.
- Semantic — embedding-similarity ranking (with an embedding-provider selector).
Selecting a result focuses that node or edge in Explore. The search also respects the time-travel cursor: with a past commit pinned, it searches the graph as it was at that commit.
Time-travel
On a native-backend server, Explore’s Time-travel tab probes the queryable
window (plane.history) and, when available, presents a slider over the commit
sequence with a Live control at the latest end. Dragging back re-plots the
graph as of that commit; the seed and node expansions read the historical
snapshot. A marker on the canvas indicates the pinned commit on every tab, and
returns the view to live when dismissed. The header search reflects the same
cursor. On a non-native backend the tab is absent.
The slider spans the retained window, not every commit ever made:
plane.history starts at the floor [server] retain_commits keeps (20 when
the key is omitted; set retain_commits = 0 for unbounded history), the
readout says how many commits
that is, and db.stats reports the setting as retain_commits. Raise it
before you need the depth — versions past the floor are reclaimed at
compaction and cannot be reached afterwards.
Wires
An HTTP route is the one reference a parser cannot turn into an edge: the
handler and the caller are often in different languages, in different
repositories, and between them there is only a string. So the preprocessor
plugins record what each side states — serves on a handler, requests on a
caller, both as VERB /path/{} with every variable segment reduced — and the
match is computed when someone asks rather than stored.
Explore’s Wires tab reads those statements for the current plane and pairs
them. Reading the routes runs the same selection the wire verb does — every
node stating either side, and nothing else — so a plane of any size answers
with its routes rather than with itself.
- Matched lists each request beside every declaration that answers it. A request nothing answers is listed too, marked and tinted rather than hidden: it is the more interesting half, and usually means the handler lives in a repository this graph does not hold.
- Requests and Serves list one side on its own, each route shown both
normalized and as written, so
/api/orders/{}can be read back against the/api/orders/{id}the source spells. - A verb filter and a path-substring filter narrow all three, and every symbol is a link that centers that node on the canvas with its neighborhood.
Two sides match on verb and path: the paths must be equal, and the verbs equal
or either one * — a side that names no verb matches every verb on that path.
Paths compare exactly, because the plugins normalized them when the graph was
built; a route recorded by no plugin, or written in a form other than VERB path, is skipped rather than guessed at.
Live feed
Explore’s Live tab opens a plane.watch subscription over the WebSocket and
streams commits as they land, newest first: each entry shows the operation
(created / updated / deleted, color-coded), the kind (node / edge), the key or
id, the labels, and the commit sequence. The stream can be paused and resumed,
and narrowed to one label; selecting a node change focuses it in the canvas.
AIgest
The AIgest view ingests a document into the current plane (see Chapter 3). Upload or paste text — Markdown and plain text, or any of Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV and PDF, which are converted to Markdown so the model sees headings, tables and lists rather than loose characters — choose the chat and embedding providers, and Preview: the model extracts the entities and relations and proposes them. Write to graph commits the previewed proposal with no further model call. Options include linking to existing nodes (to avoid duplicates) and skipping embeddings.
A URL row sits beside the upload control: paste an address, optionally a topic, and Fetch. What comes back is not text in the box but a list — the page and the pages it links to, each with its relevance score, title and size, ticked if it cleared the floor. Unticking one removes it from the document with no further request, and a fold-out shows what was not kept and why. Nothing becomes tokens until the list looks right (Chapter 3).
Mode selects how thoroughly the extraction is cleaned up — coarse, fine
(the default), or super — and is remembered like the provider choices, since
it is a standing preference rather than a per-run decision. Choosing super
raises a notice directly under the control: it re-reads every entity against all
of its passages, at roughly 15× the input token usage. See Chapter
3 for what each mode buys.
Design
The dashboard is theme-aware (it follows the viewer’s light/dark preference) and fully self-contained: because it is embedded and same-origin, it depends on no external CDN, font, or service, and issues no requests beyond the API and WebSocket of the server that served it.
SDK
Dr Strange ships client libraries for six languages — TypeScript, Python,
Go, Java, C, and Zig. Each communicates with a running drsg serve over
JSON-RPC 2.0, and its typed method surface is generated from the server’s
OpenRPC schema, so every SDK matches the wire protocol exactly and stays in
step with it across releases. (The Zig client is a thin idiomatic binding over
the generated C client, so it inherits the same guarantee.)
Obtaining the SDKs
The SDKs live under sdk/<language> in the repository, all at version 2.27.1
in step with the workspace. None is published to a language registry yet
(no PyPI, npm or Maven Central release, no sdk/go/vX tags), so each is
installed from a checkout of this repository:
| Language | Location | Install from the repository |
|---|---|---|
| TypeScript | sdk/typescript | bun install && bun run build there, then "drsg": "file:…/sdk/typescript" in package.json |
| Python | sdk/python | pip install …/sdk/python (or pip install "drsg @ git+https://github.com/wangyingsm/dr-strange.git#subdirectory=sdk/python") |
| Go | sdk/go | go get github.com/wangyingsm/dr-strange/sdk/go@<commit>, or a replace … => …/sdk/go directive |
| Java | sdk/java | ./mvnw install there, then depend on io.github.wangyingsm:drsg:2.27.1 |
| C | sdk/c | make → libdrsg.a + include/drsg.h (needs libcurl + json-c) |
| Zig | sdk/zig | zig build, or add src/drsg.zig as a module (Zig 0.16) |
Each directory’s README.md gives the exact steps for its language.
Connecting and calling
A client is constructed from a base URL and a token; the token defaults to the
DRSG_TOKEN environment variable and rides each request — the WebSocket
upgrade behind the change feed included — as an Authorization: Bearer
credential, never in a URL. (The TypeScript client falls back to ?token= on
the socket where the standard WebSocket constructor cannot set headers — a
browser window or worker, and Deno; Bun and Node send the header.) Method
names mirror the RPC method one to one, adapted to each language’s convention:
| Language | Construct a client | Example call |
|---|---|---|
| TypeScript | new Drsg({ baseUrl, token }) | await db.nodeCreate({ … }) |
| Python | Drsg(base_url=…, token=…) | db.node_create(…) |
| Go | drsg.New(drsg.WithBaseURL(…), drsg.WithToken(…)) | db.NodeCreate(ctx, …) |
| Java | new Drsg(baseUrl, token) | db.nodeCreate(…) |
| C | drsg_client_new(base_url, token) | drsg_node_create(…) |
| Zig | try drsg.Client.init(base_url, token) | c.drsg_node_create(client.handle, …) |
The shape is uniform. In TypeScript:
import { Drsg } from "drsg";
const db = new Drsg({ baseUrl: "http://127.0.0.1:7700", token: process.env.DRSG_TOKEN });
await db.nodeCreate({ plane: "social", key: "ada", labels: ["Person"] });
await db.nodeCreate({ plane: "social", key: "alan", labels: ["Person"] });
await db.edgeCreate({ plane: "social", src: "ada", dst: "alan", type: "KNOWS" });
const stats = await db.dbStats();
console.log(stats.nodes, stats.edges);
The other languages follow the same method surface with their own idioms — Go
threads a context.Context through each call, Python and Java raise exceptions,
and C returns a json_object the caller owns and reports failure through an
out-parameter.
Error handling
An application-level failure (an unknown plane, a malformed plan) is a JSON-RPC
error; a rejected credential is code -32001. The SDKs surface this as a typed
error: DrsgError / DrsgAuthError in TypeScript and Python (plus
DrsgTimeoutError for a request that outlives the TypeScript client’s
timeoutMs, and DrsgProtocolError for a non-JSON-RPC reply or a malformed
change-feed frame in Python), a *drsg.Error
with IsAuthError in Go, DrsgException / DrsgAuthException in Java, and a
filled drsg_error (with drsg_is_auth_error) in C.
The change feed
Every SDK except Zig can open a long-lived WebSocket and subscribe to a plane’s
change feed (Chapter 3), receiving each committed
ChangeEvent —{ plane, seq, truncated, changes }, where each change is
{ kind, op, id, labels?, record? }. (The Zig binding wraps the C client’s
request/response surface only; a Zig program that needs the feed calls
drsg_watch from the C library directly.) The subscription follows each
language’s natural concurrency model:
TypeScript — a callback; the socket auto-reconnects. close() stops it.
const sub = db.watch("social", (event) => {
for (const c of event.changes) console.log(event.seq, c.op, c.kind, c.id);
});
// sub.close();
Python — a blocking generator; iterate to consume, break to disconnect.
for event in db.watch("social"):
for c in event["changes"]:
print(event["seq"], c["op"], c["kind"], c["id"])
Go — a channel; cancel the context to stop. The dial and upgrade honour
the context too (30 s if it carries no deadline), the channel closes when the
server hangs up, and a message over drsg.MaxFrameBytes ends the watch.
events, _ := db.Watch(ctx, "social")
for e := range events {
for _, c := range e.Changes {
fmt.Println(e.Seq, c.Op, c.Kind, c.ID)
}
}
Java — a listener; the returned Subscription closes it (sending a close
frame, then aborting the socket so a silent peer cannot hold it open). A
listener that throws is logged at WARNING and the feed continues. The client
itself is AutoCloseable: close() ends its subscriptions and reaps the
HttpClient it built, unless one was shared through Client.Options.
var sub = db.watch("social", null, event -> {
for (var c : event.changes()) System.out.println(event.seq() + " " + c.op() + " " + c.kind());
});
// sub.close();
C — a callback; drsg_watch blocks until the callback returns non-zero (run
it on a thread if needed; drsg_watch_cancellable takes a drsg_watch_ctl
that another thread can drsg_watch_ctl_cancel). A drsg_client wraps one
libcurl handle and must not be used by two threads at once, so the watch thread
needs a client of its own. A message over DRSG_WS_MAX_MESSAGE_BYTES ends the
watch with DRSG_TRANSPORT_ERROR_CODE.
static int on_change(struct json_object *event, void *userdata) {
/* inspect event["changes"]; return non-zero to stop */
return 0;
}
drsg_error err;
drsg_watch(client, "social", NULL, on_change, NULL, &err);
An optional label narrows the subscription to changes to nodes of that label. Delivery is best-effort: a subscriber that falls too far behind drops the overflow rather than stalling writers.
Because each event carries the commit sequence it landed at, a subscriber can
read the graph as_of that sequence — and as_of the one before — to
reconstruct the exact before/after of a change (Chapter
4).
Codegen
The typed method surface of each SDK is generated from
crates/dr-strange-web/openrpc.json, the single source of truth the server also
returns from rpc.discover. Each SDK carries a small code generator and a drift
test that fails if the committed client no longer matches the schema, so the
libraries cannot silently diverge from the wire protocol. The hand-written parts
— the transport, error types, and the WebSocket watch — sit beneath the
generated surface.
Embedded CLI
drsg is the command-line interface. It opens the database directly — it is
embedded, not a client of a running server — which makes it suited to local
scripting, bulk ingestion, inspection, backup, and operations. Every invocation
takes a global --db <path> (default graph.drsg); most commands also take a
--plane (default startup).
Because it opens the database in-process, drsg cannot operate on a database
that a drsg serve — or another drsg — currently holds open: the database
takes an exclusive lock, and a second open fails with a clear error rather than
corrupting it. Use the SDKs or the RPC API against the server for concurrent
access, and the CLI for offline operations.
Command reference
| Command | Purpose |
|---|---|
init [--dir] [--rebuild] | bootstrap this repository for agents: digest it, spawn serve watch detached, write .mcp.json (Chapter 10). In a --no-default-features build without digest, it creates an empty database instead — as most commands do on first use |
plane list | create | drop | show | plane lifecycle |
import <file> --plane | load JSONL nodes/edges |
export --plane | dump a plane as JSONL |
get <node> --plane | fetch one node by id or @external-key |
query <plan> --plane | run a serialized logical plan (JSON, or -) |
cypher <query> --plane | run an openCypher-subset statement (or -) |
catalog [--plane] | print the soft schema (one plane or the whole database) |
algo <name> --plane | run a graph algorithm |
hybrid <query> --plane | fused vector + keyword + graph-proximity search |
index ensure | keyword | declare a vector or keyword index |
ask <question> --plane | natural-language query |
digest <file|dir|url> --plane [--mode] | ingest a document, a repository, or a page and its links |
search <query> --plane | semantic lookup: embeds the query, returns the nearest nodes |
context | describe | trace | impact | fathom <name> --plane | the agent verbs over a digested code plane |
impact <name> --plane [--depth] [--across [attached|all]] | --across widens the radius past this repository: the planes this one attaches, or every plane this instance holds |
snippet <name | path:start-end> --plane | a symbol’s source text, or a range of any file in the parsed tree |
grep <pattern> --plane [--regex] [--path] [--all-files] | text search over that tree, ignored files excluded unless --all-files; each hit names the symbol it falls in |
traverse <key> --plane [--edge-type] | the neighbours a hop (or several) away |
history --plane [--limit] | the repository behind a plane: HEAD, branch and tag tips, rebases, newest commits |
deployed [<target>] --plane [--ops] [--history] | what commit a running thing was built from, and what runs a commit: the ops plane joined to the history beside it through the revision an image’s tag carries |
recall <name> --at <rev> --plane [--vs <rev>] [--pattern] | code as it was at a revision: a file, a range, a directory or a symbol; a search of that tree; or a diff from an older revision |
wire <path | name> --plane [--verb] | what serves an HTTP path and what asks for it, across every language in the plane and across the planes it attaches |
attach <name> --plane [--url] [--as] [--both] | record a plane this one may ask: one here, or one another drsg serves |
detach <name> --plane | forget an attachment (an absent name is reported, not an error) |
attachments --plane [--probe] | what a plane attaches; --probe asks each remote one whether it answers now |
mount <path> [--as] [--url] | serve another database’s planes on a running drsg, now — these three speak its RPC rather than opening a file, since the server holds the store’s lock |
unmount <name> [--url] | stop serving a mount, releasing its store’s lock — said when a fold in flight still holds it (an absent name is reported, not an error) |
mounts [--url] | what that server serves: each mount with its directory and planes, and every plane with the tree it records |
queries [<id>] [--limit] | the Cypher queries that have run; cypher --history <id> runs one again |
vectorize --plane | embed a plane’s nodes for similarity search |
plugin install | list | remove | manage preprocessor plugins (sandboxed wasm parsers) |
snapshot <out> / restore <in> | whole-database backup and restore |
stats / check | summary counts / integrity scan |
serve [--addr] | run the web dashboard + JSON-RPC API + MCP endpoint |
serve watch [--dir] | serve, and keep a code plane synced to every commit |
update [--bin] [--dir] | replace this binary with a newer release, if there is one |
A digested file may be Markdown or plain text, or any of Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV and PDF — those are converted to Markdown first, so the model reads headings, tables and lists rather than loose characters. The format is detected from the file’s contents, so a wrong extension still works. A directory is walked and routed per file: source code goes through the installed parser plugins as facts, documents go to the model as prose.
Global options are --db <path> and --config <path> (the configuration file,
Chapter 2).
Planes
$ drsg --db graph.drsg plane create social
$ drsg --db graph.drsg plane list
$ drsg --db graph.drsg plane show social
$ drsg --db graph.drsg plane drop social
Data in and out
Import and export use JSONL — one node or edge per line — so a plane round-trips through the file system and integrates with other tooling:
$ drsg --db graph.drsg import nodes.jsonl --plane social
$ drsg --db graph.drsg export --plane social > social.jsonl
$ drsg --db graph.drsg get @ada --plane social
A node’s external_key is its identity within a plane, so --on-conflict
decides what happens when an imported key is already there:
| Policy | Effect |
|---|---|
error (default) | Import nothing and name the colliding keys |
skip | Keep the existing node; drop the incoming one |
update | Overwrite the existing node’s properties, and its labels when the line carries them |
$ drsg --db graph.drsg import nodes.jsonl --on-conflict update
imported 0 nodes, 0 edges, 2 existing updated
Under skip and update, edges in the file still resolve to the node already
in the plane, so relationships load against existing data. Edges themselves
carry no key, so they are always appended — the policy governs node identity,
not edge deduplication. Lines without an external_key cannot collide and are
always inserted.
Querying
A statement may be the openCypher subset or a serialized plan; either accepts -
to read from standard input. --param binds a $name placeholder to a JSON
value:
$ drsg --db graph.drsg cypher --plane social \
'MATCH (p:Person) WHERE p.age >= $min RETURN p' --param min=18
$ drsg --db graph.drsg query - --plane social < plan.json
$ drsg --db graph.drsg catalog --plane social
Every query that runs — from the CLI, the dashboard, or an agent over MCP — is
recorded. queries lists what has run, newest first, as id, time, plane and
the query folded onto one line; with an id it prints that one query’s text
alone. cypher --history runs a recorded query again, against the plane it was
written for unless --plane says otherwise:
$ drsg --db graph.drsg queries --limit 5
4 2026-09-05 08:14:02Z social MATCH (p:Person) WHERE p.age >= $min RETURN p
3 2026-09-05 08:11:47Z social MATCH (p:Person) RETURN count(*) AS people
$ drsg --db graph.drsg queries 3 > last.cypher
$ drsg --db graph.drsg cypher --history 3
The history is capped — two hundred entries by default — and the oldest is
dropped as a new one arrives. Re-running the query most recently recorded
restamps its entry rather than adding a second copy. Note that drsg history
is a different command: that one is a repository’s commits.
Graph algorithms
$ drsg --db graph.drsg algo pagerank --plane social --top 10
$ drsg --db graph.drsg algo components --plane social
$ drsg --db graph.drsg algo shortest-path --plane social --src 1 --dst 42
$ drsg --db graph.drsg algo louvain --plane social
Retrieval and ingestion
Declare indexes, run fused search, ask in natural language, and ingest documents (Chapter 3). The LLM-backed commands read provider keys from the environment:
$ drsg --db graph.drsg index ensure Doc embedding --plane social
$ drsg --db graph.drsg index keyword Doc body --plane social --lang english
$ drsg --db graph.drsg hybrid "how does time-travel work" \
--plane social --label Doc --vector embedding --keyword body --graph-hops 1
$ drsg --db graph.drsg ask "which companies does Ada work for?" \
--plane social --chat deepseek --embed qwen
$ drsg --db graph.drsg digest notes.md --plane social --apply
$ drsg --db graph.drsg digest paper.md --plane papers --mode super --apply
$ drsg --db graph.drsg digest https://example.com/paper --plane papers \
--topic "attention mechanism" --pages 6 --apply
An http(s):// argument is fetched rather than read from disk: the page is
converted to Markdown and its links followed under --pages / --depth, keeping
what is relevant to the page’s own subject and to --topic if given (Chapter
3). The scheme is required — a bare
example.com is a valid filename, and guessing which was meant would be worse
than asking. The command prints what it kept and what it dropped; there is no
selection prompt, which is what the dashboard’s page list is for.
--mode selects how thoroughly the extraction is cleaned up: coarse
reconciles the label and edge-type vocabularies, fine (the default) also
merges entities that name the same thing, and super also re-reads every entity
against all of its passages — the most accurate, at roughly 15× the input token
usage (Chapter 3).
Backup and integrity
snapshot writes a consistent whole-database bundle at one commit sequence;
restore rebuilds it into an empty database, preserving ids, the commit
sequence, and the built search indexes (Chapter 11).
stats and check report counts and scan every plane for readability:
$ drsg --db graph.drsg snapshot backup.drsgsnap
$ drsg --db fresh.drsg restore backup.drsgsnap
$ drsg --db graph.drsg stats
$ drsg --db graph.drsg check
check also names storage runs written before block checksums existed. A
compaction rewrites what it merges, so a store written to often upgrades
itself, but one that settles below the compaction threshold keeps unchecked
blocks for good — and corruption in them reads as data rather than an error.
upgrade rewrites those runs, entry for entry, so nothing is reclaimed and
the store holds what it held:
$ drsg --db graph.drsg upgrade
upgraded 2 storage runs to the checksummed format
Serving
serve is the exception to the embedded model: it opens the database and then
exposes it over the network for the dashboard, the SDKs, and the MCP server.
$ DRSG_TOKEN=please-change-me drsg --db graph.drsg serve --addr 0.0.0.0:7700
See Chapter 2 for the server and its configuration, Chapter 5 for the dashboard, and Chapter 6 for the clients.
One setting from that configuration is not the server’s alone: [server] retain_commits — how many commits of history time-travel can reach — is
applied by every command above that opens the database, so a store fed by
import or cypher from the shell keeps the same window (20 by default, 0
for unbounded) as it would under serve.
MCP
drsg-mcp is a Model Context Protocol server
that embeds Dr Strange and exposes the database to LLM agents as a set of tools.
An agent in a compatible host can search, traverse, query, run algorithms, ask
natural-language questions, and ingest documents — and write to the graph —
directly, without bespoke integration code.
How Dr Strange fits an agent
MCP is itself JSON-RPC 2.0 — the same protocol the web backend speaks — so an MCP
server is a first-class surface rather than an adapter. drsg-mcp embeds
dr-strange-core and opens the database in-process (as the CLI does), then
serves the protocol over stdio: the host launches the process and exchanges
JSON-RPC messages over its standard input and output. Logs are written to stderr
and a rolling file, never to stdout, which carries the protocol.
Running and configuring
drsg-mcp is a separate binary. Install a release with one line (Chapter
2):
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.sh | sh -s -- --bin drsg-mcp
On Windows, in PowerShell:
PS> & ([scriptblock]::Create((irm https://raw.githubusercontent.com/wangyingsm/dr-strange/master/scripts/install.ps1))) -Bin drsg-mcp
It can equally be built from source with cargo build --release -p dr-strange-mcp. It takes the database as --db <path> or as a bare argument,
else $DRSG_DB, else graph.drsg; --help and --version print and exit:
$ drsg-mcp --db /path/to/graph.drsg
$ drsg-mcp /path/to/graph.drsg # the same thing, short
The database must already exist — this server never creates one. Build it with
drsg digest <dir> --apply --db <path>, or with drsg init in a repository. An
empty database would answer every question with “nothing found”, which reads
exactly like a digest that went wrong, so a path that isn’t there is an error
instead.
The database is opened with the same history retention drsg serve and the
CLI use — the last 20 commits reachable by time travel, older versions
reclaimed at compaction — so a store an agent writes through write_nodes,
write_edges, cypher or digest does not keep every version forever. This
binary reads no config file; DRSG_RETAIN_COMMITS changes the figure (0
keeps everything, as [server] retain_commits = 0 does), and a value that is
not a number is an error at start-up rather than a silent default.
With no database named, it looks for a server first. The nearest
.mcp.json is read — walking up, as git finds its own directory, and stopping
at the repository root (the first .git); a file owned by another user or
writable by everyone is passed over, since what it names receives the host’s
session and the Authorization it carries — and if it declares a drsg server
that answers, this process relays the host’s
session to it instead of opening anything. So in a repository prepared by
drsg init (which digests the tree, starts a drsg serve … watch, and writes
that server’s URL into .mcp.json), a stdio-only host reaches the process that
already holds the database, with a plane synced to the repository’s commits.
The relay forwards messages as they are, in both directions, so the host sees
that server’s tool set — including tools a newer server has and this binary has
never heard of. Naming a database (--db, $DRSG_DB) skips the search: a
caller who says which graph they want is not asking to be sent to another one.
Nothing answering, no .mcp.json, or no drsg server in it, and the database is
opened here as before.
It is normally launched by the host rather than run by hand. A host configures it by command, arguments, and environment — the environment carries any LLM provider keys the graph tools need:
{
"mcpServers": {
"dr-strange": {
"command": "drsg-mcp",
"args": ["--db", "/path/to/graph.drsg"],
"env": {
"OPENAI_API_KEY": "sk-...",
"DEEPSEEK_API_KEY": "...",
"DASHSCOPE_API_KEY": "..."
}
}
}
}
A ready-to-edit copy of this configuration is provided at
crates/dr-strange-mcp/mcp.json; set command to drsg-mcp (on the PATH) or
an absolute path to the binary, point args at your database, and supply only
the provider keys you use.
Because it opens the database in-process, one process at a time may open a
given database directly — a drsg-mcp, a drsg command, or a drsg serve, but
not two at once. This is enforced, not advisory: the second open fails with a
clear error rather than corrupting the database.
That is the rule the relay above exists to keep out of your way, and it still
binds whenever the relay does not apply. Two editors open on the same project
each spawn their own drsg-mcp: with a watch server running, both relay to it
and share one database; with none, the first opens the file and the second
refuses to start. For that second case — several agents that need to share one
memory — see the next section.
Sharing across agents: /mcp on drsg serve
drsg-mcp embeds by design: point a host at a path and it works, with
nothing to run and nothing to configure. That’s the right answer for one
agent, but it’s also why two hosts can’t share a database directly — each
one opens the file itself.
drsg serve (arch/08) already solves the “one writer, several clients”
problem for the JSON-RPC API and the dashboard. It exposes the identical
tool set at POST /mcp, over MCP’s Streamable HTTP transport, so several
agent hosts can point at the same server instead of each embedding their own
copy of the database:
{
"mcpServers": {
"dr-strange": {
"url": "http://127.0.0.1:7700/mcp",
"headers": { "Authorization": "Bearer <DRSG_TOKEN>" }
}
}
}
(Check your host’s docs for its exact remote-MCP-server config shape — the
url/headers fields above are illustrative, not universal.)
/mcp is gated exactly like /rpc: with DRSG_TOKEN unset, only the
same-origin browser UI is trusted, and every programmatic client is
refused, reads included — a zero-config desktop install shouldn’t quietly
expose an open API on localhost. Set DRSG_TOKEN on the server and pass it
as a bearer token to reach /mcp from anywhere else, including another
agent host.
One thing to know before putting this behind a hostname: the MCP transport
validates the inbound Host header to blunt DNS-rebinding attacks on
locally running servers, and answers 403 to a name it does not expect.
Loopback (localhost, 127.0.0.1, ::1) is always accepted. Once a
bearer token is configured, the address the server is bound to is accepted
too, and so is every name in DRSG_ALLOWED_HOSTS (comma-separated, a
hostname or host:port) — so /mcp behind https://memory.example.com
works with DRSG_ALLOWED_HOSTS=memory.example.com. Without a token the
list is loopback only, whatever the environment says: a tokenless server
trusts its own same-origin UI, which is exactly what a rebinding page sets
out to impersonate, and the Host check is what defeats it. With a token
every request has to carry a secret such a page cannot read, so the guard
adds nothing the token does not, and the operator may widen it. Agent hosts
on the same machine, the case ROADMAP §10 is about, need none of this.
Every session gets its own DrStrange instance, but they all share the one
Database the server opened — a write from one session is visible to every
other, immediately, the same way two browser tabs against /rpc already
are. That’s the whole point: write_gate inside the core serializes
concurrent writers so this is safe, not just convenient.
digest’s LLM calls still spend the server process’s provider keys (never a
tool parameter, remote or local); write_nodes/write_edges keep their
per-call batch atomicity, since the tool code runs in the same process
against the same Database either way — nothing here proxies to /rpc or
reshapes what a tool does. The [digest] config section steers the digest
tool exactly as it steers digest.run over /rpc: lowering concurrency to
stay under a provider’s rate limit applies to both surfaces, not one of them.
(The embedded drsg-mcp binary has no config file and keeps the built-in
defaults.)
rebuild re-reads the whole tree a plane was built from, which is what an
answer means when it says the plugins changed since the plane was built — a fold
applies a newly installed parser only to the files a commit touched. It takes a
plane name and nothing else: the directory comes from the plane’s own recorded
synced_root, so a rebuild cannot be aimed at a tree the plane was never built
from. It costs a full re-parse of the repository, and clears the warning that
prompted it. See the Plugins chapter for what the warning means.
One capability differs by transport rather than by configuration. digest
accepts a path to a document the server reads — Word, PowerPoint, Excel,
OpenDocument, RTF, EPUB, CSV, PDF, Markdown or plain text — and only the
stdio server honours it. That server runs on your own machine as your own
user, so it reads exactly what the agent could already open for itself. A
shared drsg serve refuses it: reading any path the caller names would let an
authenticated remote agent pull arbitrary server files into the graph and query
them back out. Over /mcp, send the document as text instead.
The same line runs through grep and snippet, which read source files.
Every path they open — an argument like src/lib.rs:12 or a node’s file
property — must resolve inside the tree it belongs to: .., an absolute path
and a symlink pointing out of the checkout are refused, never read. Which trees
exist depends on the transport. The tree the operator attached (serve watch’s
directory, or [server] source_root) is always readable. A plane’s own
synced_root is a plane property — data — so the stdio server, running as you,
reads it wherever it points, while a shared drsg serve reads it only when it
lies inside the attached tree; with no tree attached, /mcp reads no file at
all.
Session lifetime
A host that exits cleanly sends DELETE /mcp and its session goes away at
once. A host that is SIGKILLed — an editor restarting its MCP child, say —
sends nothing, so the server reclaims that session on a timer instead: 10
minutes idle, or 60 seconds with no initialize after the session is
created. The session’s worker task, its DrStrange, and its buffered
messages all go with it.
The idle window is ten minutes rather than five for a specific reason: the
transport counts a running tool as idle, because its keep-alive timer is only
reset by traffic and a tool call sends nothing between dispatch and its result.
On an otherwise quiet session, a tool that runs longer than the window is torn
down mid-flight. Ten minutes clears any realistic digest; if you routinely run
longer ones, keep the session busy or expect to retry.
What survives a reclaim is one map entry per dead session. It is tens of bytes, but it is not inert: the next request on that session id is answered 500 rather than the 404 the spec calls for, so a client that would have re-initialized does not, and that session stays broken until the host restarts. Both of these are transport-level and fixed upstream rather than here. Closing sessions you script in a loop avoids the whole area.
Tool concurrency
Tool calls are bounded separately from HTTP requests, at 16 at once across
the whole process (or max_concurrent, if that is lower). The two ceilings are
not the same thing: max_concurrent counts requests, most of which are cheap,
while every tool call is a full-graph scan, a bulk write, or an LLM-fanning
digest. The transport also answers a tool call as soon as it is queued, so the
request ceiling has already released the call before the work begins and cannot
bound it. Excess calls queue rather than fail — a busy server makes an agent
wait, it does not turn it away — but not without end: every call has one
deadline covering its wait and its run, 300 s unless
DRSG_MCP_TOOL_DEADLINE_SECS says otherwise (0 disables it). A call that
passes it comes back as a tool error saying whether the server was busy or the
body was slow, so the agent can retry or narrow the request rather than sit
behind a call that never returns. A body already running is not cut short, but
it keeps its slot until it finishes, so the ceiling still counts it.
The tools
| Tool | Kind | Purpose |
|---|---|---|
list_planes | read | list planes with node/edge counts |
describe_plane | read | a plane’s soft schema (labels, properties, edge types, key prefixes) |
get_node | read | fetch one node by id or external key |
search | read | semantic lookup — embeds the query, returns the k nearest nodes |
context | read | one symbol’s whole neighborhood on a digested code plane — the primary agent verb |
describe | read | one symbol’s properties, the lightweight node-only view |
grep | read | text search over the watched source tree — literal or regex, scoped by path, with context lines; each hit names the symbol it falls in; ignored files are excluded unless all_files |
trace | read | the shortest recorded call path between two symbols |
impact | read | everything reaching a symbol, grouped by distance, with what the parse could not bind counted beside the total and never in it; across (attached or all) also names the other repositories the radius reaches, by the coordinate their build declares and the symbol their source stands in for |
fathom | read | the makeup of the region around a symbol — labels, edge types, hubs |
snippet | read | a symbol’s source text, or a range of a file (path:start-end); says which symbol a range opens in and how to read on |
history | read | a repository’s history at a glance — where HEAD is, what the branches and tags point at, what was rebased, and the newest commits; reads the _git plane a digest of a git checkout writes |
deployed | read | what commit a running thing was built from, and what runs a commit — the _ops plane a digest of manifests writes, joined to the _git plane beside it through the revision an image’s tag carries; a floating tag, a digest-only reference and a revision this history does not hold are each said in those words rather than answered with nothing |
recall | read | code as it was at a git revision — a file, a line range, a directory, or a symbol’s declaration then, found by parsing its file as it was; pattern searches that tree and vs diffs a name from an older revision; the revision resolves over the _git plane |
wire | read | what serves an HTTP path and what asks for it — routes matched across every language in the plane, and across the planes it attaches; computed per call, never stored |
attach | write | record a plane this one may ask: one on this instance, or one another drsg serves (url); both records the way back |
detach | write | forget an attachment by the name it was made under |
attachments | read | what a plane attaches; probe asks each one served elsewhere whether it answers now |
mount | write | serve the planes of another database alongside this one’s, now, without a restart — one process for many repositories, each keeping its own store, its own writer and its own plane names. Confined to [server] mount_root |
unmount | write | stop serving a mounted database, releasing its store’s lock and stopping the watcher that folded into it; freed says whether the lock came free, since a fold in flight holds it until it ends. Nothing is deleted |
mounts | read | what this server serves and where each part came from — every mount with its directory and planes, and every plane with the tree it was parsed from, which is how a client maps a working directory to a plane |
traverse | read | neighborhood expansion from a node (1+ hops) |
query | read | run a serialized logical plan |
cypher | read / write | run an openCypher-subset statement — the escape hatch for what no verb anticipated; DELETE/REMOVE require confirm: true |
algo | read | a graph algorithm (pagerank / components / shortest_path / louvain) |
hybrid | read | fused vector + keyword + graph-proximity search |
ask | read | a natural-language question, compiled to a plan and run |
write_nodes | write | create nodes (batched) |
write_edges | write | create edges (batched) by endpoint keys |
create_plane | write | create an empty plane |
drop_plane | write | delete a plane and its contents (requires confirmation) |
digest | write | ingest a document (dry-run by default; apply: true requires confirm: true; mode sets extraction precision) |
rebuild | write | re-read the whole tree a plane was built from, making a graph that was parsed by two plugin sets one parse again |
Mapping to the rest of the system
The tools are the same operations available through the CLI and the JSON-RPC API,
adapted to an agent’s needs: search / traverse / query / cypher / algo
/ hybrid / ask mirror the query and retrieval surface of Chapter
4 and Chapter 3; write_nodes /
write_edges / create_plane / drop_plane / digest mirror the write and
ingestion surface. Each is grounded in the plane’s soft schema, which
describe_plane exposes so an agent can discover a graph before acting on it.
Naming a node exactly
A node’s external key is prefixed, and the prefix is the part no convention
reveals. A Rust key is prefixed with the crate (dr_strange_core::compact::resolve);
a TypeScript key is prefixed with the name declared in the nearest
package.json, then the file’s path relative to the directory holding that
manifest, without the extension — a trailing /index collapses to its
directory, and a root index names the package itself. With no package.json
above the file, the prefix is the tree’s label, which for a local directory is
its name.
The consequence worth knowing: a TypeScript key is not prefixed with the
repository directory. A project whose directory is bridge but whose manifest
declares highway_bridge-3.0 has keys under highway_bridge-3.0/, and a lookup
by directory name finds nothing.
Two things make this discoverable rather than something to remember.
describe_plane reports key_prefixes — the prefixes each producer’s nodes
actually use, observed by scanning rather than declared, so it cannot drift from
what is stored. And a lookup that finds nothing names the prefixes the plane does
hold, because a name matches any fragment of a key: src/App finds
highway_bridge-3.0/src/App without knowing the prefix at all.
Safety
- Provider keys are read from the server’s environment, never from tool parameters — an agent cannot exfiltrate or supply a key through a call.
- Reads are non-destructive.
askin particular compiles to a read-only plan and cannot mutate the graph. - Destructive writes are guarded; additive ones are not.
drop_plane, acypherstatement carryingDELETEorREMOVE, anddigestwithapply: trueall requireconfirm: trueand refuse without it, naming the flag;digestdefaults to a dry run that returns the proposed nodes and edges for inspection rather than writing them.write_nodes,write_edgesandCREATE/MERGE/SETneed no confirmation: they add what a later delete can remove, and a flag demanded for everything is one a client learns to pass without thinking. - A mirrored plane is not rewritten by hand. A plane that
digestorserve watchkeeps in step with a source tree records the commit it reflects, andcypherrefusesCREATE/MERGE/SET/REMOVE/DELETEon it: the next fold reconciles the plane against the tree, overwriting edits to parser-owned nodes and re-creating deletions, so such a write would be undone without a trace.write_nodes/write_edgesstill work there — a fold leaves nodes it does not own alone — and a plane of the agent’s own takes every statement.
On those same mirrored planes cypher answers a RETURN n in the compact
text the agent verbs speak — a count, the synced commit, one line per node —
rather than a JSON record apiece; a projection is a table everywhere. And a
statement that fails to parse comes back with the grammar of the clauses it
reached for, so the tool listing carries the intent and two examples rather
than the whole language on every turn.
Example: an agent workflow
A typical agent session composes the tools:
describe_planeto learn the labels, properties, and edge types in scope.searchorhybridto retrieve relevant nodes, thentraverseto gather their neighborhood — grounded context for the model.askorcypherto answer a specific question against the graph.digest(dry run, then applied) to fold new source material into the graph as entities and relations.
Because the store is a graph, the agent can move from retrieval into traversal in a single session — the GraphRAG loop of Chapter 1, driven end to end by the model.
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.
Coding Agent
The previous chapter described how parser plugins turn a repository into facts. This chapter is about what those facts buy a coding agent — and, for the languages the official catalog does not cover, how to build a plugin of your own.
Four commands take a repository to a queryable, commit-synced graph:
# Install parser plugins: a name from the official catalog, no argument for
# an interactive chooser over it (0 = all), or any .wasm path or URL.
$ drsg plugin install
# Digest a repository into a plane named after it
$ drsg --db codes.drsg digest ~/src/myrepo --apply --no-embed
# Serve the API + MCP surface and keep the plane synced to every commit
$ drsg --db codes.drsg serve watch --dir ~/src/myrepo
# One symbol's whole neighborhood, one call
$ drsg --db codes.drsg context 'WriteTxn::delete_node' --plane myrepo
--no-embed skips embeddings — parsing needs no model. Run drsg vectorize
later to make the plane semantically searchable.
drsg init collapses the digest-and-serve steps into one command, run
from the repository itself once its plugins are installed: it digests the
working directory into a plane named after it, spawns serve watch detached
on a freshly-picked address and bearer token, and writes .mcp.json — Claude
Code’s own convention, also read as-is by GitHub Copilot. It then writes a
matching MCP config for Cursor, OpenCode, Gemini CLI, Codex CLI, Trae or
Kimi Code, but only for a tool whose own marker (a directory it creates, or a
config file it already owns) is already present in the repository or on this
machine. Qoder and CodeBuddy need nothing extra — both read the project’s own
.mcp.json — so init says so rather than writing a second file.
Where the token goes follows what each client can do. .mcp.json and
.cursor/mcp.json carry it literally — Cursor is a desktop app launched with
no shell environment to read one from — and both are added to the
.gitignore block init maintains (with the database, its sidecars and
logs/). .trae/mcp.json is the same case — Trae is a desktop IDE
too — and is gitignored with them. .opencode.json and
.gemini/settings.json existed before init and are as likely as not
committed, so they get an environment reference in the client’s own syntax
({env:DRSG_TOKEN}, $DRSG_TOKEN) instead, as the Codex entry always has
(bearer_token_env_var) and as Kimi Code does by naming the variable
(bearerTokenEnvVar); init prints the
export DRSG_TOKEN=$(drsg token --unhide) line to run before launching those
clients here. That line reads the token out of .mcp.json rather than spelling
it, because init’s output ends up in terminal scrollback, CI logs and agent
transcripts. drsg token is what reads it back, so the line needs nothing else
installed and says what to do when no init has run here yet.
drsg token masks what it prints — AAAA…LmXw <40 characters>, enough to
tell one repository’s token from another’s and not enough to use. --unhide
prints the token itself, for the command substitution above, where it goes
straight into the environment without reaching the screen. A token too short
for its ends to give it away is masked whole.
Trae gates project-scoped MCP behind Settings > Beta > Enable Project MCP,
so the file init writes is ignored until that is on; init says so on the
line it prints.
For Claude Code it also installs two hooks, in the repository’s
.claude/settings.local.json (the per-user file, so nothing lands in the
tree a team shares) with the scripts beside the plugin store. A config tells
an agent where the graph is; the hooks tell it when to use it. The
SessionStart hook puts the rule in the agent’s context — drsg first for
every code question, shell last — where it survives a resume and a
compaction. The PreToolUse hook meets an rg, grep, cat or sed -n
on code with the verb that answers instead. A leading DRSG_RAW=<reason>
runs the command untouched, and naming the reason is the whole of its cost:
git for history, outside for a file no plane holds, mine for one’s own
logs, logs for program output, lag for a file this session has edited.
There is no reason for docs or config: the tree a plane attaches holds every
text file in it, so grep and snippet answer a markdown or TOML question as
readily as a Rust one, and the retired =notcode — which said otherwise — is
corrected where it is typed rather than honoured. Bare
DRSG_RAW=1 is there for the case none of those names, and is counted rather
than silent — every twenty-fifth use reprints the verbs once, then lets the
immediate retry through (DRSG_NAG_EVERY sets the interval). A redirect or
heredoc is a write and passes; a search whose pattern holds an arrow
(rg '>') or the hatch itself (grep -n DRSG_RAW=1 x.rs) is still a search
and is redirected — only a real leading assignment is a hatch. Re-running
init after the scripts’ directory moved repoints the entries — only entries
whose command is exactly one of drsg’s scripts; a team’s own hooks, even one named
my-drsg-shell-guard, are left alone. The other hosts have no hooks; they get the same rule from the server’s own MCP instructions, which
every host places in the system prompt.
$ drsg init
plane 'myrepo' bootstrapped — serve watch pid 48213, http://127.0.0.1:51900/mcp
+ wrote ./.mcp.json
+ Claude Code: hooks in ./.claude/settings.local.json — a shell search or read on code is redirected to the drsg tools (DRSG_RAW=git|outside|mine|logs|lag <command> passes unremarked; bare DRSG_RAW=1 is counted)
+ Cursor: wrote ./.cursor/mcp.json
+ Gemini CLI: wrote ./.gemini/settings.json (no token inside it — export DRSG_TOKEN=$(drsg token --unhide) before launching `gemini` here)
Run drsg init again whenever the server is gone. It spawns serve watch detached and records that process’s address and bearer token in
.mcp.json, but nothing ever restarts it: an MCP http entry tells a client
where to connect, not what to launch, so no agent relaunches it and it does
not survive a reboot, a crash, or a kill. Re-running init is the way back,
and it is safe at any time — it probes the recorded endpoint first (GET /health, which the server leaves unauthenticated for exactly this), leaves a
live server alone without opening the database, and restarts a dead one on
the same address and token, skipping the re-parse because the plane resumes
from its recorded commit.
$ drsg init # already up: nothing to do
drsg is already serving . at http://127.0.0.1:51900/mcp — reusing it, the plane is untouched
$ drsg init # after a reboot: same address, same token
plane 'myrepo' restarted — serve watch pid 51002, http://127.0.0.1:51900/mcp
Leaving the database closed on the reuse path is what makes this safe to run
against a server that is already up: one process at a time may open the
plane, so an init that opened it to check would be the very collision it is
meant to avoid. Every agent’s configuration stays valid across a restart,
because the address and token are recovered from .mcp.json rather than
generated afresh — and if that recorded port has since been taken by another
process, init moves to a free one and says so. Pinning addr and token
under [server] in drsg.toml fixes the endpoint outright.
--rebuild drops the plane and reads the tree again. The parser plugins
decide what a source file means, so an upgraded plugin only shows up in a
plane that has been folded through the new one — a resumed plane keeps
whatever the old parser made of the files it had already seen. --rebuild
stops the server that holds the database (it also holds the old copy of every
plugin), rebuilds from the whole tree, and comes back on the same address and
token, so no agent’s configuration changes underneath it:
$ drsg plugin install rust # a newer parser
$ drsg init --rebuild
stopping serve watch pid 51002 — the plane is about to be rebuilt
plane 'myrepo' rebuilt — serve watch pid 51188, http://127.0.0.1:51900/mcp
Facts only, as ever: embeddings come back on the next drsg digest.
So: once per repository, then again whenever the server is gone, and with
--rebuild when a plugin has changed what the tree means.
What the facts buy an agent
An agent working from grep reconstructs structure on every question: search, open files, read, infer who calls what, repeat. A digested plane has already done that work, once, with a parser — so structural questions become one round trip instead of a search-and-read loop.
Twelve verbs carry the workload, identical over MCP
(Chapter 8) and the CLI (Chapter 7; grep,
snippet and recall read the tree the plane was parsed from, which --root
names when the plane records none of its own):
| Verb | The question it answers |
|---|---|
context | everything about one symbol — definition, callers with call sites, callees, references, and what encloses it by span where no edge relates them — the primary verb |
search | “I don’t know the name”: semantic top-k over the plane’s embeddings |
describe | one symbol’s properties — the lightweight node-only view |
grep | text over the watched source tree, docs and config as much as code — literal or regex, path-scoped, with context lines; each hit names the symbol it falls in; what the tree’s ignore files exclude is left out unless all_files; bounded and counted |
trace | how one symbol reaches another: the shortest recorded call path |
impact | blast radius: everything reaching a symbol, grouped by distance, with what the parse could not bind counted beside the total rather than in it — and, asked --across, which other repositories the radius reaches |
fathom | what kind of place a symbol sits in: the region within a few hops, by label and edge type, with its hubs |
snippet | a symbol’s source text, or a range of a file (path:start-end) — the sed -n an agent no longer needs |
recall | the code as it was at a commit — a file, a symbol’s declaration then, a search of that tree, or a diff between two revisions — the git show and git log -p an agent no longer needs |
history | the repository behind the code: where HEAD is, what the branches and tags point at, what was rebased, and the newest commits |
deployed | what commit a running thing was built from, and what runs a commit — the manifests’ ops plane joined to the history beside it |
wire | what serves an HTTP path and what asks for it, matched across every language in the plane — and across the planes it attaches |
grep and snippet open files only inside the tree they belong to — the
directory the server watches, or the one a plane was digested from — and refuse
a path that leaves it (.., an absolute path, a symlink out of the checkout);
see Chapter 8 for how that differs between the stdio and the shared
server.
Every answer is compact one-fact-per-line text, sized for a model’s context
window rather than a terminal, and context keeps itself within a fixed
budget by tightening its per-group caps and saying what it elided.
Under serve watch, the graph tracks every commit: changed files re-run
through the plugins and the plane is updated in place — new symbols created,
gone ones deleted, edges rewritten — converging on exactly what a full
re-digest would build. Each answer opens with synced: commit <sha>, so an
agent knows which code it is reasoning about; the working tree’s uncommitted
edits are invisible until committed, and the answer says so by naming the
commit.
History is the other half. A digest of a git checkout also writes
<name>_git, and recall reads the code as it was at any commit that plane
names — by sha, branch, tag, date or HEAD~n — so “what did this function look
like before the refactor” is one call rather than a checkout. A symbol is
located by parsing its file as it was at that commit, not by today’s line
numbers, and a symbol the commit did not declare is reported as absent, with
what its file did declare.
Operations are the third plane. A digest of Compose files, Kubernetes
manifests or Terraform configuration writes <name>_ops — the workloads, the
containers, the images they run, the infrastructure underneath — and
deployed joins it to the history through the one string both ends name: the
image’s tag. Where a pipeline wrote a revision into that tag, “which commit
is this service running” is one call. Where it did not, the answer says which
of the reasons applies — a floating tag, a digest that pins bytes rather than
provenance, a revision no commit here holds — because a verb that answered
all three with nothing would leave a reader unable to tell a gap from a fact.
Planes are hard namespaces, so this is a read of two of them rather than an
edge between them.
Routes: where the graph stops being one repository
A call edge needs a callee the parser can see. An HTTP request has none: a page
calls /api/v1/orders/42 and the handler is a controller in another language,
often in another repository, with nothing between them but a string. Such a
graph holds both files and no connection.
Each language states its routes in its own way — an annotation carrying a path
(@GetMapping, @app.get, #[get]), a registration call whose name is the
verb (app.get('/x', h), r.GET, http.HandleFunc), or a client call with a
path argument. The plugins record all three in one vocabulary, on the symbol
that already exists: serves for a declaration, requests for a call,
each a VERB /path/{} with every variable segment reduced to {}. The Go,
Java, Python, Rust and TypeScript plugins write them today.
Nothing new is drawn in the graph. No Route node, no SERVES edge — a
synthesised edge would freeze a guess, and a path assembled at runtime is a
guess. wire computes the correspondence per call and reports what it could
not match: a request reaching no declaration, a declaration nothing asks for
(a dead endpoint, useful in itself), and a prefix the source never states. A
side that writes no verb matches every verb on the path and says so.
attach is the second half. Frontend and backend are separate repositories,
so they are separate planes, and an attachment records the one fact that joins
them — this plane may ask that one. It carries no vocabulary of its own:
what the question is, and what key the two sides match on, belongs to the verb
asking it — so any verb that needs to cross graphs reads the same record.
$ drsg attach fe --plane be --both # two planes on this drsg, both ways
$ drsg attach front --plane be --url http://127.0.0.1:36500 --as fe
$ drsg attachments --plane be --probe # does each one answer right now?
A plane on this drsg is read directly. One another drsg serves is read over
plane.query, the read method every drsg already serves — so crossing
instances adds no protocol and no SDK method, and an attachment that cannot be
reached is named with the reason rather than skipped. Every row names the graph
it came from, so an answer spanning two repositories still says where each
fact lives.
Two senses of “attached”. A plane whose source tree is synced is also called attached (
serve watch,synced_root). That is the tree behind one plane; an attachment here is another plane this one may ask. The two are unrelated.
A shared library, and the repositories that depend on it
“Which business repositories would this change break?” is the same question
impact already answers, asked one repository out. A change to a class in a
common library is local; its consequences are not, and a radius walked in one
plane cannot see them.
impact --across answers it in the other graphs too, by two keys both sides
already state — nothing new is extracted, and no plugin needed changing:
| join | this plane holds | a dependent holds |
|---|---|---|
| build | Manifest.declares — the coordinate its own manifest publishes | a node keyed by that coordinate, with version and scope on the DEPENDS_ON edge |
| code | the changed symbol, and every key in its radius | the same key as a stand-in, labelled External |
The coordinate is read from this plane, never mapped onto it. A Maven
coordinate (com.acme:common-base) and a Java package (com.acme.common…)
cannot be mapped to one another without a guess — which is why the deps
plugin records a build dependency and declines to join it to any import.
Standing in the library there is nothing to guess: its own pom.xml says
which coordinate it publishes, and a dependent’s pom.xml keys its
declaration the same way.
$ drsg impact com.acme.common.BaseEntity --plane commonlib --across all
com.acme.common.BaseEntity Class src/main/java/com/acme/common/BaseEntity.java:3-6
depth 1 (1):
com.acme.common.Dated Class src/main/java/com/acme/common/Dated.java:3-5 [EXTENDS]
total affected within depth 3: 1
note: recorded call edges only — calls the parser could not resolve (dynamic dispatch, untyped receivers) are absent, so this is a lower bound.
across commonlib, svcorders, svcbilling
in svcorders
declares com.acme:common-base 2.4.1 [runtime] pom.xml
com.acme.common.Dated ← 1
com.acme.orders.Order Class src/main/java/com/acme/orders/Order.java:5-8 [EXTENDS]
in svcbilling
declares com.acme:common-base 2.3.0 [runtime] pom.xml
2 graph(s) read, 2 hold(s) something: 2 declare(s) a dependency, 1 reach(es) it in code
1 of the 2 key(s) offered are referenced elsewhere
svcorders is reached through Dated rather than through the changed class
itself — a key the radius holds, not only its seed. svcbilling declares the
dependency at an older version and touches none of it, which is an answer
worth having.
Two scopes, because two environments ask this differently. --across alone
reads the planes this one attaches: explicit, and the only scope that can
reach another drsg. --across all reads every plane this instance holds, so a
machine that was handed thirty repositories needs no setup to be asked across
them; an empty plane and a …_git history plane are not among them. Either
way the answer names the graphs it read, and the ones it could not.
Without --across the answer is unchanged, byte for byte. Unlike wire,
which crosses graphs unasked because a route’s two halves live in different
repositories by nature, a radius is complete within one repository — adding
graphs changes what the total counts, so a caller says how far to look.
A graph another drsg serves answers with nodes, since that is what
plane.query returns and a version is a fact about an edge. It therefore
reports which keys it holds — itself the statement that something in it
references them, a stand-in existing only where a reference minted one — and
names the call that reads the rest:
across commonlib, far
in far at http://127.0.0.1:36600
declares com.acme:common-base (the manifest and its version are read there)
com.acme.common.Dated ← referenced; what references it is read there
ask that drsg for the rest: `impact <key> --plane svcorders`
One more distinction it keeps: a graph holding a key without External is
not a dependent but the same code again — vendored, or digested twice — and is
reported as holds its own rather than counted as a user.
Honesty is the load-bearing feature
An agent acts on what a tool tells it, so the family rule — a wrong edge is worse than a missing one — shapes every answer:
- An ambiguous name is never guessed at. Two symbols named
delete_node? The reply is the candidate list, and the exact-key retry costs one call — cheaper than one wrong answer confidently followed. - A call listing is a stated lower bound. What the parser could not
resolve is present as
UnresolvedRefentries with reasons, rendered right incontext— so “who calls this” comes back with the unresolved residue attached instead of silently shortened. - The graph names its blind spots in-band: the honesty footer, the
synced:line, elision counts. An agent can decide for itself when to fall back togrep— and thegrepverb is on the same surface, so the fallback is one more round trip, not a change of tools.
In benchmarks against a ripgrep workflow and two open-source code-graph MCP tools — same corpora, same tasks, one tool per agent — this combination completed every task shape (callers, impact, flow, a compound audit) in 2–4 tool calls at the lowest marginal token cost, and was the only tool whose answers state their own bounds. Methodology and full tables: AGENT-BENCHMARKS.md.
Building a plugin for a new language
The plugin system is open: a community parser built against the SDK installs and runs in the same sandbox as an official one, under the same contract and the same no-LLM rule. The pattern every official plugin follows — and the strongest advice for a new one — is to wrap a mature, ideally canonical parser (syn, swc, ruff, tree-sitter) rather than write one, and to keep the parser a plain native library under a thin component wrapper, so its tests need no wasm toolchain at all.
Rust
Depend on the SDK and implement either the two-phase contract or, for a format without cross-file work, the one-function facade:
[dependencies]
dr-strange-ext = { git = "https://github.com/wangyingsm/dr-strange-extension" }
[lib]
crate-type = ["cdylib"]
#![allow(unused)]
fn main() {
use dr_strange_ext::{Input, Manifest, Output, OutputExt, Simple, host, node, output, simple_plugin};
struct MyPlugin;
impl Simple for MyPlugin {
fn describe() -> Manifest {
Manifest { name: "mine".into(), version: "1".into(), extensions: vec!["xyz".into()] }
}
/// One subject at a time; the SDK derives parse/assemble from this.
fn process(subject: Input, _options: &[(String, String)]) -> Result<Output, String> {
let mut out = output();
if let Input::Files(paths) = subject {
for path in paths {
let bytes = host::read(&path)?;
out.nodes
.push(node(&path, "Thing").prop("bytes", bytes.len() as i64).build());
}
}
Ok(out.finish())
}
}
simple_plugin!(MyPlugin);
}
$ cargo build --release --target wasm32-wasip2
$ drsg plugin install target/wasm32-wasip2/release/my_plugin.wasm
A real language parser implements the generated Guest trait directly —
parse returns a serialized partial per chunk, assemble resolves across
all of them — and pulls neighbouring files through the host bindings. The
official plugins/rust is the worked example.
Go
Implement the ext.Plugin interface and build with TinyGo (≥ 0.41, with
wasm-tools on the PATH):
package main
import ext "github.com/wangyingsm/dr-strange-extension/sdk/go"
type mine struct{}
func (mine) Describe() ext.Manifest {
return ext.Manifest{Name: "mine", Version: "1", Extensions: []string{"xyz"}}
}
func (mine) Parse(subject ext.Subject, options map[string]string) ([]byte, error) {
// Pull files via ext.List / ext.Read; serialize your partial.
return []byte{}, nil
}
func (mine) Assemble(partials [][]byte, options map[string]string) (ext.Output, error) {
return ext.Output{Nodes: []ext.Node{{Key: "k", Label: "Thing"}}}, nil
}
func init() { ext.Register(mine{}) }
func main() {}
$ tinygo build -target=wasip2 -scheduler=none -gc=leaking \
--wit-package ./wit --wit-world drsg:preprocess-build/plugin-go -o mine.wasm .
The flags are load-bearing (the extensions repository’s justfile explains
why), and one rule runs through the Go SDK: copy everything lifted from the
ABI before use — a cm slice is a view the collector can move out from under
you.
What a good parser keeps
The family promises are
conventions, but an agent’s trust rests on them, so a new plugin should keep
every one: the language’s own qualified names as keys, file/line on every
fact, resolution stamps on every edge, the unresolved ledger instead of
guesses, External stand-ins for code outside the tree. Test the parser
natively against real source from the language’s ecosystem; run
just check-wit before building, so the vendored contract copies match the
canonical one.
To offer it to everyone: contributions start as an issue on the
extensions repository
naming the parser you would build on, and land as
plugins/<name>/{parser,component} with a native test suite — CI builds
every component and runs every suite on each push.
Architecture
This chapter documents the internal architecture. Dr Strange is built in distinct layers, each with a narrow seam to the next, so the engine can be embedded, served, or swapped at the storage level without disturbing the layers above.
The layers
| Layer | Responsibility |
|---|---|
| Storage | durable key/value with MVCC snapshots, behind a backend trait |
| Cache | decoded-object memoization, version-stamped by commit sequence |
| Computation | plan execution, graph algorithms, hybrid-retrieval fusion |
| API | the Database / PlaneHandle surface: queries, writes, indexes, time-travel, the change feed, snapshots |
| Planes | the cross-cutting namespace that scopes all data |
Above the core sit the wrapper layers: the JSON-RPC web backend and dashboard, the language SDKs, the CLI, the MCP server, and the LLM layer that powers natural-language query and ingestion.
The commit sequence
One primitive threads through every layer: the commit sequence — a monotonic number assigned to each committed write. It is the storage engine’s MVCC version, the cache’s coherence stamp, the freshness stamp on the index sidecars, the address for a time-travel read, and the sequence carried by every change event. A single, engine-wide clock keeps the layers consistent without separate coordination.
Storage
The graph layer is written against a backend trait — begin_read yields a
stable MVCC snapshot, begin_write a serialized single-writer transaction — over
a fixed set of logical tables (nodes, edges, forward/reverse adjacency, label and
property indexes, external keys, plane metadata). Keys are big-endian, so byte
order is scan order and every per-plane or per-node range is a contiguous prefix.
The default backend is a hand-rolled LSM engine:
- A write is appended to a write-ahead log — a length-prefixed, CRC32-checked
batch record,
fsynced before the change is published — and inserted into a versioned in-memory memtable stamped with the commit sequence. - When the memtable exceeds a threshold it is flushed to an immutable, sorted SST file (with a per-SST Bloom filter), and the WAL is rotated.
- A read merges the memtable over the SSTs, newest run first, returning the first version at or below its snapshot.
- Compaction merges accumulated runs into one, reclaiming versions no reader can still observe.
Because each version carries its commit sequence and SSTs are immutable, a reader holds no locks across its lifetime, and an old snapshot’s data remains reachable until compaction reclaims it.
Time-travel and retention
Time-travel falls out of the MVCC design almost for free. begin_read_at(seq)
pins a read to any past commit sequence, and the read path — which already
returns versions at or below a snapshot — serves it unchanged. The only
requirement is that the historical versions survive compaction, which a
retention window guarantees by flooring the compaction GC (unbounded by
default; a bounded window trades history for disk). A timestamp address resolves
to a commit by binary-searching a wall-clock time recorded on each commit. Only
the native backend retains prior versions, so time-travel is native-only.
The cache
The executor never touches storage directly; it reads through a cache with two tiers. A per-query L1 memoizes decoded nodes, edges, and adjacency for the life of one query (bound to its snapshot). A cross-query L2 — a bounded, version-stamped object cache — is shared by every query: an entry is served only when its stamp equals the reader’s commit sequence, so any write silently invalidates prior entries for later snapshots. This exact-sequence rule also makes a historical read cache-safe: it can never be served a newer version.
Computation
The executor runs a logical plan over a stream of rows, each carrying a current node and the path that produced it, so a read can return the connected subgraph rather than bare endpoints. Graph algorithms run over an index-dense frame of the plane’s adjacency, read-only over one snapshot. Hybrid retrieval gathers each channel independently, normalizes within-channel scores, and fuses them by weighted sum, with the graph channel seeded from the strongest hits.
Indexes
The vector (HNSW) and keyword (BM25) indexes are in-memory registries, their
declarations recorded in storage. On open, a registry loads from its sidecar
(.hnsw / .bm25) when the sidecar’s stamped commit sequence matches the data,
and otherwise rebuilds from the key/value store — which is always the source of
truth. Coherence is maintained at commit: a write transaction buffers the index
changes it implies and applies them to the registries under a write lock once the
data is durable. A snapshot serializes the live registries to sidecar bytes, and
an id-faithful restore reloads them without a rebuild.
The change feed
A Database accepts one commit-time observer. A write transaction buffers the
node and edge mutations it makes; at commit, once the data is durable, they are
collapsed per entity, resolved to records at the committed snapshot (with
embeddings and internal properties stripped), and handed to the observer. The web
layer registers an observer that publishes each change set into a broadcast
channel, which every plane.watch WebSocket subscriber drains — delivery is
best-effort, so a slow consumer drops overflow rather than stalling writers.
Backends
The storage backend is a compile-time choice: the native LSM engine (default), the legacy redb engine, or an in-memory engine for tests. Capabilities that depend on prior versions — time-travel, and its retention — exist only on the native backend and are feature-gated accordingly; the read seam compiles on every backend regardless.
The wire contract
The wrapper layers speak JSON-RPC 2.0. The drsg serve backend, the MCP
server, and the SDKs all use it, and the surface is described by an OpenRPC
schema that is the single source of truth: the SDKs are generated from it, the
server returns it from rpc.discover, and drift tests fail if the schema, the
dispatch table, and the generated clients ever disagree. The change-feed
subscription is a WebSocket extension of the same protocol.
Design documents
The detailed, per-layer design notes live under arch/ in the
repository — the source of truth this chapter summarizes.
Appendix A: JSON-RPC API List
This appendix is a reference for the JSON-RPC 2.0 methods exposed by drsg serve.
The OpenRPC schema at crates/dr-strange-web/openrpc.json is the authoritative
source — the server returns it from rpc.discover, and the SDKs are generated
from it.
Each entry shows the method, its access tier (read / write / admin;
under the single shared token all three require the same token), a one-line
summary, and its parameters. A parameter is written name type; ! marks a
required parameter. Types are JSON values; Properties is the property-map
dialect ({"$vector":[…]}, {"$desc":…,"$value":…}, {"$bytes":[…]}, and
{"$map":{…}} for a nested map whose own keys begin with $; integers must fit
a signed 64-bit value), and NodeRef is a node id or an external key.
Discovery and database
rpc.discover· read — the OpenRPC service description. Params: none.db.stats· read — plane/node/edge counts, labels, edge types, indexes, commits, on-disk size, memory (the process’s resident set, and the bytes the loaded plugins hold). Params: none.db.catalog· read — the soft-schema catalog across every plane. Params: none.db.mount· admin — serve the planes of another database alongside this one’s (issue #44). The directory must already be a database and must sit under[server] mount_root; a store another process holds is refused. Params:path,as?(prefix the mount’s plane names).db.unmount· admin — stop serving a mount, releasing its store’s lock.freedsays whether the lock came free before the call returned: a fold in flight holds it until it finishes. An absent name is reported, not an error. Params:name.db.mounts· read — what this instance serves: each mount with its directory and planes, and every plane with the source tree it records. Params: none.
Planes
plane.list· read — every plane with id, name, counts, properties. Params: none.plane.catalog· read — one plane’s soft schema. Params:planestring!.plane.indexes· read — the search indexes declared on a plane. Params:planestring!.plane.history· read — the time-travel window (native backend only). Params: none.plane.create· admin — create an empty plane. Params:namestring!,propertiesProperties.plane.rename· admin — rename a plane. Params:planestring!,tostring!.plane.set_props· admin — replace a plane’s property map. Params:planestring!,propertiesProperties!.plane.delete· admin — drop a plane and its contents. Params:planestring!.plane.attach· admin — record a plane this one may ask: one on this instance, or one another drsg serves (url). Params:planestring!,namestring!,urlstring,asstring,bothboolean.plane.detach· admin — forget an attachment by name (absent is a no-op). Params:planestring!,namestring!.plane.attachments· read — what a plane attaches, as recorded; nothing is contacted. Params:planestring!.
Nodes and edges
node.get· read — one node by id or external key. Params:planestring!,idinteger,keystring.node.create· write — add a node with optional key and labels. Params:planestring!,keystring,labelsarray,propertiesProperties.node.update· write — patch properties (set/unset) and labels. Params:planestring!,idinteger,keystring,setProperties,unsetarray,labelsarray.node.delete· write — delete a node, cascading its edges. Params:planestring!,idinteger,keystring.edge.create· write — add a directed edge between two nodes. Params:planestring!,srcNodeRef!,dstNodeRef!,typestring!,propertiesProperties.edge.update· write — patch properties (set/unset) or the type. Params:planestring!,edgeinteger!,setProperties,unsetarray,typestring.edge.delete· write — delete one edge. Params:planestring!,edgeinteger!.
Query and retrieval
plane.neighbors· read — 1-hop expansion as{node, edge}id pairs. Params:planestring!,idinteger!,directionstring,typestring,as_ofinteger,as_of_msinteger.plane.query· read — run a serialized logical plan. Params:planestring!,planobject!,as_ofinteger,as_of_msinteger.plane.cypher· write — run an openCypher-subset statement (write-gated). Params:planestring!,querystring!,embedstring,paramsobject.plane.find· read — text or semantic search over a plane. Params:planestring!,qstring!,limitinteger,semanticboolean,providerstring,embed_modelstring,as_ofinteger,as_of_msinteger.plane.search· read — vector top-k over a property. Params:planestring!,propertystring!,queryarray!,labelstring,kinteger,metricstring.plane.hybrid· read — fused vector + keyword + graph-proximity search. Params:planestring!,qstring!,labelstring,vector_propstring,keyword_propstring,metricstring,graph_hopsinteger,graph_decaynumber,w_vectornumber,w_keywordnumber,w_graphnumber,kinteger,candidatesinteger,providerstring,embed_modelstring.plane.algo· read — a graph algorithm over a plane or label subset. Params:planestring!,algostring!,labelstring,limitinteger,dampingnumber,max_itersinteger,tolerancenumber,srcinteger,dstinteger,dirstring,weightstring,max_levelsinteger,min_gainnumber.plane.ask· read — natural-language query → plan → run. Params:planestring!,questionstring!,dry_runboolean,max_attemptsinteger,limitinteger,providerstring,modelstring,embed_providerstring,embed_modelstring.graph.seed· read — an initial canvas of nodes plus induced edges. Params:planestring!,labelstring,limitinteger,orderstring (scan|degree|pagerank, defaultscan),as_ofinteger,as_of_msinteger. A rankedorderreturns the highest-scoring nodes rather than the first the scan reaches, and includes theirscores— preferdegreefor a skeleton, since PageRank pools rank in sinks.graph.expand· read — hub-safe 1-hop neighbourhood around a node. Params:planestring!,idinteger!,directionstring,typestring,limitinteger,as_ofinteger,as_of_msinteger.
Indexes and ingestion
index.ensure· admin — declare a vector or keyword index on(label, property). Params:planestring!,labelstring!,propertystring!,kindstring,metricstring,languagestring.digest.run· write — extract a node/edge proposal from text via the LLM (dry run). Params:planestring!,textstring!,chatstring,embedstring,modelstring,embed_modelstring,sourcestring,no_embedboolean,linkboolean,concurrencyinteger,chunk_charsinteger,modestring (coarse|fine|super, defaultfine— see Chapter 3;supercosts ~15× the input tokens).digest.write· write — write a previously-computed proposal (no LLM call). A proposed key the plane already owns is skipped by default, since a distillation proposes every entity as new. Params:planestring!,nodesarray!,edgesarray,on_conflictstring (error/skip/update; defaultskip).plane.vectorize· write — embed every node in a plane (unchanged texts are skipped) and ensure a vector index onembeddingper label; the provider key comes from the server’s environment. Params:planestring!,embedstring,embed_modelstring,metricstring.
Plugins
plugin.list· read — the installed preprocessor plugins, the same recordsdrsg plugin list --jsonprints. Params: none.plugin.catalog· read — the official catalog, read from the extensions repository’scatalog.jsonrather than compiled into the binary, so a plugin release needs no drsg release. Returns{stale, schema, source, plugins}; each entry carriesname,version,claims,url,sha256andcompat(ok,needs_host,other_contract), joinable againstplugin.list. Cached for an hour;stale: truemeans the fetch failed and this is the last copy the server kept. Params: none.plugin.install· write — download, validate, hash-pin and store a plugin from anhttp(s)URL (server-local paths are refused over RPC). Params:urlstring!.plugin.remove· write — uninstall a plugin by name. Params:namestring!.
WebSocket subscription
The /ws endpoint answers the same request/response methods and additionally
supports the change feed (these are WebSocket-only):
plane.watch· client → server — subscribe to a plane’s changes. Params:planestring!,labelstring.plane.unwatch· client → server — stop the subscription. Params: none.plane.change· server → client — a committed change set. Fields:plane,seq,truncated,changes.
Error codes
| Code | Meaning |
|---|---|
-32700 | parse error |
-32600 | invalid request |
-32601 | method not found |
-32602 | invalid params |
-32000 | application error (bad plane, dangling endpoint, conflict, …) |
-32001 | unauthorized (missing or invalid credential) |
Appendix B: Query-Language Grammar
The complete grammar of the openCypher subset, as the parser accepts it. The dashboard labels its query tab GraphQL; the language is not GraphQL but an openCypher subset, and this appendix is its reference. Chapter 4 explains what each construct is for; this one states exactly what parses.
Notation: | alternatives, […] optional, {…} zero or more, '…' a literal
token. Keywords are case-insensitive (match = MATCH); identifiers are
not.
Statement
statement ::= read-query | write-statement
A read compiles to a logical plan and returns rows; a write mutates the plane and returns change counts.
Reads
read-query ::= source { beam } [ where ] return
[ order-by ] [ skip ] [ limit ] [ as-of ]
Every source binds exactly one node pattern and may continue with a
relationship tail, so a typed hop follows a retrieval or algorithm seed exactly
as it follows a MATCH node.
source ::= 'MATCH' pattern
| 'SEARCH' node-pat vector-seed tail
| 'SEARCH' node-pat keyword-seed tail
| 'HYBRID' node-pat { hybrid-part } tail
| 'CALL' ident '(' [ call-args ] ')' 'ON' node-pat tail
tail ::= { rel-pat node-pat }
vector-seed ::= [ 'ON' ident ] 'NEAR' vec-arg [ 'METRIC' metric ] [ 'TOPK' uint ]
keyword-seed ::= 'ON' ident 'MATCHING' string [ 'TOPK' uint ]
hybrid-part ::= 'VECTOR' [ 'ON' ident ] 'NEAR' vec-arg
[ 'METRIC' metric ] [ 'WEIGHT' number ]
| 'KEYWORD' 'ON' ident 'MATCHING' string [ 'WEIGHT' number ]
| 'GRAPH' 'HOPS' uint [ 'DECAY' number ]
[ 'SEEDS' uint ] [ 'WEIGHT' number ]
| 'CANDIDATES' uint
| 'TOPK' uint
call-args ::= call-arg { ',' call-arg }
call-arg ::= ident ':' value
HYBRID requires at least one of VECTOR or KEYWORD — GRAPH only boosts
what those find. Parts may appear in any order, each at most once.
Traversal
beam ::= 'BEAM' node-pat direction [ ':' ident ] [ 'ON' ident ]
'NEAR' vec-arg [ 'METRIC' metric ] 'WIDTH' uint 'DEPTH' uint
direction ::= 'OUT' | 'IN' | 'BOTH'
pattern ::= node-pat { rel-pat node-pat }
node-pat ::= '(' [ ident ] [ ':' ident ] ')'
rel-pat ::= [ '<' ] '-' [ '[' rel-body ']' ] '-' [ '>' ]
rel-body ::= [ ident ] [ ':' ident ] [ var-len ]
var-len ::= '*' [ uint ] [ '..' [ uint ] ]
A relationship’s direction comes from its arrows: -[…]-> out, <-[…]- in,
-[…]- both. <-…-> is rejected. A relationship variable parses but is not
bound. var-len must have an upper bound (*1..3, *2, *..4); an unbounded
* or *2.. is a clear error.
Tail clauses
where ::= 'WHERE' expr
return ::= 'RETURN' [ 'DISTINCT' ] return-item { ',' return-item }
return-item ::= ident | '*' (* the rows themselves *)
| ( expr | aggregate ) [ 'AS' ident ]
aggregate ::= 'count' '(' '*' ')'
| agg-func '(' [ 'DISTINCT' ] expr ')'
agg-func ::= 'count' | 'sum' | 'avg' | 'min' | 'max' | 'collect'
order-by ::= 'ORDER' 'BY' order-key { ',' order-key }
order-key ::= ( expr | ident ) [ 'ASC' | 'DESC' ]
skip ::= 'SKIP' uint
limit ::= 'LIMIT' uint
as-of ::= 'AS' 'OF' ( uint | string | 'TIME' int )
RETURN n or RETURN * hands back whole records and must name the pattern’s
last variable. Any other item projects: it becomes a column named by
its AS alias or, without one, by the item as written, and it may read any
variable the pattern bound. A node cannot share a RETURN with columns — it is
not a value.
An aggregate folds each group; the grouping key is every column that is not
one. Folds skip nulls and values they cannot use; over nothing, count and
sum are 0, avg/min/max are null, and collect is the empty list.
In a projecting query DISTINCT compares whole projected rows, ORDER BY
names one of the returned columns (by alias, or by the expression it returned),
and SKIP/LIMIT count projected rows. Over RETURN n all four keep their
node reading. AS OF is last: a bare integer is a commit sequence, a quoted
string an RFC-3339 instant, and TIME takes unix-epoch milliseconds.
Expressions
Loosest to tightest:
expr ::= or-expr
or-expr ::= and-expr { 'OR' and-expr }
and-expr ::= not-expr { 'AND' not-expr }
not-expr ::= 'NOT' not-expr | comparison
comparison ::= additive [ 'IS' [ 'NOT' ] 'NULL'
| 'IN' '[' [ expr { ',' expr } ] ']'
| cmp-op additive ]
cmp-op ::= '=' | '<>' | '!=' | '<' | '<=' | '>' | '>='
additive ::= multiplicative { ( '+' | '-' ) multiplicative }
multiplicative ::= unary { ( '*' | '/' ) unary }
unary ::= '-' unary | primary
primary ::= '(' expr ')' | param | literal | term
term ::= ident '.' ident (* a property *)
| ident ':' ident (* a label test *)
| function
function ::= 'score' '(' ')'
| 'hops' '(' ')'
| 'key' '(' ident ')'
| ( 'similarity' | 'distance' )
'(' ident '.' ident ',' vec-arg [ ',' metric ] ')'
score() is the row’s score channel — a seed’s relevance, or an algorithm’s
per-node result. hops() is the path length. key(n) is the node’s external
key; on the source variable, key(n) = "…" and key(n) IN […] compile to a
key seek rather than a scan-and-filter. x IN [a, b] is sugar for equalities.
A WHERE condition may reference only one pattern variable — the compiler
places it at that variable’s position in the pipeline.
Writes
write-statement ::= 'CREATE' create-path { ',' create-path }
| 'MERGE' create-path { merge-on }
| 'MATCH' pattern [ 'WHERE' expr ] mutate-op { mutate-op }
merge-on ::= 'ON' ( 'CREATE' | 'MATCH' ) 'SET' set-item { ',' set-item }
mutate-op ::= 'SET' set-item { ',' set-item }
| 'REMOVE' remove-item { ',' remove-item }
| [ 'DETACH' ] 'DELETE' ident { ',' ident }
| 'CREATE' create-path { ',' create-path }
| 'MERGE' create-path { merge-on }
set-item ::= ident '.' ident '=' value
| ident ':' ident
| ident '+=' prop-map
remove-item ::= ident '.' ident | ident ':' ident
create-path ::= create-node { create-rel create-node }
create-node ::= '(' [ ident ] [ ':' ident ] [ prop-map ] ')'
create-rel ::= [ '<' ] '-' '[' ':' ident [ prop-map ] ']' '-' [ '>' ]
prop-map ::= '{' [ prop-entry { ',' prop-entry } ] '}'
prop-entry ::= ident ':' value
In a create-node, a string-valued key: entry sets the node’s external key
rather than becoming a property. A created edge must be directed and must name
a type. MATCH … mutate-op operates on the pattern’s terminal variable.
Terminals
ident ::= ( letter | '_' ) { letter | digit | '_' } (* Unicode letters *)
| '`' { any character except '`' } '`'
uint ::= digit { digit }
int ::= [ '-' ] uint
number ::= [ '-' ] digit { digit } [ '.' digit { digit } ] [ exponent ]
exponent ::= ( 'e' | 'E' ) [ '+' | '-' ] digit { digit }
string ::= '"' { string-char | escape } '"'
| "'" { string-char | escape } "'"
string-char ::= any character except the closing quote or '\\'
escape ::= '\\' ( '\'' | '"' | '\\' | 'n' | 't' | 'r' | 'b' | 'f' | 'u' hex hex hex hex )
vector ::= '[' [ number { ',' number } ] ']'
vec-arg ::= string | vector
metric ::= 'cosine' | 'dot' | 'l2'
param ::= '$' ident
literal ::= number | string | 'true' | 'false' | 'null' | vector
value ::= param | literal
Inside a string a backslash starts an escape — \', \", \\, \n, \t,
\r, \b, \f, \uXXXX — so a quote of the same kind is written escaped, a literal
backslash is written \\ (a Windows path is "C:\\data"), and an unknown
escape or an unterminated string is a syntax error. Identifiers are Unicode
words, or anything between backticks when the plain form cannot spell the name.
Whitespace between tokens is insignificant; there are no comments. A $name parameter stands where a value does, resolved from the
caller’s parameter map at parse time — the injection-safe way to pass values.
Defaults
| Omitted | Value | Why |
|---|---|---|
ON <property> before NEAR | embedding | what the ingestion pipeline writes |
METRIC | cosine | |
TOPK | 10 | |
HYBRID … CANDIDATES | 100 | per-channel pool before fusion |
GRAPH … DECAY | 0.5 | matches the RPC, MCP and CLI surfaces |
GRAPH … SEEDS | 10 | top hits per channel used as seeds |
WEIGHT | vector 1.0, keyword 1.0, graph 0.5 | |
CALL pagerank args | damping: 0.85, iterations: 20, tolerance: 1e-6 | |
CALL louvain args | max_levels: 10, min_gain: 1e-9 | |
CALL shortest_path dir | out | from and to are required |
MATCHING has no property default: keyword indexes are declared per (label, property) and those properties follow no convention, so ON is required — as
is a label, for the same reason.
Algorithms
CALL 'pagerank' ( [ damping: number ] [ iterations: uint ] [ tolerance: number ] )
CALL 'components' ( ) (* alias: connected_components *)
CALL 'louvain' ( [ max_levels: uint ] [ min_gain: number ] )
CALL 'shortest_path' ( from: key-or-id , to: key-or-id [ , dir: string ]
[ , weight: string ] )
key-or-id is a string (an external key) or a whole number (a node id). Each
algorithm reports its per-node result through score(): the rank for
pagerank, a dense 0-based group index for components and louvain, and the
node’s position along the path for shortest_path. An unknown algorithm or
argument name is an error, never a silently ignored setting.
Not supported
Each of these is a clear error, never a silent miscompile:
WITHpipelining — a projection is a tail, so nothing follows it.- returning the rows of any variable but the pattern’s last (
RETURN pafter a hop); its values project fine (RETURN p.name). - ordering a non-projecting query by any variable but the pattern’s last.
- predicates spanning two variables —
WHERE p.year < q.year. - reusing a pattern variable, which would express a graph constraint.
- branching patterns — one linear path per statement.
- unbounded variable-length relationships —
*,*2... - undirected or untyped created edges.
- comments and
WITH.
Appendix C: LLM Included or Not
Dr Strange calls an external language model for a specific, bounded set of features. Everything else — the graph store, vector and keyword indexes, hybrid retrieval over pre-computed vectors, graph algorithms, time-travel, the change feed, backup, and code digestion through the parser plugins (a repository that yields only parsed facts is ingested without a single model call) — runs with no model at all.
This appendix delineates exactly which features require model support, how to run
Dr Strange with none, and how to point the model features at a local model
instead of a hosted API. In all cases, provider API keys are read from the
server’s environment (or the [llm] configuration section), never from a request
or tool parameter.
What features need LLM support
A model is invoked in two situations: to embed text (turn a string into a vector), and to chat (generate structure or a plan). The features that depend on one or both:
| Feature | Needs | Why |
|---|---|---|
Embedding a text similarity query (SEARCH … NEAR "text", semantic plane.find, the search agent tool, a hybrid vector channel from text) | embedding provider | the query string is embedded server-side before the search |
Natural-language query (ask / plane.ask) | chat provider (+ embedding provider for the grounding tools) | the model compiles the question into a plan, optionally calling embedding-backed find_edge / find_entity tools |
Document ingestion (digest / AIgest) | chat + embedding providers | the model extracts entities and relations, then cleans up the extraction (--mode); the entities are embedded |
Everything else requires no model:
- Storing and searching vectors you already have. A vector is an ordinary
property; declare an index and search it with a literal vector
(
SEARCH … NEAR $vec). Only text queries need embedding. - Keyword search. BM25 is purely lexical.
- Graph queries and traversal.
MATCH,SEARCH … NEAR $vec,plane.query,plane.neighbors,graph.seed/graph.expand. - Graph algorithms. PageRank, components, shortest path, Louvain.
- Time-travel, the change feed, and backup/restore.
- The hybrid keyword and graph channels, and the vector channel when given a literal vector.
In short: the model is needed only to turn text into vectors, and to drive
ask and digest. If you embed your data with your own pipeline and query by
literal vector, keyword, and graph, Dr Strange needs no model.
Running Dr Strange without LLM
There are two independent ways to run without a model: simply not using the model features, and building a binary with the model code excluded entirely.
Not using the model features
The model-backed operations call a provider only when invoked. Supply no provider
keys and avoid ask, digest, and text-embedding queries, and the rest of the
system is fully functional. Embed your data with your own pipeline, store the
vectors as properties, and query by:
- literal-vector similarity —
SEARCH (d:Doc) ON embedding NEAR $vec TOPK 10, - keyword — a BM25 index (
index keyword …), - graph —
MATCH, traversal, and the graph algorithms.
A served instance still exposes ask and digest, but they return a clear error
when no provider key is configured; nothing else is affected.
Building without the model code
The command-line tool gates the model features behind the digest Cargo feature,
which pulls in the LLM crate. Build without it for a lean binary that has no model
dependency at all:
$ cargo build --release -p drsg --no-default-features --features native-backend
The resulting drsg omits the ask and digest commands; every other command
— planes, import/export, queries, indexes, hybrid (keyword and literal-vector
channels), algorithms, snapshot/restore, and serve — is unchanged.
Use local LLM / models
The model features do not require a hosted API. A provider is either a preset
name or a base URL, so any OpenAI-compatible endpoint — including a local one
— can serve chat and embeddings. A base URL is an operator’s setting — the CLI
flags and the configuration file take one; a request to drsg serve’s JSON-RPC
API may name only a preset, or the URL the server was configured with.
Ollama
Ollama exposes an OpenAI-compatible API locally. The
built-in ollama preset points at http://localhost:11434/v1, needs no key, and
defaults to llama3.1 for chat and nomic-embed-text for embeddings:
$ ollama pull llama3.1
$ ollama pull nomic-embed-text
$ drsg --db graph.drsg ask "which companies does Ada work for?" \
--plane social --chat ollama --embed ollama
$ drsg --db graph.drsg digest notes.md --plane social --apply \
--chat ollama --embed ollama
Override the models with --model and --embed-model as needed.
Any OpenAI-compatible server
A local inference server that speaks the OpenAI API — vLLM, LM Studio, llama.cpp’s server, and others — is addressed by passing its base URL as the provider, with the model named explicitly:
$ drsg --db graph.drsg ask "…" --plane social \
--chat http://localhost:8000/v1 --model my-chat-model \
--embed http://localhost:8000/v1 --embed-model my-embed-model
If the endpoint requires a key, set it in the environment and name its variable with the key-environment options; a keyless local server needs none.
In the dashboard, server, and MCP
The same providers apply everywhere the model is used. The AIgest page and the
semantic search selector list the presets, including ollama; the server and the
MCP host read the provider keys (or none, for a keyless local server) from their
environment. Chat and embedding providers are chosen independently, so a local
chat model may be paired with a local — or hosted — embedding model, and vice
versa.