Files
sol/README.md
Sienna Meridian Satterwhite 2949ea354f docs: comprehensive README rewrite — orchestrator, gRPC, code indexing, new tools
Cover the transport-agnostic orchestrator, gRPC CodeAgent service,
tree-sitter code indexing, client/server tool dispatch, research tool,
SearXNG web search, Kratos identity tools, breadcrumbs, and all new
config sections.
2026-03-24 12:58:18 +00:00

27 KiB

Sol

A virtual librarian for Matrix. Sol lives in your chat rooms, archives conversations in OpenSearch, and responds with the help of Mistral AI — with end-to-end encryption, tool use, per-user memory, a multi-agent architecture, and a gRPC coding agent service.

Sol is built by Sunbeam Studios as part of our self-hosted collaboration stack.

What Sol Does

  • Matrix Presence — Joins rooms, reads the vibe, decides when to speak. Direct messages always get a response; in group rooms, Sol evaluates relevance before jumping in.
  • Message Archive — Every message is indexed in OpenSearch with full-text and semantic search. Sol can search its own archive via tools.
  • Tool Use — Mistral calls tools mid-conversation: archive search, code search, room context retrieval, web search, a sandboxed TypeScript/JavaScript runtime (deno_core), and parallel research agents.
  • Per-User Memory — Sol remembers things about the people it talks to. Memories are extracted automatically after conversations, injected into the system prompt before responding, and accessible from scripts via sol.memory.get/set.
  • User Impersonation — Sol acts on behalf of users when calling external services. PATs are auto-provisioned via admin APIs and stored securely in OpenBao (Vault). OIDC-to-service username mappings handle identity mismatches.
  • Gitea Integration — Devtools agent: list repos, search issues, create issues, list PRs, get file contents, search code, create pull requests — all as the requesting user.
  • Identity Management — Kratos integration for user account operations: create, get, list, update, delete users via the admin API.
  • Multi-Agent Architecture — An orchestrator agent with personality + tools + web search. Domain agent delegation is dynamic — only active agents appear in instructions. Agent state is persisted in SQLite with instructions hash for automatic recreation on prompt changes.
  • Conversations API — Persistent conversation state per room via Mistral's Conversations API, with automatic compaction at token thresholds. Per-message context headers inject timestamps, room info, and memory notes.
  • Code Indexing — Tree-sitter symbol extraction from Gitea repos (Rust, Python, TypeScript, Go, Java). Indexed to OpenSearch for hybrid keyword + semantic code search.
  • gRPC Coding Agent — Bidirectional streaming service for sunbeam code TUI sessions. Transport-agnostic orchestrator emits events; gRPC bridge translates to protobuf. JWT auth in production, dev mode for local use.
  • Research Tool — Spawns parallel micro-agents with depth control for multi-step research tasks.
  • Web Search — Self-hosted search via SearXNG. No commercial API keys needed.
  • Multimodalm.image messages are downloaded from Matrix via mxc://, converted to base64 data URIs, and sent as ContentPart::ImageUrl to Mistral vision models.
  • Reactions — Sol can react to messages with emoji when it has something to express but not enough to say.
  • E2EE — Full end-to-end encryption via matrix-sdk with SQLite state store.

Architecture

