Skip to content

Architecture

basemind is a single Rust crate with one binary (basemind) serving three roles: basemind scan indexes a workspace, basemind serve runs the MCP stdio server, and the daemon (basemind comms daemon, auto-spawned; singleton per user) is the sole writer managing the global cache.

Your project (code + documents + git history)
basemind scan (or incremental watch)
├─ Tree-sitter parser pool (300+ languages)
├─ Extract L1 (outlines: symbols, signatures, imports)
├─ Extract L2 (calls: callee, byte offset, line/col) [if eager_l2=true]
├─ Extract L3 (structural hash of symbol bodies)
└─ Forward writes to basemind daemon (IPC over UDS)
├─ Daemon writes content-addressed msgpack blobs
└─ Daemon writes Fjall LSM index (sole Fjall writer per machine)
basemind serve (MCP stdio)
├─ Load index + blobs from global cache
├─ Read index concurrently (N sessions per repo, all read-only to Fjall)
├─ Forward writes back to daemon
├─ Answer MCP tool requests instantly
└─ Watch filesystem for changes (signal daemon to rescan)

basemind persists project state in a machine-global cache under ~/.local/share/basemind/ (or $BASEMIND_DATA_HOME), keyed by workspace root. All repos/worktrees on one machine share a single content-addressed blob store and a daemon-managed Fjall writer.

Location: ~/.local/share/basemind/blobs/<hash>.{l1,l2,l3}.msgpack

Each extraction tier is a separate msgpack blob, keyed by content hash. This enables deduplication across files with identical bodies.

  • L1 (always): outlines with symbols, signatures, imports, doc comments
  • L2 (if eager_l2=true): call sites (callee name, byte offset, line/column)
  • L3: structural hash of each symbol’s body (used by symbol_history)

Blobs are immutable and content-addressed, so they can be safely shared across multiple views (scans) and repos.

Location: ~/.local/share/basemind/views/<workspace_hash>/<view>/index.fjall/

A disk-backed, append-only LSM tree (Fjall) provides lightning-fast lookups. The daemon is the sole writer; all basemind serve sessions read concurrently from the same index via a read-only handle:

Keyspace Purpose
meta Schema version and constants
symbols_by_path Per-file symbol lookup
symbols_by_name Name-prefix range scans (drives search_symbols)
calls_by_path Per-file call lookup
calls_by_callee Callee-prefix range scans (drives find_references)
imports_by_path Per-file import lookup
imports_by_module Module-prefix range scans (drives dependents)
implementations_by_path Per-file implementation lookup
implementations_by_trait Trait-prefix range scans (drives find_implementations)
embeddings Reserved for future in-Fjall vector index
memory_by_key Agent memory namespaced by (scope, visibility, owner)

All composite keys are length-prefixed (u16:len ‖ bytes), ensuring a prefix like Foo never spills into Foobar.

Schema version is stamped in meta. On version mismatch, the next basemind scan auto-wipes and rebuilds.

Location: ~/.local/share/basemind/lancedb/

Powers semantic search (code search, document search, memory search). Vectors are indexed for fast KNN queries; on-disk format is LanceDB’s columnar structure. Writes go through the daemon.

basemind scan and basemind watch (incremental) share the same per-file pipeline:

Walker (gitignore-aware) → filter by glob + size cap
rayon par_iter (parallel across all CPU cores)
process_file(rel_path, file_contents):
1. lang::detect() ← tree-sitter language pack
2. Extract L1 (outline) ← tree-sitter queries
3. Extract L2 (calls) ← if eager_l2=true
4. Write L1 to blob store
5. Write L2 to blob store ← if eager_l2=true
6. Index lookup:
a. Read existing entries (if re-scan)
b. Stage all deletes + inserts in one atomic Fjall batch
7. Per-file commit ← Fjall handles cross-thread locking
collect FileResult { rel, l1_hash, l2_hash?, … }
apply_outcomes:
· Write Index meta
· Remove stale files via IndexWriter::remove_file

Per-file commit — Every file commits its Fjall batch before returning. Fjall handles cross-thread locking; the scanner does not.

Atomic upsertIndexWriter::upsert_file is read-before-write: it reads existing entries first to derive secondary-index keys for deletion, then stages all deletes + inserts in one batch. This prevents torn state on re-scan.

Eager L2 cost — Eager L2 (call-site extraction) is on by default and adds to scan time; the 81k-file TypeScript scan lands around 18s. Set eager_l2 = false to skip it for a faster scan, trading away reference search.

scan_paths removal mirror — When a file disappears between scans, scan_paths calls IndexWriter::remove_file so secondary indexes don’t leak stale entries.

Per-file symbol table with signatures, line/column locations, import statements, and doc comments. Generated by tree-sitter queries (hand-written overrides at src/queries/<language>.scm, falling back to tree-sitter-language-pack’s tags.scm).

Used by: outline, search_symbols, goto_definition, call-graph traversal.

Every call site in a file: callee name, byte offset, line/column. Extracted only if eager_l2 = true.

Used by: find_references, find_callers, call_graph.

Disabling it speeds up the scan but makes reference search unavailable.

