Skip to main content

README.md

# Beamscope

Compiler-accurate code intelligence for BEAM codebases (Erlang and Elixir).

## Why this exists

AI coding agents burn a large share of their context window just
*finding* the code relevant to a question, before they can reason about
it — and that cost is worse on large, mature Erlang/Elixir codebases than
on most languages, because BEAM code leans on real compile-time macros
for common patterns (backend dispatch, logging wrappers, `-define`-based
DSLs). Beamscope's goal is narrow and specific: **cut the token cost of
navigating a BEAM codebase, with measured evidence, not promises.**
Structural correctness (seeing through macros, an accurate call graph) is
the means to that end, not the end in itself.

The approach: chunking and call-graph extraction are built on `:epp` and
`Code.string_to_quoted/2` — the same frontends the compiler itself uses —
instead of a generic syntax grammar. On Erlang specifically, this means
macros are seen exactly as the compiler sees them: a call hidden behind a
`-define`d macro resolves to its real target, not an opaque token.

In benchmarks across three real codebases, using beamscope instead of raw
grep/read cuts token usage by 90–100% on call-graph queries. See
[ENGINEERING.md](ENGINEERING.md) for the architecture decisions,
benchmark methodology, and full results.

This is deliberately **not** a general-purpose, many-language code
intelligence platform — it exists to be deep and correct on BEAM
specifically, not broad across every language.

## How this differs from other approaches

Existing BEAM code-intelligence options generally fall into two
categories, and each has a real, structural reason it doesn't cover this
specific case:

- **Broad, many-language code-search tools.** Often more full-featured
  than beamscope in most respects — more languages, more query types.
  But BEAM languages are a small slice of a large addressable market for
  a generalist tool, so the depth of language-specific handling varies a
  lot across what they support, and macro-aware call resolution for
  Erlang specifically isn't something a generalist architecture is
  well-positioned to prioritize.
- **Language-server/syntax-grammar-based tooling.** Good, often
  excellent, for straightforward symbol lookup. Some of these do attempt
  macro-aware analysis on top of a generic syntax tree, and — checked
  directly, not assumed — at least one real, actively maintained Erlang
  tool already resolves common macro patterns correctly. But that kind of
  support means *reimplementing* macro semantics separately from the real
  compiler, which carries a real risk of diverging from it in edge cases
  the reimplementation doesn't happen to cover. Beamscope avoids that
  category of risk by construction: it calls `:epp`/
  `Code.string_to_quoted/2` directly, so macro expansion is whatever the
  real compiler produces, not a second, independent approximation of it.
  See [ENGINEERING.md](ENGINEERING.md) for the specific, verified test
  behind this.

Neither of these makes existing tools bad — most of what they do, they do
well, for the languages and use cases they're built around. This is a
narrower claim than "better than everything": beamscope exists
specifically for codebases where seeing through macros reliably is worth
a dedicated tool. If your BEAM codebase barely uses macros, the gap this
closes matters a lot less to you.

## How it works

```mermaid
flowchart LR
    Client["AI agent / MCP client\n(Claude Code, etc.)"] -->|HTTP JSON-RPC| MCP["Beamscope.MCP\n(Plug + Bandit)"]
    MCP --> Repo["Beamscope.Repo"]

    Repo --> CGStore["Callgraph.Store"]
    Repo --> SearchStore["Search.Store"]

    CGStore --> CGPipeline["Callgraph.Pipeline\n(:epp / Code.string_to_quoted)"]
    SearchStore --> ChunkPipeline["Chunking.Pipeline"]
    SearchStore --> Embeddings["Embeddings\n(Bumblebee/Nx/Torchx, optional)"]

    CGPipeline --> Source[("your repo's\nsource files")]
    ChunkPipeline --> Source

    CGStore -. persists .-> CGFile[(".beamscope/callgraph.json")]
    SearchStore -. persists .-> SearchFile[(".beamscope/search.dets")]
```