flowchart TD
    subgraph Clients
        matrix[Matrix Sync Loop]
        grpc[gRPC CodeAgent<br/>sunbeam code TUI]
    end

    subgraph Engagement["Engagement (Matrix only)"]
        eval[Evaluator]
        rules[Rule Checks]
        llm_eval[LLM Evaluation<br/>ministral-3b]
    end

    subgraph Core
        orchestrator[Orchestrator<br/>Event-driven, transport-agnostic]
        tools[Tool Registry]
        agents[Agent Registry<br/>Orchestrator + domain agents]
    end

    subgraph Tools
        server_tools[Server-Side Tools<br/>search_archive, search_code,<br/>list_rooms, get_room_members,<br/>get_room_context, run_script,<br/>search_web, research,<br/>gitea_*, identity_*]
        client_tools[Client-Side Tools<br/>file_read, file_write,<br/>search_replace, grep, bash,<br/>list_directory, lsp_*]
    end

    subgraph Persistence
        sqlite[(SQLite<br/>conversations, agents,<br/>service_users)]
        opensearch[(OpenSearch<br/>archive, memory, code)]
    end

    subgraph External
        mistral[Mistral AI<br/>Agents + Conversations API]
        gitea[Gitea<br/>repos, issues, PRs, code]
        searxng[SearXNG<br/>web search]
        vault[OpenBao<br/>user tokens]
        kratos[Kratos<br/>identity management]
    end

    matrix --> eval
    eval --> rules
    rules --> |MustRespond| orchestrator
    rules --> |no rule match| llm_eval
    llm_eval --> |MaybeRespond| orchestrator
    llm_eval --> |React/Ignore| matrix

    grpc --> orchestrator

    orchestrator --> mistral
    orchestrator --> tools
    tools --> server_tools
    tools --> |relay to client| client_tools

    server_tools --> opensearch
    server_tools --> gitea
    server_tools --> searxng
    server_tools --> kratos

    orchestrator --> |response| matrix
    orchestrator --> |events| grpc
    orchestrator --> sqlite
    matrix --> |archive| opensearch
    matrix --> |memory extraction| opensearch
    agents --> mistral

Engagement Pipeline

sequenceDiagram
    participant M as Matrix
    participant S as Sync Handler
    participant E as Evaluator
    participant LLM as ministral-3b
    participant O as Orchestrator

    M->>S: m.room.message
    S->>S: Archive message
    S->>S: Update conversation context
    S->>E: evaluate(sender, body, is_dm, recent)

    alt Own message
        E-->>S: Ignore
    else @mention or matrix.to link
        E-->>S: MustRespond (DirectMention)
    else DM
        E-->>S: MustRespond (DirectMessage)
    else "sol" or "hey sol"
        E-->>S: MustRespond (NameInvocation)
    else No rule match
        E->>LLM: Relevance evaluation (JSON)
        LLM-->>E: {relevance, hook, emoji}
        alt relevance >= spontaneous_threshold (0.85)
            E-->>S: MaybeRespond
        else relevance >= reaction_threshold (0.6) + emoji
            E-->>S: React (emoji)
        else Below thresholds
            E-->>S: Ignore
        end
    end

    alt MustRespond or MaybeRespond
        S->>S: Check in-flight guard
        S->>S: Check cooldown (15s default)
        S->>O: Generate response
    end

Orchestrator

The orchestrator is a transport-agnostic, event-driven pipeline that sits between input sources (Matrix sync, gRPC sessions) and Mistral's Agents API. It has no knowledge of Matrix, gRPC, or any specific UI — transport bridges subscribe to its events and translate to their protocol.

flowchart LR
    subgraph Input
        matrix_bridge[Matrix Bridge]
        grpc_bridge[gRPC Bridge]
    end

    subgraph Orchestrator
        request[GenerateRequest]
        engine[Engine<br/>tool loop]
        events[OrchestratorEvent<br/>broadcast channel]
    end

    subgraph Output
        matrix_out[Matrix Room]
        grpc_out[gRPC Stream]
    end

    matrix_bridge --> request
    grpc_bridge --> request
    request --> engine
    engine --> events
    events --> matrix_out
    events --> grpc_out

Events emitted during response generation:

Event Description
Started Generation begun, carries routing metadata
Thinking Model is generating
ToolCallDetected Tool call found in output (server-side or client-side)
ToolStarted Tool execution began
ToolCompleted Tool finished with result preview
Done Final response text + token usage
Failed Generation error

Tool dispatch routes calls to either the server (Sol executes them) or the client (relayed to the gRPC TUI for local execution). The orchestrator parks on a oneshot channel for client-side results, transparent to the tool loop.

gRPC Coding Agent

Sol exposes a CodeAgent gRPC service for sunbeam code TUI sessions. The protocol is defined in proto/code.proto.

