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, and a multi-agent architecture.
- **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, room context retrieval, room info, and a sandboxed TypeScript/JavaScript runtime (deno_core) for computation.
- **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** — first domain agent (sol-devtools): list repos, search issues, create issues, list PRs, get file contents — all as the requesting user.
- **multi-agent architecture** — an orchestrator agent with personality + tools + web search. domain agent delegation is dynamic — only active agents appear in instructions. agent state 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.
- **multimodal** — m.image messages are downloaded from Matrix via mxc://, converted to base64 data URIs, and sent as `ContentPart::ImageUrl` to Mistral vision models.
4. Build system prompt via `Personality` (template substitution: `{date}`, `{room_name}`, `{members}`, `{memory_notes}`, `{room_context_rules}`, `{epoch_ms}`)
5. Assemble message array: system → context messages (with timestamps) → trigger (multimodal if image)
6. Tool iteration loop (up to `max_tool_iterations`, default 5):
- If `finish_reason == ToolCalls`: execute tools, append results, continue
- If text response: strip "sol:" prefix, return
7. Fire-and-forget memory extraction
### conversations API path (`generate_response_conversations`)
1. Apply response delay
2. Send typing indicator
3. Format input: raw text for DMs, `<@user:server> text` for groups
4. Send through `ConversationRegistry.send_message()` (creates or appends to Mistral conversation)
5. Function call loop (up to `max_tool_iterations`):
- Execute tool calls locally via `ToolRegistry`
- Send `FunctionResultEntry` back to conversation
6. Extract assistant text, strip prefix, return
## tool system
| Tool | Parameters | Description |
|------|-----------|-------------|
| `search_archive` | `query` (required), `room`, `sender`, `after`, `before`, `limit`, `semantic` | Search the message archive (keyword or semantic) |
| `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 |
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`.
## 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 as a `## notes about {display_name}` block with instructions to use them naturally without mentioning their existence.
## 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 tracking** — `m.replace` events update the original document's content
- **Redaction** — `m.room.redaction` sets `redacted: true` on the original
- **Reactions** — `m.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)
The orchestrator agent carries Sol's full personality (system prompt) plus all 5 tool definitions converted to `AgentTool` format. It's created on startup if `agents.use_conversations_api` is enabled. Temperature: 0.5.
Domain agents are defined in `agents/definitions.rs` as `DOMAIN_AGENTS` (name, description, instructions). Temperature: 0.3.
### ToolBridge
`tools/bridge.rs` provides a generic async handler map (`ToolBridge`) for mapping Mistral tool call names to handler functions. This is scaffolding for future SDK-based tool integration where domain agents will have their own tool sets.
## persistence
SQLite database at `/data/sol.db` (configurable via `matrix.db_path`), WAL mode.
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`.