Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CapabilityWhat it gives you
PlanesMany independent graphs in one database
First-class embeddingsVector properties, natively HNSW-indexed
Hybrid retrievalFused vector + keyword (BM25) + graph-proximity search
Query languageA serializable logical plan and an openCypher subset
Graph algorithmsPageRank, connected components, shortest path, Louvain
Natural-language queryAsk in plain language → plan → run
Time-travelRead the graph as of a past commit or timestamp
Change feedSubscribe to a plane and receive mutations live
Code digestionSandboxed wasm parser plugins turn a repository into a resolved call graph (8 official languages)
Commit-synced watchserve watch folds every commit into the plane
Agent toolscontext · search · describe · grep · trace · impact · fathom · snippet · history · recall · wire — one round trip each
Backup / restoreConsistent, id-faithful whole-database snapshots
Read-only replicasserve --follow mirrors a running server for read-scaling across a cluster
InterfacesWeb 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 variableEffect
--bin drsg-mcp (-Bin)DRSG_INSTALL_BINwhich binary — drsg (default), drsg-mcp, or all
--version v1.1.0 (-Version)DRSG_VERSIONpin a release instead of the latest
--dir <path> (-Dir)DRSG_INSTALL_DIRdestination 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

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_edge and find_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:

--modepasseswhat it buys
coarsevocabulary reconciliationone label per kind, one edge type per relationship
fine (default)+ identity resolutionone node per entity
super+ per-entity refinementproperties 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
-vread 812 of 840 files (96.7%)
-vvand each withheld file with the rule that withheld it
-vvvand 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:

SourceRows produced
ScanAllevery 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:

StepEffect
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
Distinctdeduplicate 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:

ClauseEffect
CREATEcreate nodes and edges
MERGEmatch an existing pattern or create it
SETadd or overwrite properties or labels
REMOVEremove properties or labels
DELETEdelete a node or edge
DETACH DELETEdelete 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 one RETURN. 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 MATCH node ((n:Person {name: "x"})): write them in WHERE (WHERE n.name = "x", or key(n) = "…"). In CREATE and MERGE the 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 of IN (or a vector after NEAR), a map only a CREATE/SET property map.
  • The % and ^ operators; arithmetic is + - * /.
  • Cross-variable predicates (p.year < q.year), returning the rows of an earlier variable (RETURN p after a hop — project p.name instead), 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:

AlgorithmRow orderscore()
pagerankmost important firstthe rank
componentsgrouped by componenta dense 0-based component index
louvaingrouped by communitya dense 0-based community index
shortest_pathsource → targetthe 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 import reads 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.seed also 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.

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:

LanguageLocationInstall from the repository
TypeScriptsdk/typescriptbun install && bun run build there, then "drsg": "file:…/sdk/typescript" in package.json
Pythonsdk/pythonpip install …/sdk/python (or pip install "drsg @ git+https://github.com/wangyingsm/dr-strange.git#subdirectory=sdk/python")
Gosdk/gogo get github.com/wangyingsm/dr-strange/sdk/go@<commit>, or a replace … => …/sdk/go directive
Javasdk/java./mvnw install there, then depend on io.github.wangyingsm:drsg:2.27.1
Csdk/cmake → libdrsg.a + include/drsg.h (needs libcurl + json-c)
Zigsdk/zigzig 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:

LanguageConstruct a clientExample call
TypeScriptnew Drsg({ baseUrl, token })await db.nodeCreate({ … })
PythonDrsg(base_url=…, token=…)db.node_create(…)
Godrsg.New(drsg.WithBaseURL(…), drsg.WithToken(…))db.NodeCreate(ctx, …)
Javanew Drsg(baseUrl, token)db.nodeCreate(…)
Cdrsg_client_new(base_url, token)drsg_node_create(…)
Zigtry 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

