rift

Configuration

Since v0.0.5

Rift reads rift.toml from the workspace root. The generated rift.schema.json defines the file and rejects any key it doesn't know, so a typo fails at load rather than being silently ignored. Edits take effect for later requests once validation passes; while the file is invalid, requests fail with configuration_invalid until you fix it.

An editor that supports TOML schema directives can load the generated schema:

#:schema https://volar.sh/rift/rift.schema.json

Treat rift.toml as executable workspace configuration: a selected hook runs with the server's OS permissions after an applied change, and an LSP process starts with those permissions on its first request. Inspect the file before running Rift in a cloned repository.

Each section states its defaults below its example. Durations take ms, s, m, h, or d; byte sizes take b, kb, mb, gb, or tb - binary units, so 1kb is 1024 bytes.

Server

Since v0.0.8

server bounds the server's own blocking work - the filesystem scans and parses behind reads, changes, and index rebuilds:

[server]
num_workers = 4
worker_queue_timeout = "30s"
idle_timeout = "30m"
readiness_timeout = "30s"
  • num_workers caps the workers running blocking operations at once, 1 to 64
  • worker_queue_timeout bounds one request's wait for a free worker, 1ms to 1h - when it expires, the request fails with temporarily_unavailable and the client can retry without stacking another waiter
  • idle_timeout stops the server once no request remains active and that span has passed after the last response completed, 1s to 1d; the next rift mcp or rift server start brings it back
  • readiness_timeout bounds one request's whole wait for the workspace's index and its LSP processes to prove they're ready to answer, 1s to 1h - index validation starts the clock, and once the request has resolved which LSP processes it needs, waiting on them spends whatever time is left
  • port pins the exact loopback port, 1024 or above; omitted, the server picks the first free port from port_range - port_range = { min = 12000, max = 13000 } is the default - and the two keys exclude each other

The worker pool exists before the first request, so the server sizes it once at startup: a change to this table applies on the next start, while every other table applies to later requests as soon as the file is accepted.

Logs

Since v0.0.22

logs bounds what the server records about itself. Diagnostics admitted by capture enter a bounded queue and land in the workspace database, where the rift://logs resource reads them back for an agent and rift server logs reads the same records for an operator - so a refusal an agent meets can be explained without an operator reading the server's terminal:

[logs]
retention_records = 50000
page_records = 500
capture = "rift=info,rift_mcp=info,rift_server=info"
  • retention_records caps the records the store keeps, 100 to 1000000; a write past the cap drops the oldest records first
  • page_records caps what one rift://logs read returns, 1 to 5000
  • capture selects which targets are recorded, in the RUST_LOG spelling tracing takes - rift_mcp=debug records the index lane's own rebuild decisions. A target this filter excludes never reaches the store, whatever the terminal shows

The server reads this table at startup, so a change applies on the next start. The records outlive the process that wrote them: they live beside the search index in .rift/db, and a workspace whose index will not settle still answers rift://logs.

Source

Since v0.0.6

source controls which files below the workspace root the index and reads consider visible. include and exclude take exact project-relative paths or globs, matched the same way search's paths selector matches them:

[source]
include = []
exclude = []
respect_gitignore = true

Empty include includes every file exclude and .gitignore leave standing; a match in exclude drops a file even when include also matches it. respect_gitignore, on by default, applies the workspace's own .gitignore files, root and nested; the server never reads global or parent-directory ignore sources.

.git, .rift, and target stay invisible whatever this table says: no include, exclude, or .gitignore entry can make the server serve them.

source shapes what the index walks: a file it excludes stops reaching search and get_symbol results. A single search request can still reach one such file on demand, through the request-side force_include override on search's paths selector.

Languages and LSP

Since v0.0.25

An exact language entry connects visible paths to shipped syntax, execution permission, and an optional LSP process. Its key is a language name, or name:dialect for an exact dialect:

[languages.rust]
enabled = true
include = ["**/*.rs"]
exclude = ["vendor/**"]
execution = false
lsp = "rust"

[lsp.rust]
command = ["rustup", "run", "1.98", "rust-analyzer"]
environment = {}
startup_timeout = "30s"
request_timeout = "1m"
output_limit = "4kb"

[lsp.rust.retry]
attempts = 8
delay = "250ms"
delay_limit = "2s"

[lsp.rust.restart]
attempts = 3
window = "5m"

Every shipped syntax provider contributes its file patterns. An absent include keeps those patterns; a present list replaces them, and an empty list matches no file. exclude removes matches from the effective list. enabled defaults to true, exclude defaults empty, execution defaults to false, and lsp is absent by default. A bare language and each of its dialect entries are independent. enabled = false turns off syntax, LSP service, and execution for paths the entry matches.