sequenceDiagram
    participant CLI as sunbeam code
    participant gRPC as gRPC Service
    participant O as Orchestrator
    participant M as Mistral

    CLI->>gRPC: StartSession (project context, capabilities)
    gRPC->>gRPC: Create Matrix room, auth
    gRPC-->>CLI: SessionReady (session_id, room_id, model)

    CLI->>gRPC: IndexSymbols (tree-sitter symbols)
    gRPC->>gRPC: Index to OpenSearch

    CLI->>gRPC: UserInput (text)
    gRPC->>O: GenerateRequest
    O->>M: Conversation append

    alt Server-side tool
        O->>O: Execute locally
        gRPC-->>CLI: Status (TOOL_RUNNING → TOOL_DONE)
    else Client-side tool
        gRPC-->>CLI: ToolCall (is_local=true)
        CLI->>CLI: Execute (file_read, bash, etc.)
        CLI->>gRPC: ToolResult
        gRPC->>O: Resume tool loop
    end

    M-->>O: Response text
    gRPC-->>CLI: TextDone (full_text, token usage)

Two RPC methods:

  • Session — Bidirectional stream for interactive coding sessions
  • ReindexCode — On-demand Gitea repo indexing via tree-sitter

Auth: JWT validation via JWKS in production, fixed dev identity when grpc.dev_mode = true.

Tool System

Server-Side Tools

Tool Parameters Description
search_archive query (required), room, sender, after, before, limit, semantic Search the message archive (keyword or semantic)
search_code query (required), language, repo, branch, limit Search the code index for functions, types, patterns
get_room_context room_id (required), around_timestamp, around_event_id, before_count, after_count Get messages around a point in time or event
list_rooms (none) List all rooms Sol is in with names and member counts
get_room_members room_id (required) Get members of a specific room
run_script code (required) Execute TypeScript/JavaScript in a sandboxed deno_core runtime
search_web query (required), limit Search the web via SearXNG
research query (required), depth Spawn parallel micro-agents for multi-step research
gitea_list_repos query, org, limit List or search repositories on Gitea
gitea_get_repo owner, repo (required) Get details about a specific repository
gitea_search_code query (required), repo, limit Search code across Gitea repositories
gitea_create_issue owner, repo, title (required), body, labels Create a new issue as the requesting user
gitea_create_pr owner, repo, title, head, base (required), body Create a pull request as the requesting user
gitea_get_file owner, repo, path (required), ref Get file contents from a repository
identity_create_user email, name (required) Create a new user account via Kratos
identity_get_user email (required) Get user account details
identity_list_users limit List all user accounts
identity_update_user email (required), name, active Update a user account
identity_delete_user email (required) Delete a user account

Tools are conditionally registered — Gitea tools only appear when services.gitea is configured, Kratos tools when services.kratos is configured, LSP tools when the client advertises capabilities.

Client-Side Tools (gRPC sessions)

Tool Permission Description
file_read always Read file contents with optional line ranges
file_write ask Write or create files
search_replace ask Apply SEARCH/REPLACE diffs to files
grep always Search files with ripgrep
bash ask Execute shell commands
list_directory always List directory tree
lsp_definition always Jump to definition
lsp_references always Find all references
lsp_hover always Type info + docs
lsp_diagnostics always Compiler errors
lsp_symbols always Document/workspace symbols

run_script Sandbox

The script runtime is a fresh V8 isolate per invocation with:

  • TypeScript support — Code is transpiled via deno_ast before execution
  • Timeout — Configurable via behavior.script_timeout_secs (default 5s), enforced by V8 isolate termination
  • Heap limit — Configurable via behavior.script_max_heap_mb (default 64MB)
  • Outputconsole.log() + last expression value, truncated to 4096 characters
  • Temp filesystem — Sandboxed sol.fs.read/write/list with path traversal protection
  • Networksol.fetch(url) restricted to behavior.script_fetch_allowlist domains

Host API (sol.*):