CommandPurpose
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 | showplane lifecycle
import <file> --planeload JSONL nodes/edges
export --planedump a plane as JSONL
get <node> --planefetch one node by id or @external-key
query <plan> --planerun a serialized logical plan (JSON, or -)
cypher <query> --planerun an openCypher-subset statement (or -)
catalog [--plane]print the soft schema (one plane or the whole database)
algo <name> --planerun a graph algorithm
hybrid <query> --planefused vector + keyword + graph-proximity search
index ensure | keyworddeclare a vector or keyword index
ask <question> --planenatural-language query
digest <file|dir|url> --plane [--mode]ingest a document, a repository, or a page and its links
search <query> --planesemantic lookup: embeds the query, returns the nearest nodes
context | describe | trace | impact | fathom <name> --planethe 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> --planea 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> --planeforget 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 --planeembed a plane’s nodes for similarity search
plugin install | list | removemanage preprocessor plugins (sandboxed wasm parsers)
snapshot <out> / restore <in>whole-database backup and restore
stats / checksummary 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:

PolicyEffect
error (default)Import nothing and name the colliding keys
skipKeep the existing node; drop the incoming one
updateOverwrite 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

ToolKindPurpose
list_planesreadlist planes with node/edge counts
describe_planereada plane’s soft schema (labels, properties, edge types, key prefixes)
get_nodereadfetch one node by id or external key
searchreadsemantic lookup — embeds the query, returns the k nearest nodes
contextreadone symbol’s whole neighborhood on a digested code plane — the primary agent verb
describereadone symbol’s properties, the lightweight node-only view
grepreadtext 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
tracereadthe shortest recorded call path between two symbols
impactreadeverything 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
fathomreadthe makeup of the region around a symbol — labels, edge types, hubs
snippetreada 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
historyreada 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
deployedreadwhat 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
recallreadcode 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
wirereadwhat 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
attachwriterecord a plane this one may ask: one on this instance, or one another drsg serves (url); both records the way back
detachwriteforget an attachment by the name it was made under
attachmentsreadwhat a plane attaches; probe asks each one served elsewhere whether it answers now
mountwriteserve 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
unmountwritestop 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
mountsreadwhat 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
traversereadneighborhood expansion from a node (1+ hops)
queryreadrun a serialized logical plan
cypherread / writerun an openCypher-subset statement — the escape hatch for what no verb anticipated; DELETE/REMOVE require confirm: true
algoreada graph algorithm (pagerank / components / shortest_path / louvain)
hybridreadfused vector + keyword + graph-proximity search
askreada natural-language question, compiled to a plan and run
write_nodeswritecreate nodes (batched)
write_edgeswritecreate edges (batched) by endpoint keys
create_planewritecreate an empty plane
drop_planewritedelete a plane and its contents (requires confirmation)
digestwriteingest a document (dry-run by default; apply: true requires confirm: true; mode sets extraction precision)
rebuildwritere-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. ask in particular compiles to a read-only plan and cannot mutate the graph.
  • Destructive writes are guarded; additive ones are not. drop_plane, a cypher statement carrying DELETE or REMOVE, and digest with apply: true all require confirm: true and refuse without it, naming the flag; digest defaults to a dry run that returns the proposed nodes and edges for inspection rather than writing them. write_nodes, write_edges and CREATE/MERGE/SET need 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 digest or serve watch keeps in step with a source tree records the commit it reflects, and cypher refuses CREATE/MERGE/SET/REMOVE/DELETE on 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_edges still 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:

  1. describe_plane to learn the labels, properties, and edge types in scope.
  2. search or hybrid to retrieve relevant nodes, then traverse to gather their neighborhood — grounded context for the model.
  3. ask or cypher to answer a specific question against the graph.
  4. 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:

  1. parse — the host splits the routed files into fixed-size chunks and runs parse over them in parallel, one fresh sandbox instance per call, sharing nothing. Each call returns a partial: opaque bytes the host shuttles but never reads.
  2. assemble — called once, with every partial in chunk order. This is where cross-file resolution lives — imports, headers, barrel re-exports, interface satisfaction — inside the plugin, because it is language semantics and the database refuses to hold any. The result must not depend on where the chunk boundaries fell.

Input arrives by pull, not push. A plugin is handed file paths, and reads what it needs through the host — which is how a code parser follows an import into a neighbouring file. A format with no cross-file structure can treat assemble as concatenation; the SDKs provide exactly that as a default.

The contract

The plugin ↔ host contract is one small WIT world, drsg:preprocess@1.0.0, canonical at the root of the extensions repository and vendored by drsg (just check-wit fails when any copy drifts):