A language no shipped syntax provider knows must carry a nonempty include list. It can still select an LSP process and execution permission. One visible path may match one exact language; a current path two entries claim refuses the workspace candidate, naming the path and both identities, and the previously published revision keeps serving. A visible file no language claims remains available to generic reads, and joins lexical search as plain text when [search.text].include selects it. So does a file whose entry is disabled, or whose language ships no grammar: what those entries drop is syntax analysis, not the file.

The lsp key is what turns the engine-served tools on: rename_symbol and move_file refuse unsupported for a language whose entry selects no process. Reads and the direct change tools answer either way.

lsp accepts an inline process table or the name of one shared top-level process. The inline table collapses to one key when the program takes no arguments:

[languages.rust]
lsp.command = "rust-analyzer"

A named process may serve several exact identities, including dialects:

[languages.typescript]
lsp = "typescript"

[languages."typescript:tsx"]
lsp = "typescript"

[lsp.typescript]
command = ["node_modules/.bin/typescript-language-server", "--stdio"]

[lsp.typescript.initialization_options.tsserver]
useSyntaxServer = "never"
Since v0.0.31 · Python

An entry may select the embedded engine instead of a spawned command. The build links the engine in, so nothing is installed or started beside the server:

[languages.python.lsp]
embedded = "ty"

embedded names an engine this build carries; ty serves Python. Exactly one of command and embedded is present, and an embedded engine takes no environment and no initialization_options: both configure a spawned process, so a table naming them beside embedded is refused rather than silently ignored.

command accepts a program string without whitespace or a nonempty list holding the program and its literal arguments. The list holds one program and at most 64 arguments; each argument holds at most 4096 bytes. Rift starts the command directly without a shell. Absolute programs are refused. A bare name resolves through the server's PATH, with environment applied on top. initialization_options is absent by default and must be a JSON object when present; environment defaults empty and holds at most 64 entries.

startup_timeout defaults to "30s" and takes "1s" to "10m". request_timeout defaults to "1m" and takes "1s" to "10m". output_limit defaults to "4kb" and takes "1kb" to "8mb"; Rift still reports the full size, so you can tell a truncated log from a short one.

retry bounds repeated requests while an answer stays unsettled. attempts includes the first request; waits start at delay, double, and stop at delay_limit. restart bounds replacement processes after a process ends or stops answering. A replacement older than window stops counting. The server starts a process on its first request and keeps it across later requests. Configuration reload reuses unchanged process definitions and ends replaced processes.

retry.attempts defaults to 8 and takes 1 to 64. retry.delay defaults to "250ms" and takes "1ms" to "1m"; retry.delay_limit defaults to "2s" and takes "1ms" to "10m". restart.attempts defaults to 3 and takes 0 to 16; restart.window defaults to "5m" and takes "1s" to "1d".

A named process that no language selects is accepted but never started. A language that names a missing process is refused during configuration validation. The top-level table holds at most 16 processes. The workspace holds at most 64 language entries. Each language include and exclude list holds at most 64 patterns.

A language server may decline a request it's already serving, report that the file moved under it, or answer before it has loaded the declaration. Every such refusal gets the configured retry schedule. If every attempt refuses, Rift returns the language server's latest words.

A capability the server never advertised is not a refusal and returns at once.

A server still loading your project answers anyway, and answers thinly: rust-analyzer reports no diagnostics at all for a file it hasn't analyzed, and refuses a rename with No references found at position for a declaration it hasn't indexed. Neither is its verdict.

LSP's $/progress traffic tells Rift when work remains outstanding, so an answer that arrives during that work is provisional. Progress doesn't bind completed work to one semantic request, so an empty answer or a refusal stays bounded by the retry schedule after progress ends.

Before the first announcement arrives there's nothing to read, and one shape of thin answer says exactly what a settled server says: a move_file answered with no edit, and a diagnostics pull answered with no finding, are also what a loaded server answers for a move with no import to rewrite and for a file that's clean. Every empty move or reference answer is therefore asked through the retry schedule while one document remains open. A classified workspace change invalidates readiness from earlier work.

A move that spends the schedule with no proposal applies with a rift.move.references_not_updated warning, while an unconfirmed empty reference check refuses unless forced. A nonempty full diagnostic report can settle after progress ends. An empty full report needs equal evidence at the final attempt; a report still changing at that bound becomes rift.engine.unready, not an empty finding list.

A workspace/diagnostic/refresh request from the language server invalidates an earlier report before that comparison.