`Repo` is the single entry point both the MCP server and direct callers go
through. The call graph and search index are each built once per
`repo_path`, cached in memory, and persisted to disk so a restart doesn't
mean re-parsing or re-embedding the whole repo (see
[Limitations](#limitations) for what's not yet incremental about that).
`search_code`'s embedding step is the only part of this that touches the
optional `bumblebee`/`nx`/`torchx` deps — everything else works with zero
ML dependencies. See [ENGINEERING.md](ENGINEERING.md) for why each piece
is built the way it is.

## Status

Chunking and call-graph parity are validated against real production
Erlang codebases. The MCP server (call graph + semantic search) works
end-to-end and is verified as a real `mix` dependency in an external
Elixir app. **No incremental indexing yet** — every index build
reprocesses the whole repo from scratch (see
[Limitations](#limitations)). Published on
[Hex](https://hex.pm/packages/beamscope) — see [Setup](#setup).

## Setup

Add to the consuming project's `mix.exs`:

```elixir
def deps do
  [
    {:beamscope, "~> 0.1.2"}
  ]
end
```

```
mix deps.get
```

If the consuming project uses [Igniter](https://hexdocs.pm/igniter),
`mix igniter.install beamscope` does the same `mix.exs` edit and prints a
notice listing what's wired up right now.

Prefer to track the git repo directly (a specific commit, or `main`)
instead of a Hex release:

```elixir
{:beamscope, git: "https://github.com/mangalakader/beamscope.git"}
# or, against a local checkout:
# {:beamscope, path: "../beamlens_spike"}
```

```
mix igniter.install beamscope@git:https://github.com/mangalakader/beamscope.git
```

**Ignore the build artifacts.** Once you index a repo (see below), beamscope
writes `<repo_path>/.beamscope/` there — a rebuildable cache, like `_build/`,
not something to commit or ship. Add to the *consuming* project's
`.gitignore` (and `.dockerignore`, if you build container images):

```
.beamscope/
```

**Optional — only needed for `search_code` (semantic search)** — add the ML
deps too:

```elixir
{:bumblebee, "~> 0.7"},
{:nx, "~> 0.12"},
{:torchx, "~> 0.12"}
```

Building Torchx's NIF needs a C/C++ toolchain and `cmake` on the machine
running `mix deps.get`/`mix compile` the first time — install that first if
you don't already have it (e.g. `brew install cmake` on macOS). Everything
after that is automatic — no Ollama, Qdrant, or Docker to run.

## First run

Start the MCP server:

```
mix beamscope.mcp                # http://localhost:9877/mcp
mix beamscope.mcp --port 8080
```

Connect an MCP client to that URL as a remote HTTP server (not a spawned
stdio subprocess). There's no separate "index this repo" step: every tool
call takes an explicit `repo_path`, and the first call for a given path
builds (and caches, and persists to `<repo_path>/.beamscope/`) whatever it
needs on demand.

- `get_callers`/`get_callees`/`find_call_path` build the call graph on
  first use — seconds for a small repo, longer for a real production-sized
  one (this is the one-time cost of walking and parsing every file).
- `search_code` additionally chunks the repo and embeds every chunk. The
  first `search_code` call for a repo is the slowest call you'll make —
  it's also the point where the embedding model gets downloaded, if this
  is the first time it's run on this machine.

Every call after the first for a given `repo_path` is served from an
in-memory cache, and the on-disk `.beamscope/` files survive a server
restart too — nothing needs to rebuild just because the server restarted.

```mermaid
sequenceDiagram
    participant C as Caller
    participant R as Repo
    participant Disk as .beamscope/ on disk

    C->>R: first call (repo_path)
    Note over R: no in-memory cache, no disk file yet
    R->>R: build (parse / chunk / embed)
    R->>Disk: persist (atomic write)
    R-->>C: result

    C->>R: next call (same repo_path)
    Note over R: in-memory cache hit
    R-->>C: result (fast)

    Note over R: server restarts
    C->>R: call after restart
    R->>Disk: load persisted file
    Note over R: cached in memory again
    R-->>C: result (no rebuild)
```

Without the MCP server, the same operations are available directly:

```elixir
alias Beamscope.Repo

{:ok, %{callers: callers}} = Repo.callers("/path/to/repo", "my_module", "my_function")

{:ok, %{exact_matches: exact, semantic_matches: semantic}} =
  Repo.search("/path/to/repo", "where session tokens get validated", limit: 5)
```

## Usage

**Call graph** — who calls what, and how to get from A to B:

```elixir
alias Beamscope.Repo

{:ok, %{callers: callers}} = Repo.callers("/path/to/repo", "my_module", "my_function")
{:ok, %{callees: callees}} = Repo.callees("/path/to/repo", "my_module", "my_function")
{:ok, %{path: path}} = Repo.call_path("/path/to/repo", "mod_a", "foo", "mod_b", "bar")
```

Each caller/callee comes back enriched with its definition's
`file_path`/`start_line`/`end_line`, so acting on a result doesn't require
re-reading the whole file it lives in.

**Semantic search** — chunk-level embeddings, searchable by natural-language
query, entirely in-process (no external service, no Ollama/Qdrant/Docker):

```elixir
Repo.search("/path/to/repo", "where is the session token validated", limit: 5)
# {:ok, %{
#   exact_matches: [%{file_path:, line:, text:}, ...],
#   semantic_matches: [%{file_path:, symbol:, start_line:, end_line:, kind:, score:}, ...],
#   semantic_error: nil
# }}
```

`exact_matches` and `semantic_matches` are two separate lists, not one
blended ranking. `exact_matches` is a literal, in-process grep for
identifier-like terms in the query and needs no ML deps; use it (or
`get_callers`/`get_callees` once you have a name) for exact-name lookups.
Lean on `semantic_matches` when you don't know what to grep for. If the
optional ML deps aren't installed, `semantic_matches` comes back empty
with `semantic_error: :embeddings_not_available` rather than the whole
call failing.

**Chunking** — the lower-level building block, if you need
function/attribute-level chunks directly:

```elixir
alias Beamscope.Chunking.Pipeline

result = Pipeline.chunk_repo("/path/to/repo", max_concurrency: 8)
result.chunks   # [%{symbol:, start_line:, end_line:, text:, kind:, file_path:, warning:}, ...]
result.errors   # [{path, reason}, ...] — timeouts/crashes, doesn't fail the whole run
```

Supports `.erl`/`.hrl` (via `:epp`), `.ex`/`.exs` (via
`Code.string_to_quoted/2`), and falls back to line-window chunking for
everything else (docs, config files) or files that fail to parse.

## Benchmarking your own repo

```
mix beamscope.benchmark --repo /path/to/repo [--repo /path/to/repo2] [--output docs/benchmarks/]
```

Auto-discovers representative tasks in the repo, measures real token
counts and latency for beamscope vs. a grep/read baseline, and writes a
timestamped Markdown report. See [ENGINEERING.md](ENGINEERING.md) for the
methodology and results this same tool produced against MongooseIM,
amoc-arsenal-xmpp, and the Elixir language's own source.

The token-count/reduction table works with no extra setup. The latency
comparison table needs `Benchee`; add `{:benchee, "~> 1.3", only: :dev}`
to your own `mix.exs` deps to get it — without it, the benchmark still
runs and reports token counts, just without that section.

## Roadmap

Planned, not yet built:

- **Incremental indexing** — skip re-processing files that haven't
  changed since the last build, instead of always rebuilding from
  scratch (see [Limitations](#limitations)).
- **Concurrent/pipelined embedding** — parallelize `search_code`'s
  chunk-embedding step to speed up cold indexing of large repos.
- **Export/import of a built index** — move a repo's `.beamscope/` index
  between machines without rebuilding.
- **A web-based call-graph visualizer** — browse `get_callers`/
  `get_callees`/`find_call_path` results interactively instead of only
  through MCP tool calls or direct API calls.
- **Embedding/search-index quality metrics** — surface how well
  `search_code`'s semantic matches are actually performing, beyond the
  benchmark tool's task-by-task quality notes.

None of these block current usage — each is additive to what's already
working.

## Limitations

- **No incremental indexing.** Every index build — call graph or search —
  reprocesses every file in the repo from scratch; nothing is tracked
  about what changed since the last build. `Repo.reindex/2` means
  "discard the cache and rebuild everything," not "update only what
  changed." For a small repo that's fast enough not to matter; for a
  large production codebase it's the same one-time cost as the very first
  build, paid again on every reindex. The mental model: **the index is a
  rebuildable cache, not a live-updating one.**

## Development

```
mix deps.get
mix test
```

Tests tagged `:external` (`Beamscope.Embeddings`/`Beamscope.Search.Store`
real embedding tests) are excluded by default since they hit a real
model download + CPU inference — run `mix test --include external` to
include them.

Real-world parity fixtures (full MongooseIM and amoc-arsenal-xmpp checkouts)
live under `priv/fixtures/` but are gitignored — they're research artifacts
for validating parity against a reference pipeline, not package fixtures.
The small synthetic `.erl`/`.ex` files alongside them *are* real, tracked
test fixtures.

## License

MIT — see `LICENSE`.