interface host {
  %list: func(suffix: string) -> result<list<string>, string>;
  read:  func(path: string) -> result<list<u8>, string>;
  label: func() -> option<string>;
}

interface preprocessor {
  describe: func() -> manifest;                          // name, version, extensions
  parse:    func(subject: input, options: list<tuple<string, string>>)
              -> result<list<u8>, string>;               // one chunk → an opaque partial
  assemble: func(partials: list<list<u8>>, options: list<tuple<string, string>>)
              -> result<output, string>;                 // all partials, in order → facts
}

The three host functions are the capability grant: a plugin can reach exactly what is written there and nothing else. %list returns readable paths under the digested root, sorted — unsorted directory order would vary the output between runs, and re-ingesting a tree is meant to yield the same graph. read returns one file’s bytes, refusing any path that resolves outside the root (the check is on the resolved path, so .. and symlinks do not walk through it) — and any path inside it that %list would not have named: the same .gitignore, dotfile and build-directory rules apply to both, so a plugin cannot read a .env or an ignored credentials file just by asking for it. label names the input when its contents cannot.

A plugin’s output is nodes, edges, prose, and a report — counts of facts, prose characters, and skipped inputs, plus notes in words for whatever could not be resolved. Counted and named rather than dropped silently: a thin graph should be explained by its report, not investigated by re-running the ingest. options carries the plugin’s own settings from the operator’s [plugins.<name>] config section, passed through uninterpreted.

No LLM in a plugin — the rule

A plugin never calls a language model. Not the official ones, not a third-party one: the rule applies to every plugin, and it is enforced by the sandbox rather than requested of authors — no network (wasi:sockets is refused at load, by name), no environment, no filesystem beyond the three host functions, so there is no way to reach a provider or the key a call would need.

The division of labour is the point. A plugin’s job is what a parser can prove, deterministically; whatever genuinely needs a model — a doc comment’s meaning, a README, anything the parser cannot claim as fact — is returned as prose, and the host’s digest pipeline decides whether a model reads it, under the operator’s keys, budgets, and --mode. A repository that yields only facts is digested with no model call at all.

The same line is kept visible in the graph itself:

  • A parsed fact carries _generated_by instead of _model, so it is always distinguishable from a model’s extraction. Where both claim one key, the fact wins and the model’s claim is dropped and counted. The stamp names the plugin, its version, and the artifact that produced the fact — rust@3+fdb01bb6, the suffix being the leading bytes of the wasm’s pinned SHA-256 — so two facts made by the same version of a plugin from different bytes never read as one.
  • Determinism is part of the contract, not an aspiration. The sandbox freezes the clock, deals entropy from a fixed sequence, and sorts directory listings — so digesting the same tree twice yields byte-identical facts. A model call inside a plugin would break exactly this.

The sandbox

Every plugin is a wasm32-wasip2 component running under a deny-everything grant. A guest runtime may import wasi:filesystem — Go’s does, before the plugin’s first line runs — but the preopen table behind it is empty; wasi:sockets is refused at load, by name; clocks are frozen; entropy is fixed; and each call runs under instruction and memory budgets, with a wall-clock deadline as the net beneath them and a process-wide memory budget across the calls running at once. A trapped guest’s stderr is captured into the error the operator sees, along with the trap code itself. Whatever a plugin produces comes back as a return value — only the host writes to the database.

A parse call handles one file, so a plugin that trips over one is one file’s worth of loss: it is skipped and counted, and the report names it. Generated source is the usual culprit — a .pb.go whose descriptor blob is a thousand-term string concatenation will walk a recursive printer straight off the stack the plugin was linked with, which is the plugin author’s to fix and no host setting can raise. A plugin that fails on every file it claimed is a different matter, and still refuses the run.

assemble carries no such bound, and the difference is worth knowing. It runs once, over every partial at once, so a trap there is not one file’s loss: it fails the plugin outright, and with it the routing pass folding the tree. One file the parser chokes on, in a language that is a rounding error in the repository, can therefore empty the plane — and under drsg init --rebuild, which drops the plane before folding it again, that is destructive rather than a no-op. Until the host isolates a trapping plugin from the rest of the route, read an assemble failure as an outage rather than a gap in the report: the operator’s recourse is to reinstall the plugin’s previous version while the parser is fixed.