sol.search(query, opts?)         // Search message archive
sol.rooms()                      // List joined rooms → [{name, id, members}]
sol.members(roomName)            // Get room members → [{name, id}]
sol.fetch(url)                   // HTTP GET (allowlisted domains only)
sol.memory.get(query?)           // Retrieve memories relevant to query
sol.memory.set(content, category?) // Save a memory note
sol.fs.read(path)                // Read file from sandbox
sol.fs.write(path, content)      // Write file to sandbox
sol.fs.list(path?)               // List sandbox directory

All sol.* methods are async — use await.

Code Indexing

Sol indexes source code from Gitea repositories using tree-sitter for symbol extraction. Symbols are stored in OpenSearch and searchable via the search_code tool and adaptive breadcrumbs.

flowchart LR
    subgraph Sources
        gitea_repos[Gitea Repos<br/>via API]
        sidecar[sunbeam code<br/>IndexSymbols]
    end

    subgraph Extraction
        treesitter[Tree-Sitter<br/>Rust, Python, TypeScript,<br/>Go, Java]
    end

    subgraph Storage
        opensearch_code[(OpenSearch<br/>sol_code index)]
    end

    subgraph Consumers
        search_code_tool[search_code tool]
        breadcrumbs[Adaptive Breadcrumbs<br/>project outline + hybrid search]
    end

    gitea_repos --> treesitter
    sidecar --> opensearch_code
    treesitter --> opensearch_code
    opensearch_code --> search_code_tool
    opensearch_code --> breadcrumbs

Each symbol is a SymbolDocument with file path, repo name, language, symbol name/kind, signature, docstring, line numbers, content, branch, and source. The ReindexCode gRPC endpoint triggers on-demand indexing for specific orgs, repos, or branches.

Memory System

Extraction (Post-Response, Fire-and-Forget)

After each response, a background task sends the exchange to ministral-3b with a structured extraction prompt. The model returns {"memories": [{"content": "...", "category": "preference|fact|context"}]}. Categories are normalized via normalize_category() — valid categories are preference, fact, context; anything else falls back to general.

Storage (OpenSearch)

Each memory is a MemoryDocument with: id, user_id, content, category, created_at, updated_at, source ("auto" or "script"). The index name defaults to sol_user_memory. User isolation is enforced at the Rust level via user_id filtering on all queries.

Pre-Response Loading

Before generating a response, the responder loads up to 5 memories:

  1. Topical query — Semantic search against the trigger message
  2. Recent backfill — If fewer than 3 topical results, fill remaining slots with most recent memories

Memory notes are injected into the system prompt (legacy path) or per-message context header (Conversations API path) as a ## notes about {display_name} block.

Archive

Every message event is archived as an ArchiveDocument in OpenSearch:

  • Batch indexing — Messages are buffered and flushed periodically (opensearch.batch_size default 50, opensearch.flush_interval_ms default 2000)
  • Embedding pipeline — Configurable via opensearch.embedding_pipeline for semantic search
  • Edit trackingm.replace events update the original document's content
  • Redactionm.room.redaction sets redacted: true on the original
  • Reactionsm.reaction events append {sender, emoji, timestamp} to the document's reactions array
  • Backfill — On startup, conversation context is backfilled from the archive; reactions are backfilled from Matrix room timelines (last 500 events per room)
  • Cross-room visibility — Search results from other rooms are filtered by member overlap (behavior.room_overlap_threshold, default 0.25)

Agent Architecture

stateDiagram-v2
    [*] --> CheckHash: Startup
    CheckHash --> Restore: Hash matches
    CheckHash --> Recreate: Hash changed

    Restore --> CheckServer: Agent ID in SQLite
    Restore --> SearchByName: Not in SQLite

    CheckServer --> Ready: Exists on Mistral server
    CheckServer --> SearchByName: Gone from server

    SearchByName --> Recreate: Not found
    Recreate --> Ready: Agent created, conversations reset, *sneezes*

    Ready --> [*]

Orchestrator

The orchestrator agent carries Sol's full personality (system prompt) plus all tool definitions converted to AgentTool format, including web search. It's created on startup if agents.use_conversations_api is enabled.