Cryptographic hash of each symbol’s body. Used to detect when a symbol’s implementation changed across commits.

Used by: symbol_history.

When [documents] enabled = true, scanner_docs.rs runs a separate pass:

  • Reads PDFs, Office files, HTML, images, email
  • Extracts text (with OCR for images)
  • Chunks and embeds via LanceDB
  • Indexed by search_documents

basemind uses the tree-sitter language pack (TSLP) to detect and parse 300+ languages. Language detection is dynamic:

File extension → tree_sitter_language_pack::detect_language
LangId = &'static str (the pack name, e.g. "rust", "python", "typescript")
Parser from pool (lazy init, reused across files)
Query (hand-written override OR TSLP tags.scm fallback)
Extract symbols + calls

All languages with a tags.scm query in TSLP (~100) work out of the box. For richer extraction, add a .scm override at src/queries/<pack-name>.scm.

basemind git commands are powered by gix (a pure-Rust Git implementation) with a git-history index for microsecond latency:

Location: ~/.local/share/basemind/git-cache/<workspace_hash>/

Two-tier caching:

  • In-process LRU (1024 entries per category by default; tune via basemind serve --git-cache-mem)
  • Disk store (SHA-keyed):
    • commit_files/<sha>.msgpack — file list for a commit (immutable, never stales)
    • log/<head_sha>__<scope>.msgpack — commit log (keyed by HEAD, rolls off naturally)
    • blame/<sha>__<path_hash>.msgpack — blame data (immutable, never stales)

History queries (commits_touching, recent_changes, blame_*) are posting-list lookups over this index, answering in tens of microseconds flat.

The index is a pure accelerator: it’s used only when fresh (last_indexed_head == HEAD) and otherwise walks history directly, so it can never serve stale results. It rebuilds automatically on history rewrite (filter-repo, rebase, force-push).

Two persistent schemas, both tracking RELEASE_MINOR from src/version.rs:

  • INDEX_SCHEMA_VER in src/index/mod.rs — Fjall keyspace format
  • SCHEMA_VER in src/extract/mod.rs — msgpack blob format

Patch release (0.1.00.1.1): RELEASE_MINOR unchanged. Blobs + index must stay compatible — no format changes.

Minor release (0.1.x0.2.0): RELEASE_MINOR increments. On next basemind scan, .basemind/ is auto-wiped and rebuilt from source. Users are notified in release notes.

Wipe-on-mismatch is the explicit migration story. No manual steps — basemind scan handles it.

Global cache at ~/.local/share/basemind/ (or $BASEMIND_DATA_HOME):

~/.local/share/basemind/
├── blobs/
│ ├── abc123….l1.msgpack # Outline: symbols, signatures, imports
│ ├── abc123….l2.msgpack # Calls (if eager_l2=true)
│ └── abc123….l3.msgpack # Structural hashes
│ └── (shared across all repos/worktrees via content hash)
├── views/
│ └── <workspace_hash>/
│ └── <view>/
│ └── index.fjall/ # Fjall LSM index (daemon is sole writer)
├── git-cache/
│ └── <workspace_hash>/
│ ├── commit_files/ # Cached file lists per commit
│ ├── log/ # Cached logs
│ └── blame/ # Cached blame data
├── lancedb/
│ └── <workspace_hash>/ # Vector store (documents, memory, code search)
└── agent-registry/ # Daemon-managed room/thread registry
Per-repo configuration (repo-local):
repo/.basemind/basemind.toml # Configuration

The daemon manages the global cache; individual repos share no per-repo .basemind/ directory.

basemind serve exposes a stdio MCP server. All paths are byte-precise repo-relative (RelPath).

Tool conventions:

  • Paths: always RelPath (repo-relative), never arbitrary strings
  • Lists: capped at 1000 items (default 100); responses include any_truncated flag and cursor for pagination
  • Index scans: internally use scan_cap = limit * 8 to bound work on common names
  • Descriptions: state matching semantics (substring vs. prefix, scope-aware vs. name-only)
  • Stability: new fields are additive with #[serde(default)]

Tool bodies live in src/mcp/helpers*.rs (sliced by area: helpers_documents.rs, helpers_calls.rs, helpers_graph.rs, etc.); src/mcp/tools*.rs contain only thin #[tool] shims.

The background daemon (singleton per user) manages agent communication and shared state:

  • Threads: per-repo, per-workspace, or global scope; agents join explicitly via thread_join
  • Messages: front-matter stored in messages_by_thread, bodies in message_body (fetched on demand for efficiency)
  • Registry: tracks active workspaces, worktrees, and advisory branch claims for collision detection
  • Memory: namespaced by (scope, visibility, owner) — group memory is shared, individual memory is private to one agent
  • Vectors: LanceDB stores embeddings for memory + document search

Why a daemon? Content-addressed blobs and a single Fjall writer are the key wins: all repos/worktrees on one machine share blob deduplication, and N sessions can read concurrently from a single index without downgrade-to-read-only issues. The daemon is the sole Fjall writer and enforces this via socket-bind-as-lock (singleton enforcement).