The budgets are tunable in drsg.toml (Chapter 2):

[plugins]
fuel = 200000000000    # instructions per sandbox call (0 disables the check)
memory_mb = 3072       # linear memory per call, MiB (wasm32 itself allows at most 4096)
deadline_secs = 300    # wall-clock ceiling per call, seconds (0 disables it)
total_memory_mb = 6144 # linear memory all calls in the process may hold together (0 = twice memory_mb)
hostcall_mb = 1024     # what one call may copy out of the sandbox, MiB (0 = wasmtime's 128)

[plugins.rust]         # a plugin's own settings pass through untouched
include_source = true

deadline_secs is the wall-clock ceiling on one call (default 300; 0 disables it) — not a budget for work, which fuel is and deterministically so, but what stops a plugin that never returns once fuel has been switched off. total_memory_mb is what every call in the process may hold together (default twice memory_mb): parse runs one call per core, and a per-call ceiling alone would let a wide machine be filled a store at a time. Both can be overridden from the shell for one run — DRSG_PLUGINS_DEADLINE_SECS and DRSG_PLUGINS_TOTAL_MEMORY_MB, same units and same reading of 0 — and the environment wins over the file, as with every other DRSG_* variable. A refusal names the ceiling it hit, so the operator knows which knob it was. Turning fuel off also switches the sandbox to compiling each plugin from its wasm on every load, since the precompiled artifacts carry the fuel instrumentation; the first load says so once.

hostcall_mb bounds what one call may copy out of the sandbox — a host function’s arguments, or an export’s return value. Leave it out (or set 0) and wasmtime’s own 128 MiB applies. parse returns one file’s partial and stays far below it; assemble returns the whole tree in one value, so a large repository is the first to pass it, and the error then names this setting: plugin `go` copied more out of the sandbox in one call than the 128 MiB limit allows. Raise it in drsg.toml:

[plugins]
hostcall_mb = 1024

There is no environment variable for it.

One boundary follows from the pull model: preprocessing runs where the files are. The CLI and the stdio MCP server route through it; bytes sent to a shared drsg serve over the wire stay prose. The deliberate exception is serve watch, where the operator points the server at a repository on its own machine — an explicit filesystem grant — so commit folds run through the installed plugins.

The official catalog

Thirteen official plugins cover the common languages and two cross-cutting sources, each wrapping a mature parser rather than reinventing one:

PluginClaimsParser underneath
rust.rssyn
go.goGo’s own go/parser, compiled with TinyGo
ts.ts .tsx .mts .cts .js .jsx .mjs .cjsswc — ESM and CommonJS
py.py .pyi .pywruff’s parser
java.javatree-sitter-java
kotlin.kt .ktstree-sitter-kotlin-ng
csharp.cstree-sitter-c-sharp
c.c .htree-sitter-c
web.html .htm .csstree-sitter html/css/js — one plugin, so class="btn" binds to the stylesheet defining .btn
toml.tomltoml
xml.xmlquick-xml — by the dialect the file declares
git(a git checkout)git’s own object database, read directly — flate2 for the zlib streams. Dispatched by the source being a checkout rather than by extension, and writes the history plane beside the code one
deps(manifests, by name)toml_edit and quick-xml: package.json, go.mod, pyproject.toml, requirements.txt, pom.xml. A manifest is a filename, not an extension — Cargo.toml stays with the toml plugin

Each releases at its own pace as a <plugin>-vX.Y.Z tag; CI builds the component and publishes <plugin>.wasm with its SHA-256 on the releases page.

The list of those releases — catalog.json — lives in the extensions repository beside the plugins, not in this binary. That is the point: a catalog compiled into drsg would make every plugin release a drsg release, for a fact drsg only repeats. So drsg plugin install fetches it, and a plugin’s name is enough to install it:

$ drsg plugin install rust          # a name from the catalog
$ drsg plugin install              # or the interactive list, 0 = all
$ drsg plugin list --available     # the catalog, tagged against this store