When the system prompt changes, the instructions hash detects staleness and the agent is automatically recreated. All existing conversations are reset and Sol sneezes into all rooms to signal the context reset.

Agent names can be prefixed via agents.agent_prefix — set to "dev" in local development to avoid colliding with production agents on the same Mistral account.

Domain Agents

Domain agents are defined in agents/definitions.rs. The delegation section in the orchestrator's instructions is built dynamically — only agents that are actually registered appear.

User Impersonation

Sol authenticates to OpenBao via Kubernetes auth (role sol-agent) and stores per-user PATs at secret/sol-tokens/{localpart}/{service}. The service_users SQLite table maps Matrix localparts to service-specific usernames, handling cases where OIDC auto-registration produces different names.

For Gitea, PATs are auto-provisioned via the admin API on first use. The username is discovered by direct match or email-based search.

Persistence

SQLite database at /data/sol.db (configurable via matrix.db_path), WAL mode.

Tables

  • conversations — room_id (PK), conversation_id, estimated_tokens, created_at
  • agents — name (PK), agent_id, model, instructions_hash, created_at
  • service_users — (localpart, service) PK, service_username, created_at

Recovery Behavior

On startup, if the database fails to open:

  1. Log error
  2. Fall back to in-memory SQLite (conversations won't survive restarts)
  3. After sync loop starts, send *sneezes* to all joined rooms to signal the hiccup

The same sneeze happens when the orchestrator agent is recreated due to prompt changes.

Configuration Reference

Config is loaded from SOL_CONFIG (default: /etc/sol/sol.toml).

[matrix]

Field Type Default Description
homeserver_url string required Matrix homeserver URL
user_id string required Bot's Matrix user ID
state_store_path string required Path for Matrix SDK SQLite state
db_path string /data/sol.db SQLite database for persistent state

[opensearch]

Field Type Default Description
url string required OpenSearch cluster URL
index string required Archive index name
batch_size usize 50 Messages per flush batch
flush_interval_ms u64 2000 Flush interval in milliseconds
embedding_pipeline string tuwunel_embedding_pipeline Ingest pipeline for semantic embeddings
memory_index string sol_user_memory Memory index name

[mistral]

Field Type Default Description
default_model string mistral-medium-latest Model for response generation
evaluation_model string ministral-3b-latest Model for engagement evaluation + memory extraction
research_model string mistral-large-latest Model for research tasks
max_tool_iterations usize 5 Max tool call rounds per response
tokenizer_path string? (none) Path to local tokenizer.json (downloads from HuggingFace Hub if unset)

[behavior]

Field Type Default Description
response_delay_min_ms u64 100 Min delay before direct response
response_delay_max_ms u64 2300 Max delay before direct response
spontaneous_delay_min_ms u64 15000 Min delay before spontaneous response
spontaneous_delay_max_ms u64 60000 Max delay before spontaneous response
spontaneous_threshold f32 0.85 LLM relevance score to trigger spontaneous response
reaction_threshold f32 0.6 LLM relevance score to trigger emoji reaction
reaction_enabled bool true Enable emoji reactions
room_context_window usize 200 Messages to keep in group room context
dm_context_window usize 200 Messages to keep in DM context
backfill_on_join bool true Backfill context from archive on startup
backfill_limit usize 10000 Max messages to backfill
instant_responses bool false Skip response delays (for testing)
cooldown_after_response_ms u64 15000 Cooldown before another spontaneous response
evaluation_context_window usize 200 Recent messages sent to evaluation LLM
detect_sol_in_conversation bool true Use active/passive evaluation prompts based on Sol's participation
evaluation_prompt_active string? (built-in) Custom prompt when Sol is in conversation
evaluation_prompt_passive string? (built-in) Custom prompt when Sol hasn't spoken
script_timeout_secs u64 5 Script execution timeout
script_max_heap_mb usize 64 V8 heap limit for scripts
script_fetch_allowlist string[] [] Domains allowed for sol.fetch()
memory_extraction_enabled bool true Enable post-response memory extraction
room_overlap_threshold f32 0.25 Min member overlap for cross-room search visibility
silence_duration_ms u64 1800000 Duration Sol stays quiet when told to (30 minutes)

[agents]

Field Type Default Description
orchestrator_model string mistral-medium-latest Model for orchestrator agent
domain_model string mistral-medium-latest Model for domain agents
compaction_threshold u32 118000 Token estimate before conversation reset (~90% of 131K context)
use_conversations_api bool false Enable Conversations API path (vs legacy chat completions)
coding_model string mistral-medium-latest Model for sunbeam code sessions
research_model string ministral-3b-latest Model for research micro-agents
research_max_iterations usize 10 Max tool calls per research micro-agent
research_max_agents usize 25 Max parallel agents per research wave
research_max_depth usize 4 Max recursion depth for research agents
agent_prefix string "" Name prefix for agents (e.g. "dev" to avoid production collisions)

[grpc]

Field Type Default Description
listen_addr string 0.0.0.0:50051 gRPC server listen address
jwks_url string? (none) JWKS URL for JWT validation (required unless dev_mode)
dev_mode bool false Disable JWT auth, use fixed dev identity

[vault]

Field Type Default Description
url string http://openbao.data.svc.cluster.local:8200 OpenBao/Vault URL
role string sol-agent Kubernetes auth role name
mount string secret KV v2 mount path

[services.gitea]

Field Type Default Description
url string required if enabled Gitea API base URL

[services.kratos]

Field Type Default Description
admin_url string required if enabled Kratos admin API URL

[services.searxng]

Field Type Default Description
url string required if enabled SearXNG API URL

Environment Variables

Variable Required Description
SOL_MATRIX_ACCESS_TOKEN Yes Matrix access token
SOL_MATRIX_DEVICE_ID Yes Matrix device ID (for E2EE state)
SOL_MISTRAL_API_KEY Yes Mistral API key
SOL_GITEA_ADMIN_USERNAME No Gitea admin username (enables devtools agent)
SOL_GITEA_ADMIN_PASSWORD No Gitea admin password
SOL_CONFIG No Config file path (default: /etc/sol/sol.toml)
SOL_SYSTEM_PROMPT No System prompt file path (default: /etc/sol/system_prompt.md)

Dependencies

Sol talks to seven external services:

  • Matrix homeserverTuwunel (or any Matrix server)
  • OpenSearch — Message archive, user memory, and code symbol indices
  • Mistral AI — Response generation, engagement evaluation, memory extraction, agents + conversations
  • OpenBao — Secure token storage for user impersonation PATs (K8s auth, KV v2)
  • Gitea — Git hosting API for devtools agent (repos, issues, PRs, code search, indexing)
  • Kratos — Identity management for user account operations
  • SearXNG — Self-hosted web search (no API keys required)

Key crates: matrix-sdk 0.9 (E2EE + SQLite), mistralai-client 1.1.0 (private registry), opensearch 2, deno_core 0.393, tonic 0.14 (gRPC), tree-sitter 0.24, rusqlite 0.32 (bundled), ruma 0.12, tokenizers 0.22.

Building

cargo build --release

Docker (cross-compile to x86_64 Linux, vendored deps):

docker build -t sol .

Production build + deploy:

sunbeam build sol --push --deploy

The Dockerfile uses a two-stage build: deps layer (cached until Cargo.toml/vendor change) → source layer (only Sol code recompiles). Final image is gcr.io/distroless/cc-debian12:nonroot.

Testing

cargo test

Integration tests against the Mistral API require a .env file with SOL_MISTRAL_API_KEY:

cargo test --test integration_test

License

Sol is dual-licensed:

  • Open SourceGNU Affero General Public License v3.0 (AGPL-3.0-or-later). You can use, modify, and distribute Sol freely under the AGPL. If you run a modified version as a network service, you must share your changes under the same license.

  • Commercial — For organizations that want to use Sol without AGPL obligations (private modifications, proprietary integrations, no source disclosure), a commercial license is available. The commercial license grants unlimited internal use but does not permit redistribution.

For commercial licensing or other licensing questions, contact hello@sunbeam.pt.