The waits grow from delay and stop growing at delay_limit, which keeps the resends off a process that is busy indexing. At the defaults a request waits at most 9.75s across its eight asks, which covers a rust-analyzer cold start on a small project.

What the caller sees when the attempts run out depends on what was being asked. A rename asks before anything is written, so the call fails temporarily_unavailable with the tree untouched when analysis remains outstanding. Move and reference checks carry their stated warning or refusal when final emptiness remains unconfirmed.

A diagnostics pull runs after the change has already applied, so it can't fail the call: the summary carries rift.engine.unready when the process never settled, or rift.engine.failed when it failed outright, rather than an empty finding list that would tell the caller the changed file is clean.

restart bounds what Rift does when the process itself fails. One that ended, that couldn't be started, or that stopped answering is replaced automatically, and the replacement serves the request. Each replacement spends one of restart.attempts, and a replacement older than restart.window stops counting, so a process that dies once a week never runs out.

One that crash-loops does: once the budget is spent, requests for its languages fail temporarily_unavailable until the window passes, instead of starting a process that dies again. The first process a workspace starts is the start, not a replacement, and a refused command program, empty or an absolute path, is reported at once rather than started again.

A symbol address spells its language as an identity segment, and a declaration in a .tsx file is addressed under the dialect: rift://symbol/typescript:tsx/view.tsx/Banner. The server looks the entry up by that exact identity, so the bare language and the dialect are two independent entries, each selecting its own process, and both may select the same named one. Without the dialect entry the same rename refuses unsupported.

A minimal entry names its process inline. This one names tombi, a TOML formatter, linter, and language server:

[languages.toml.lsp]
command = ["tombi", "lsp"]

lsp is the subcommand that starts tombi's language server over stdio.

A language server that ships as a package runs from workspace-installed bytes. Pin both packages in package.json, install from its lockfile, and point command at the local executable, the way [lsp.typescript] above does. This avoids paying package runner resolution at startup.

typescript-language-server refuses its initialize request unless a typescript package resolves from the workspace root, answering Could not find a valid TypeScript installation. Install that package in the workspace, or name an existing one with the tsserver.path initialization option; without either, every rename and move for the language fails on the language server's own words.

The index still walks what source leaves visible, so the installed package directory needs an exclude:

[source]
exclude = ["node_modules/**"]

Without it the walk reaches typescript's own sources and the first change fails limit_exceeded on node_modules/typescript/lib/_tsc.js, which is past the index's per-file byte bound. Any workspace with an installed package directory needs the same entry.

tsserver.useSyntaxServer = "never" keeps the process to one semantic server. Under the default auto it also runs a syntax-only server, and that one can answer the first rename after startup from the open file alone: the observed cold answer renamed the declaration and left both importers standing. With one semantic server the rename waits for the loaded project and reaches every file.

Execution

Since v0.0.25

languages.<identity>.execution records whether the planned execute tool may run a block of agent-written code in the targeted tree's execution copy. Execution stays off until its exact language entry enables it:

[execution]
max_code = "16kb"
max_timeout = "30s"
max_output = "8kb"
max_concurrent = 2

[languages.python]
include = ["**/*.py"]
execution = true

The execution keys are shown at their defaults. languages.<identity>.execution defaults to false.

  • max_code bounds one submitted block, 1b to 32kb
  • max_timeout bounds one evaluation, 1ms to 1d - the evaluation only: refreshing the execution copy is extra, and a refresh that outruns the same bound fails the call
  • max_output bounds each captured stream separately, up to 16kb, so a program that floods stdout gets a prefix and a byte count rather than a hung request
  • max_concurrent caps evaluations running at once across the whole workspace, 1 to 64

The schema states each range as rift:range on the key, and the server enforces it at load - schema validation alone can't compare "16kb" against a ceiling.

The language entry also owns file selection, so the execution permission applies to the same exact identity and paths that syntax and LSP service use.

Providers

Since v0.0.7 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, Python

The built-in providers run without configuration; the providers table exists to bound or disable them:

[providers.history]
enabled = true
max_revisions = 500

enabled = false turns the history provider off: the server refuses a revision-addressed read - rev on get_symbol, search, or nodes - with capability_unavailable. A workspace without version control cannot serve those reads whatever this table says.

Since v0.0.13

max_revisions bounds the timeline walk from the served revision, 1 to 100000, because the walk's cost scales with how far back it reaches. The history provider computes a SymbolHistory timeline inside that depth; a revision beyond it contributes no version.

Since v0.0.27

[providers.binding] bounds the name binding provider, which resolves references from syntax facts inside the server, with no language engine involved. The keys are shown at their defaults:

[providers.binding]
enabled = true
max_unit_scopes = 4096
max_unit_definitions = 16384
max_unit_references = 65536
max_unit_links = 4096
max_graph_nodes = 2000000
max_graph_links = 500000
max_reference_work = 4096
max_path_depth = 64
max_reference_targets = 64
max_publication_work = 50000000

enabled = false turns the provider off: reads answer from syntax facts alone.

The provider assembles every file's facts into one graph and resolves each reference on a bounded work queue; each bound caps one phase of that work:

  • max_unit_scopes bounds the scopes one file may contribute, 1 to 40960
  • max_unit_definitions bounds the definitions one file may contribute, 1 to 163840
  • max_unit_references bounds the references one file may contribute, 1 to 655360
  • max_unit_links bounds the imports, member links, and module declarations one file may contribute together, 1 to 40960
  • max_graph_nodes bounds the scopes, definitions, and references one build's graph holds, 1 to 20000000
  • max_graph_links bounds that graph's links, 1 to 5000000
  • max_reference_work bounds the work items one reference may enqueue while resolving, 1 to 409600
  • max_path_depth bounds the steps one work item may accumulate, 1 to 640
  • max_reference_targets bounds the definitions one reference may resolve to, 1 to 256
  • max_publication_work bounds the work items one build may enqueue across every reference, 1 to 5000000000

The index's publication acceptance follows max_graph_nodes too: raising it also raises the bound on what one build can publish beside syntax.

An exhausted bound never fails a build: the revision publishes with the syntax facts alone, and the breach lands in the server log naming the bound.

Provider configuration does not set merge order or freshness. A provider rebuilding after a file change can serve its previous immutable revision; the result then carries a stale_index entry in warnings naming the two tree revision digests.

Since v0.0.9

search runs on a lexical index. [search.text] selects the plain-text files that join it through include and bounds their chunks. pool_slots bounds pooled connections shared by search and stored logs; writes wait for one process-wide turn.

Since v0.0.25

A visible file joins the lexical index through its effective language entry, or through [search.text].include when no language claims it. Narrow that list where the workspace holds large unclaimed files search should not carry:

[search.text]
include = ["**/*.md", "**/*.mdx", "**/*.txt"]
max_chunk = "1mb"

[search]
pool_slots = 4
busy_timeout = "5s"
  • include selects text files from the visible paths no language entry claimed, at most 64 patterns. It defaults to ["**"], so every such path joins the lexical text index; an explicit empty list selects no text file
  • max_chunk bounds how large a selected text file may grow before it's split into several indexed chunks, 1kb to 16mb
  • pool_slots bounds SQLite connections the workspace database may hold open at once, 1 to 16
  • busy_timeout bounds how long a connection waits for a database lock held by another process before failing with SQLITE_BUSY, 100ms to 30s
Since v0.0.16 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, Python

The two rankings are combined by reciprocal-rank fusion: a result scores as the weighted sum of 1 / (k + rank) over the rankings that returned it. [search.lexical] and [search.semantic] carry the weights, fusion_k carries the k, and [search.semantic] names the model the second ranking runs on:

[search]
fusion_k = 60

[search.lexical]
weight = 0.7

[search.semantic]
weight = 0.3
source = "hf"
model = "minishlab/potion-retrieval-32M"
download_timeout = "5m"
download_attempts = 3
batch_declarations = 32
max_tokens = 256
candidates = 200
candidates_per_file = 3
max_vectors = 200000
  • weight is each ranking's share of a fused score, 0.0 to 1.0; the two must sum to 1, so each reads as a share of one whole
  • fusion_k sets how sharply a top rank counts, 1 to 1000; a larger value flattens the contribution curve and lets agreement between the two rankings outweigh one ranking's top position
  • [search.semantic].source names where the weights come from: hf downloads them from a Hugging Face repository and caches them where every other Hugging Face client on the machine caches them, directory reads a directory the workspace already holds and downloads nothing
  • model names which weights, in the form the semantic tier's source sets: under hf a repository identifier such as minishlab/potion-retrieval-32M, optionally carrying a revision after @; under directory a workspace-relative directory. The server stores vectors per model - two models' vectors aren't comparable - so changing the value embeds the workspace again
  • The model's own config.json decides how the server reads it. A model_type of model2vec names a static model, which embeds a declaration by averaging one row per token; anything else is read as a BERT checkpoint, which runs a forward pass per batch. The shipped default is static, because a forward pass over every declaration in a workspace costs minutes on a laptop CPU and a row gather costs seconds
  • download_timeout and download_attempts bound one model acquisition, 10s to 1h and 1 to 10; a spent budget leaves search lexical for the life of the server
  • batch_declarations and max_tokens bound one embedding pass, 1 to 256 and 32 to 512. A BERT checkpoint's attention memory grows with the square of the token window, so a raised window wants a lowered batch; a static model reads one row per token, so its cost grows with the window rather than with its square
  • candidates bounds how many declarations the semantic ranking returns before the two rankings are fused, 1 to 1000, and candidates_per_file bounds any one file's share of them
  • candidates_per_file bounds how many declarations one file contributes to that list, 1 to 64; the rest of the file's declarations are dropped, so one file whose declarations all rank well can't fill the list on its own and leave every other file unreachable
  • max_vectors bounds how many vectors the workspace holds, 1000 to 1000000; each vector costs the model's dimension in single-precision floats