Fetching the list does not mean trusting it blindly. Each entry pins the artifact’s SHA-256, checked on download before the bytes are even looked at as a component; and each entry says which host it is for — contract, the WIT world it was built against, and min_drsg, the oldest host it claims to work with. An entry this build cannot honour is listed with the reason, not hidden: a plugin that silently vanishes from the list is a support question, while “needs drsg >= 3.0.0” is an answer. Several entries may share a name, which is how a plugin keeps serving older hosts — each host installs the newest entry it can run.

Every successful fetch is cached beside the installed plugins, so an offline drsg plugin install still shows the list and says how old it is. With no cache and no network it fails naming the URL, because a plugin can still be installed without a catalog at all — a path or a URL needs no list.

Behind a proxy, or with no network at all

drsg plugin install honours ALL_PROXY, HTTPS_PROXY and HTTP_PROXY (also read lowercase), or [network] proxy in drsg.toml; see Getting Started. socks5:// works as well as http://.

When the machine that runs drsg has no route out at all, install from a file. The catalog is only a lookup table, and nothing about the artifact’s integrity depends on having fetched it — the SHA-256 is checked either way:

# On a machine that can reach GitHub — the catalog names both the artifact
# and its hash, so read them from it rather than guessing a release path.
# A name may have several entries, one per host generation; pick the one
# your drsg can run (`drsg plugin list --available` prints that reasoning).
$ curl -fsSL https://raw.githubusercontent.com/wangyingsm/dr-strange-extension/master/catalog.json \
    | jq -r '.plugins[] | select(.name=="ts") | "\(.version)\t\(.url)\t\(.sha256)"'
2.0.0	https://github.com/…/ts.wasm	9f86d081…

$ curl -fLo ts.wasm 'https://github.com/…/ts.wasm'
$ echo '9f86d081…  ts.wasm' | sha256sum -c -
ts.wasm: OK

# Carry ts.wasm across, then on the offline machine:
$ drsg plugin install ./ts.wasm

Installing from a path pins that file’s SHA-256 in the store exactly as a catalog install would, so every later load re-checks it. Verify the checksum yourself before installing: an install from a path cannot compare against a catalog entry it never fetched, so that step is the one the catalog would otherwise have done for you.

Install pins the artifact’s SHA-256 in the store; every later load re-checks it, so a file that changes on disk is refused rather than silently run. Installing a name again is the upgrade path.

A plugin installed after the graph was built

Installing a plugin does not reach back into a graph that already exists. A watched repository folds one commit at a time, so a new parser applies to the files the next commits touch and every untouched file keeps whatever the old set made of it — half the tree parsed one way, half the other. Nothing is corrupt; the graph is simply two parses.

So a plane records the plugin builds installed when it was built, and says so when they no longer match:

synced: commit ec31cac58593
plugins changed since this plane was built (added since: ts@2+d99f6db4) — files
the difference claims are parsed only where a later commit touched them; rebuild
to make the graph whole

That line is on every answer read from the plane, including “no symbol matches” — the answer where it matters most, because an absence may mean the symbol is not there or that its file was never parsed.

The comparison is on the full identity, name@version+build, so a plugin rebuilt at the same version counts as a change: the same version built differently can parse differently. Both directions are reported — added since for a parser whose files were never read, no longer installed for facts in the graph that came from something the store no longer has.

The cure is a rebuild, which re-reads the whole tree:

$ drsg init --rebuild

An agent that reads the warning can do it without leaving the conversation, with the rebuild MCP tool — it takes only a plane name and reads that plane’s own recorded directory, so it cannot be aimed somewhere else. Either way the warning clears itself once the graph is one parse again.

A plane built before drsg recorded this says nothing: unknown is not drifted, and warning about every older plane would teach readers to ignore the warning.

Install also compiles the plugin once and keeps the compiled form beside the wasm, pinned by a hash of its own. A load — every digest, every commit serve watch folds — deserializes that in milliseconds instead of compiling, which is seconds of CPU and hundreds of MiB for the official parsers. The compiled form belongs to the drsg build that made it: after drsg update, or in a store from before it existed, the first load compiles as before and stores the result for the next one.

What every official parser promises

The ten parsers are one family, built to one discipline:

  • Keys are the language’s own qualified names — crate::module::fn, pkg.Type.Method, file.c::func, index.html#map — never invented ids.
  • Everything carries its location: a definition its file and line, an edge the line it is written on.
  • Resolved edges say how they were resolved: each carries _resolved_by (which rule matched), _confidence (a band, not a decimal), and _ref (the text as written at the call site).
  • What cannot be known is counted, never guessed. A call whose receiver type the source does not declare becomes an UnresolvedRef ledger entry with a _reason — queryable in the graph and rendered in context — rather than a plausible edge. A wrong edge sends an agent somewhere wrong; a missing one, honestly declared, sends it to grep.
  • External things are stand-ins, keyed by the path as written and labeled External: “this code uses that” is recorded without pretending to have read code it never saw.

Those promises are what the Coding Agent chapter builds on — and what a community plugin should keep, which that chapter’s second half walks through.

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):

VerbThe question it answers
contexteverything 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
describeone symbol’s properties — the lightweight node-only view
greptext 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
tracehow one symbol reaches another: the shortest recorded call path
impactblast 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
fathomwhat kind of place a symbol sits in: the region within a few hops, by label and edge type, with its hubs
snippeta symbol’s source text, or a range of a file (path:start-end) — the sed -n an agent no longer needs
recallthe 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
historythe repository behind the code: where HEAD is, what the branches and tags point at, what was rebased, and the newest commits
deployedwhat commit a running thing was built from, and what runs a commit — the manifests’ ops plane joined to the history beside it
wirewhat 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:

jointhis plane holdsa dependent holds
buildManifest.declares — the coordinate its own manifest publishesa node keyed by that coordinate, with version and scope on the DEPENDS_ON edge
codethe changed symbol, and every key in its radiusthe 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 UnresolvedRef entries with reasons, rendered right in context — 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 to grep — and the grep verb 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

LayerResponsibility
Storagedurable key/value with MVCC snapshots, behind a backend trait
Cachedecoded-object memoization, version-stamped by commit sequence
Computationplan execution, graph algorithms, hybrid-retrieval fusion
APIthe Database / PlaneHandle surface: queries, writes, indexes, time-travel, the change feed, snapshots
Planesthe 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. freed says 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: plane string!.
  • plane.indexes · read — the search indexes declared on a plane. Params: plane string!.
  • plane.history · read — the time-travel window (native backend only). Params: none.
  • plane.create · admin — create an empty plane. Params: name string!, properties Properties.
  • plane.rename · admin — rename a plane. Params: plane string!, to string!.
  • plane.set_props · admin — replace a plane’s property map. Params: plane string!, properties Properties!.
  • plane.delete · admin — drop a plane and its contents. Params: plane string!.
  • plane.attach · admin — record a plane this one may ask: one on this instance, or one another drsg serves (url). Params: plane string!, name string!, url string, as string, both boolean.
  • plane.detach · admin — forget an attachment by name (absent is a no-op). Params: plane string!, name string!.
  • plane.attachments · read — what a plane attaches, as recorded; nothing is contacted. Params: plane string!.

Nodes and edges

  • node.get · read — one node by id or external key. Params: plane string!, id integer, key string.
  • node.create · write — add a node with optional key and labels. Params: plane string!, key string, labels array, properties Properties.
  • node.update · write — patch properties (set/unset) and labels. Params: plane string!, id integer, key string, set Properties, unset array, labels array.
  • node.delete · write — delete a node, cascading its edges. Params: plane string!, id integer, key string.
  • edge.create · write — add a directed edge between two nodes. Params: plane string!, src NodeRef!, dst NodeRef!, type string!, properties Properties.
  • edge.update · write — patch properties (set/unset) or the type. Params: plane string!, edge integer!, set Properties, unset array, type string.
  • edge.delete · write — delete one edge. Params: plane string!, edge integer!.