disabled keeps the workspace lexical without emptying the rest of the table. Set it where the workspace must not fetch model weights:

[search.semantic]
disabled = true

Hooks

Since v0.0.5

hooks adds transforms and validations, run in configured order after direct edits land.

Since v0.0.25

A hook is selected from the initial changed paths through include and exclude. Rift selects the complete list before any hook runs, so one transform cannot make a later hook join the run. command accepts a program string without whitespace or a nonempty list holding one program and at most 64 literal arguments, each at most 4096 bytes. Rift starts it directly without a shell:

[[hooks]]
id = "tests"
kind = "test"
command = ["cargo", "test"]
changed_paths = "none"
writes = "none"
timeout = "120s"
output_limit = "4kb"
failure_severity = "error"
determinism = "deterministic"
include = ["crates/**"]
exclude = ["crates/generated/**"]

The keys that shape how the command runs:

  • id - unique within the list, labels the result
  • command - refuses an absolute program; a bare name resolves through the PATH of the environment the command runs with - the server's, with environment applied on top - and a relative one below working_directory
  • working_directory - relative to the workspace root; an omitted value selects the root
  • changed_paths - "append" adds the changed project paths after command in byte order, for a linter that takes files; "none" leaves the command alone
  • include - selects a hook when an initial changed path matches; empty selects every change
  • exclude - removes paths from that selection
  • writes - "none" makes a validation that may retain no source write; "changed_paths" makes a transform that may change only paths changed before hooks ran; "workspace" lets a transform change any visible source file
  • failure_severity - "warning" or "error" on the diagnostic a hook failure contributes

A hook runs in the tree it checks, so it executes the agent's change each time one applies - cargo test compiles the very change it's checking.

id, kind, command, and determinism are required. changed_paths and writes default to "none"; failure_severity defaults to "error"; working_directory, environment, guarantees, include, and exclude default empty; and the timeout and output keys use the shown defaults. The list holds at most 32 hooks. environment holds at most 64 entries, guarantees holds at most 16 entries, and each include and exclude list holds at most 64 patterns.

Two keys carry bounds: timeout, when Rift kills the process, takes "1ms" to "1h", and output_limit, how much of each stream Rift keeps, takes "256b" to "4kb". Rift still reports the full size, so you can tell a truncated log from a short one.

guarantees declares what a validation pass establishes, which turns a passing run into GuaranteeEvidence on the change. Each entry names the property (kind), what the check covers (scope), and the claim in words (detail); the evidence on the change carries them alongside the hook's id. determinism says whether the same tree and environment are expected to give the same answer twice.

Every transform must appear before every validation. Transform hooks cannot declare guarantees. A passing transform keeps writes inside its declared scope, and those writes become part of the change's final files and id. Rift restores transform writes when the command fails, times out, writes outside scope, or changes source permissions. If transforms undo every direct edit, result is unchanged.

Validation hooks read final transformed bytes. Rift restores any source bytes or permissions they change and reports that as failure. A failed validation does not refuse or roll back an applied change; it contributes configured warning or error. This is same flow as editor save: formatting updates source, then checks report on saved result.

A hook that exits nonzero, overruns timeout, or fails to launch contributes a finding under the code rift.hook.failed, naming what ended the run and quoting each non-empty stream with its size.

A command starts from the environment the server inherited, with environment values added on top. Store secrets outside the committed rift.toml and inject them into the server environment. Every configured hook inherits that environment and can read those secrets.

This formatter runs first and may update any visible source path:

[[hooks]]
id = "format"
kind = "format"
command = ["cargo", "fmt", "--all"]
changed_paths = "none"
writes = "workspace"
timeout = "120s"
output_limit = "4kb"
failure_severity = "warning"
determinism = "deterministic"