Query and retrieval

  • plane.neighbors · read — 1-hop expansion as {node, edge} id pairs. Params: plane string!, id integer!, direction string, type string, as_of integer, as_of_ms integer.
  • plane.query · read — run a serialized logical plan. Params: plane string!, plan object!, as_of integer, as_of_ms integer.
  • plane.cypher · write — run an openCypher-subset statement (write-gated). Params: plane string!, query string!, embed string, params object.
  • plane.find · read — text or semantic search over a plane. Params: plane string!, q string!, limit integer, semantic boolean, provider string, embed_model string, as_of integer, as_of_ms integer.
  • plane.search · read — vector top-k over a property. Params: plane string!, property string!, query array!, label string, k integer, metric string.
  • plane.hybrid · read — fused vector + keyword + graph-proximity search. Params: plane string!, q string!, label string, vector_prop string, keyword_prop string, metric string, graph_hops integer, graph_decay number, w_vector number, w_keyword number, w_graph number, k integer, candidates integer, provider string, embed_model string.
  • plane.algo · read — a graph algorithm over a plane or label subset. Params: plane string!, algo string!, label string, limit integer, damping number, max_iters integer, tolerance number, src integer, dst integer, dir string, weight string, max_levels integer, min_gain number.
  • plane.ask · read — natural-language query → plan → run. Params: plane string!, question string!, dry_run boolean, max_attempts integer, limit integer, provider string, model string, embed_provider string, embed_model string.
  • graph.seed · read — an initial canvas of nodes plus induced edges. Params: plane string!, label string, limit integer, order string (scan | degree | pagerank, default scan), as_of integer, as_of_ms integer. A ranked order returns the highest-scoring nodes rather than the first the scan reaches, and includes their scores — prefer degree for a skeleton, since PageRank pools rank in sinks.
  • graph.expand · read — hub-safe 1-hop neighbourhood around a node. Params: plane string!, id integer!, direction string, type string, limit integer, as_of integer, as_of_ms integer.

Indexes and ingestion

  • index.ensure · admin — declare a vector or keyword index on (label, property). Params: plane string!, label string!, property string!, kind string, metric string, language string.
  • digest.run · write — extract a node/edge proposal from text via the LLM (dry run). Params: plane string!, text string!, chat string, embed string, model string, embed_model string, source string, no_embed boolean, link boolean, concurrency integer, chunk_chars integer, mode string (coarse | fine | super, default fine — see Chapter 3; super costs ~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: plane string!, nodes array!, edges array, on_conflict string (error / skip / update; default skip).
  • plane.vectorize · write — embed every node in a plane (unchanged texts are skipped) and ensure a vector index on embedding per label; the provider key comes from the server’s environment. Params: plane string!, embed string, embed_model string, metric string.

Plugins

  • plugin.list · read — the installed preprocessor plugins, the same records drsg plugin list --json prints. Params: none.
  • plugin.catalog · read — the official catalog, read from the extensions repository’s catalog.json rather than compiled into the binary, so a plugin release needs no drsg release. Returns {stale, schema, source, plugins}; each entry carries name, version, claims, url, sha256 and compat (ok, needs_host, other_contract), joinable against plugin.list. Cached for an hour; stale: true means 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 an http(s) URL (server-local paths are refused over RPC). Params: url string!.
  • plugin.remove · write — uninstall a plugin by name. Params: name string!.

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: plane string!, label string.
  • plane.unwatch · client → server — stop the subscription. Params: none.
  • plane.change · server → client — a committed change set. Fields: plane, seq, truncated, changes.

Error codes

CodeMeaning
-32700parse error
-32600invalid request
-32601method not found
-32602invalid params
-32000application error (bad plane, dangling endpoint, conflict, …)
-32001unauthorized (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

OmittedValueWhy
ON <property> before NEARembeddingwhat the ingestion pipeline writes
METRICcosine
TOPK10
HYBRID … CANDIDATES100per-channel pool before fusion
GRAPH … DECAY0.5matches the RPC, MCP and CLI surfaces
GRAPH … SEEDS10top hits per channel used as seeds
WEIGHTvector 1.0, keyword 1.0, graph 0.5
CALL pagerank argsdamping: 0.85, iterations: 20, tolerance: 1e-6
CALL louvain argsmax_levels: 10, min_gain: 1e-9
CALL shortest_path diroutfrom 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:

  • WITH pipelining — a projection is a tail, so nothing follows it.
  • returning the rows of any variable but the pattern’s last (RETURN p after 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:

FeatureNeedsWhy
Embedding a text similarity query (SEARCH … NEAR "text", semantic plane.find, the search agent tool, a hybrid vector channel from text)embedding providerthe 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 providersthe 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.