Mnemolis
A unified local knowledge search API for self-hosted homelabs. Mnemolis runs as a Docker container on your internal network and routes queries to the appropriate backend — offline knowledge, weather forecast, RSS news, live web search, service monitoring, or multiple sources concurrently — via a single endpoint. When a client wants an answer rather than raw source text — voice assistants especially — it can also compose a short, grounded answer from the retrieved material only, returned alongside the raw result (see Answer Synthesis).
Exposes both a REST API and an MCP server so any client can connect to it.
This README covers what it is, installation, and the API reference. For deep-dive mechanism detail, exact scoring weights, and the real bugs found and fixed along the way, see the Wiki.
Why Mnemolis
A homelab accumulates real, distinct sources of truth — your own RSS feeds, an offline encyclopedia, weather, service uptime, Home Assistant state — but each one normally needs its own query language, its own client, its own mental context switch. Mnemolis exists so you can ask one plain-language question and not have to know in advance which backend actually has the answer, or query three of them yourself when the real answer spans more than one. It runs entirely on your own infrastructure — Open-Meteo is the one deliberate exception — so asking it something never means sending your query, your home's state, or your reading habits to a third party.
Sources
| Source | Backend | Description |
|---|---|---|
kiwix |
Kiwix | Offline knowledge base — Wikipedia, Stack Exchange, iFixit, FreeCodeCamp, DevDocs |
forecast |
Open-Meteo | 3-day weather forecast, no API key required |
news |
FreshRSS | Recent articles from your RSS feeds via GReader API |
web |
SearXNG | Live web search via your local SearXNG instance |
uptime |
Uptime Kuma | Service monitor status — reports any down services |
ha |
Home Assistant | Entity state summaries — lights, locks, sensors, motion, batteries, power |
changes |
Snapshot Engine | Detected changes since last snapshot — outages, weather shifts, new headlines |
history |
History Engine | Recorded time series over the house's own sensors/services — highs, lows, averages, counts, trends. Off by default (HISTORY_ENABLED) |
fusion |
— | Query multiple sources concurrently and merge results |
auto |
— | Mnemolis detects intent and picks the best source |
Integrations
| Client | Protocol | How |
|---|---|---|
| Open WebUI | REST | Lightweight tool that POSTs to /search |
| Mnemolis Intents | REST | Native HA LLM API integration |
| Any MCP client (Claude Desktop, Cursor, etc.) | MCP/Streamable HTTP | Connect to http://your-host:8888/mcp |
MCP
Mnemolis exposes an MCP server via Streamable HTTP at /mcp on the same port as the REST API. (Previously SSE at /mcp/sse — migrated since SSE is being superseded across the MCP ecosystem. See the wiki's MCP Server page for the full reasoning and a real bug found and fixed during the migration.)
Connecting Claude Desktop
Add this to your claude_desktop_config.json:
{
"mcpServers": {
"mnemolis": {
"url": "http://your-host-ip:8888/mcp"
}
}
}
Connecting any MCP client
Streamable HTTP endpoint: http://your-host-ip:8888/mcp
The MCP server exposes a single search tool with the same interface as the REST API, including fusion_sources support and the optional synthesize / answer_style arguments for a grounded synthesized answer.
Requirements
- Docker + Docker Compose
- A Docker network for container communication (default:
mnemo-net) - One or more of the supported backends running and reachable on the same network
Quick Start
Full stack (recommended)
The repo includes an example compose file and SearXNG config to get all services running together:
git clone https://github.com/immortalbob/Mnemolis
cd Mnemolis
# Create the shared network if it doesn't exist
docker network create mnemo-net
# Copy and edit the example compose file
cp docker-compose.example.yml docker-compose.yml
# Fill in credentials, your coordinates, and secret_key in searxng/settings.yml
docker compose up -d
What's not in the full stack
The example compose intentionally excludes Home Assistant, your LLM backend, and Uptime Kuma — these are typically long-running services with their own existing setup.
If you're running any of these in Docker and want them reachable by Mnemolis, connect them to mnemo-net:
docker network connect mnemo-net ollama
docker network connect mnemo-net homeassistant
Mnemolis only
If you already have the backends running:
git clone https://github.com/immortalbob/Mnemolis
cd Mnemolis
# Edit docker-compose.yml with your settings
docker compose up -d
Hit http://your-host:8888/health to confirm it's running.
Full API docs at http://your-host:8888/docs.
Configuration
All settings are passed as environment variables in docker-compose.yml:
| Variable | Description | Default |
|---|---|---|
KIWIX_URL |
Kiwix container URL | http://kiwix:8080 |
FRESHRSS_URL |
FreshRSS container URL | http://freshrss |
FRESHRSS_USER |
FreshRSS username | |
FRESHRSS_API_PASSWORD |
FreshRSS API password | |
FRESHRSS_MAX_ARTICLES |
Max articles to fetch | 10 |
SEARXNG_URL |
SearXNG container URL | http://searxng:8080 |
SEARXNG_REQUEST_TIMEOUT_SECONDS |
How long Mnemolis itself waits for a SearXNG response — set to match or exceed SearXNG's own server-side max_request_timeout, see SearXNG request timeout |
25 |
WEB_NEWS_RAW_RESULT_BUDGET |
How many raw, unscored results to pull from each web search before confidence-aware scoring filters them down — the scoring pipeline's input budget, distinct from WEB_NEWS_TOP_N's output cap below |
25 |
QUERY_EXPANSION_MIN_WORDS |
Minimum query length (in words) for web search query expansion to trigger | 3 |
KIWIX_ARTICLE_MAX_CHARS |
How many characters of a fetched Kiwix article to keep before scoring/fusion sees it — distinct from FUSION_MAX_CHARS_PER_SOURCE, which truncates the already-combined multi-source response |
3000 |
KIWIX_MULTI_BOOK_FUSION_THRESHOLD_PCT |
A second book's best result must score at least this fraction of the leading book's top score to be included in a multi-book fusion response. Lower for more aggressive fusion, raise for more conservative | 0.5 |
SNAPSHOT_STALE_GRACE_MULTIPLIER |
How many multiples of a job's own expected interval can pass before /health flags it as "stale" rather than "ok" |
3 |
ROUTING_CACHE_TTL_SECONDS |
How long a routing decision (source, Kiwix book, disambiguation candidates) stays cached before the LLM gets asked again | 3600 |
CACHE_TTL_KIWIX_SECONDS |
Result cache TTL for kiwix |
86400 |
CACHE_TTL_FORECAST_SECONDS |
Result cache TTL for forecast |
1800 |
CACHE_TTL_NEWS_SECONDS |
Result cache TTL for news |
900 |
CACHE_TTL_WEB_SECONDS |
Result cache TTL for web |
3600 |
CACHE_TTL_UPTIME_SECONDS |
Result cache TTL for uptime |
60 |
CACHE_TTL_HA_SECONDS |
Result cache TTL for ha |
30 |
CACHE_TTL_CHANGES_SECONDS |
Result cache TTL for changes |
120 |
CACHE_TTL_FUSION_SECONDS |
Result cache TTL for fusion |
1800 |
CACHE_TTL_HISTORY_SECONDS |
Result cache TTL for history |
300 |
HISTORY_ENABLED |
Master switch for the History source — records the numeric sensors the snapshot jobs already fetch into a time series and answers highs/lows/averages/counts/trends. Ingestion rides inside snapshot_ha/snapshot_uptime (their cadence, zero added load — there is deliberately no interval knob). Off by default (has an on-disk cost); opt in per the standard rollout contract |
false |
HISTORY_RETENTION_DAYS |
Samples older than this are pruned in the sampler tick. ~40 numeric entities ≈ 60–80 MB at 90 days; cost scales linearly with tracked-entity count | 90 |
HISTORY_DEVICE_CLASSES |
Comma-separated HA device_classes the sampler keeps. battery is included on purpose (draining-battery questions for free); use HISTORY_EXCLUDE_ENTITIES to drop button-cell noise |
temperature,humidity,carbon_dioxide,power,energy,illuminance,pressure,battery |
HISTORY_EXTRA_ENTITIES |
Allowlist of unclassified numeric sensors worth recording — comma-separated entity IDs | (blank) |
HISTORY_EXCLUDE_ENTITIES |
Denylist of entity IDs the sampler must never keep even if they pass the device-class filter | (blank) |
HISTORY_TREND_MIN_SAMPLES |
A trend claim needs at least this many real samples in the window; below it, the answer reports the summary and says the window's too short to call a direction | 12 |
HISTORY_TREND_MIN_DELTA |
Per-window, unit-free noise floor: a slope only counts as a trend when the fitted change clears this fraction of the window's own observed value range | 0.1 |
HISTORY_STALE_GRACE_MULTIPLIER |
How many multiples of the sampler interval can pass before /health flags the history job "stale" |
3 |
FORECAST_LATITUDE |
Forecast location latitude — required for forecast to work at all; leaving it unset correctly reports forecast as not configured rather than returning weather for the wrong place |
(unset) |
FORECAST_LONGITUDE |
Forecast location longitude — same requirement as above | (unset) |
FORECAST_LOCATION_NAME |
Human-readable location name | (blank) |
FORECAST_TIMEZONE |
Timezone for forecast times | UTC |
UPTIME_KUMA_URL |
Uptime Kuma URL | (blank — disables uptime source) |
UPTIME_KUMA_USERNAME |
Uptime Kuma username | |
UPTIME_KUMA_PASSWORD |
Uptime Kuma password | |
UPTIME_KUMA_TIMEOUT_SECONDS |
How long the Uptime Kuma client waits before giving up. Lower for faster fallback on a genuinely unreachable instance | 10 |
HA_URL |
Home Assistant URL | (blank — disables HA source) |
HA_TOKEN |
Home Assistant long-lived access token | |
LLM_URL |
LLM backend URL for intelligent routing | (blank — disables LLM routing) |
LLM_MODEL |
Model to use for source and book selection | qwen3:8b |
LLM_API_TYPE |
API format: ollama or openai |
ollama |
LLM_CONNECTION_POOL_SIZE |
How many pooled HTTP connections to keep open to the LLM backend at once, for reuse across calls. Raise this if you run with significantly more than 20 concurrent users/requests | 20 |
LLM_KEEP_ALIVE |
How long Ollama keeps the model resident in VRAM after Mnemolis's last call (Ollama-native backend only — see below). Accepts Ollama's own formats: a duration string (30m, 3h), plain seconds, -1 (never unload), or 0 (unload immediately) |
5m |
EMBEDDING_MODEL |
Embedding model for the semantic routing cache — reuses routing decisions across rephrasings of the same question, skipping the cold LLM routing call. Blank disables the feature entirely (zero behavior change). On Ollama: ollama pull nomic-embed-text, then set this to match |
(blank — disabled) |
EMBEDDING_URL |
Backend serving the embedding model. Blank = same as LLM_URL |
(blank — uses LLM_URL) |
SEMANTIC_ROUTING_THRESHOLD |
Cosine-similarity floor for reusing another query's routing decision. Deliberately conservative — a wrong reuse silently misroutes | 0.92 |
SYNTHESIS_ENABLED |
Master switch for grounded answer synthesis — per-request LLM composition of a short answer from the retrieved material only, returned in a separate answer field alongside the raw result. Additive: off (or a request that doesn't ask) leaves the response byte-identical. Defaults on (opt-out); every call is still gated by the per-request synthesize flag |
true |
SYNTHESIS_TIMEOUT_SECONDS |
Synthesis generation timeout — its own budget, independent of the routing call's. On timeout, answer is null and result is untouched |
20 |
SYNTHESIS_MODEL |
Blank = LLM_MODEL. Set to run a larger instruct model for synthesis while routing uses a smaller, faster one on the same backend |
(blank — uses LLM_MODEL) |
SYNTHESIS_INPUT_BUDGET_CHARS |
Retrieved-material characters offered to the synthesis prompt, apportioned across sections | 6000 |
SYNTHESIS_MIN_INPUT_CHARS |
Results shorter than this skip synthesis (a one-line HA state answer is already ideal for voice) — exempts most ha/uptime traffic |
200 |
SYNTHESIS_MAX_CHARS / SYNTHESIS_VOICE_MAX_CHARS |
answer_style length ceilings: detailed / voice. brief (default style) is fixed at 800 |
2000 / 400 |
SYNTHESIS_DIGEST_MAX_CHARS / SYNTHESIS_DIGEST_INPUT_BUDGET_CHARS |
answer_style=digest output and input budgets (v3.55.1) — larger than the other styles because digest preserves many distinct items ("summarize/list" queries) instead of fusing them |
3000 / 12000 |
MORNING_START_HOUR |
Reference hour (0-23, local time) for resolving "this morning" in changes queries | 6 |
WORK_START_HOUR |
Reference hour (0-23, local time) for resolving "while at work" in changes queries | 9 |
API_KEYS |
Comma-separated list of valid API keys. Protects POST /search and GET /changes. |
(blank — auth disabled) |
FORECAST_PRECIP_THRESHOLD_PCT |
Precipitation probability (%) above which the forecast mentions rain chance | 20 |
FORECAST_WIND_THRESHOLD_MPH |
Wind speed (mph) above which the forecast mentions wind | 15 |
FORECAST_TEMP_CHANGE_THRESHOLD |
Temperature shift (°) between snapshots that counts as a meaningful weather change | 5.0 |
BATTERY_LOW_THRESHOLD_PCT |
Battery level (%) below which a snapshot diff reports "low" | 20.0 |
FUSION_MAX_SOURCES |
Maximum number of sources allowed in a single fusion query | 4 |
FUSION_MAX_CHARS_PER_SOURCE |
Characters per source result before truncation in fusion output | 1500 |
FUSION_TIMEOUT_SECONDS |
Maximum time to wait for any single source in a fusion query — now also bounds the caller's actual wait, not just the gather loop (see v3.50.18) | 15 |
FUSION_THREAD_POOL_SIZE |
Worker threads in fusion's shared, long-lived thread pool, reused across every concurrent fusion call instead of a fresh pool per call | 12 |
CACHE_MAX_SIZE |
Maximum result cache entries before oldest-eviction kicks in | 500 |
ROUTING_CACHE_MAX_SIZE |
Maximum routing cache entries before oldest-eviction kicks in | 1000 |
KIWIX_SEARCH_LIMIT |
Results requested per book per Kiwix search — higher values help the scoring function find the right answer among brand-name collisions | 15 |
KIWIX_MAX_BOOKS |
Maximum number of Kiwix books the LLM can select for a single query — raise for broader multi-book fusion | 2 |
WEB_NEWS_SCORE_THRESHOLD |
Web/news results scoring at or below this are dropped as irrelevant | 0 |
WEB_NEWS_TOP_N |
Maximum web/news results kept after scoring | 10 |
LOG_LEVEL |
Application log level (DEBUG, INFO, WARNING, ERROR) — INFO shows decomposition splits, disambiguation candidates, and article selection decisions |
INFO |
ADVERSARIAL_TEST_ENABLED |
Master on/off switch for Adversarial Self-Testing — opt-in (defaults off); generates extra background LLM/SearXNG/Kiwix traffic when on. false skips DB init, never registers the scheduler job, and makes POST /adversarial/trigger a safe no-op |
false |
ADVERSARIAL_TEST_INTERVAL_MINUTES |
How often the adversarial-testing scheduler tick fires | 60 |
ADVERSARIAL_TEST_BATCH_SIZE |
Queries generated per tick — cheap to raise, since generation is pure combinatorics with no LLM calls in the hot path | 8 |
ADVERSARIAL_TEST_LATENCY_OUTLIER_MULTIPLIER |
How many multiples of a recipe's own historical p95 latency counts as a real outlier | 1.5 |
ADVERSARIAL_TEST_LATENCY_OUTLIER_FLOOR_MS |
A floor below which latency is never flagged regardless of the multiplier | 1000 |
ADVERSARIAL_TEST_LATENCY_OUTLIER_MIN_SAMPLES |
How many historical samples a recipe needs before the latency-outlier check engages at all | 10 |
TEMPORAL_PATTERN_DETECTION_ENABLED |
Master on/off switch for Cross-Source Temporal Pattern Detection. false skips DB init, never registers the scheduler job, and makes POST /temporal-patterns/trigger a safe no-op |
true |
TEMPORAL_PATTERN_MINING_INTERVAL_HOURS |
How often the mining cycle runs — deliberately far longer than every other scheduler job, since mining over a short window is statistically meaningless given how infrequently real structured events occur | 24 |
TEMPORAL_PATTERN_LAG_WINDOW_MINUTES |
The maximum lag within which event B must follow event A to count as one real occurrence of that pair | 30 |
TEMPORAL_PATTERN_MIN_OCCURRENCES |
A hard floor below which a pair is never even significance-tested, regardless of what the math would say | 5 |
TEMPORAL_PATTERN_SIGNIFICANCE_LEVEL |
The per-comparison significance level, before Bonferroni correction divides it by the number of pairs actually tested in a given pass | 0.05 |
TEMPORAL_PATTERN_VALIDATION_WINDOW_HOURS |
How much later, non-overlapping data a candidate needs to be re-checked against before it can be promoted to confirmed |
24 |
TEMPORAL_PATTERN_STALE_GRACE_MULTIPLIER |
Same role as SNAPSHOT_STALE_GRACE_MULTIPLIER — how many missed mining intervals before /health flags this job stale |
3 |
FreshRSS API setup
- Enable API access: Administration → Authentication → Allow API access
- Set an API password: Profile → API password
- Use that password for
FRESHRSS_API_PASSWORD(it's separate from your login password)
SearXNG JSON format
Mnemolis queries SearXNG's JSON API. The included searxng/settings.yml already has this enabled. If you're using an existing SearXNG instance, make sure json is in your formats list:
search:
formats:
- html
- json
Also generate a unique secret_key in searxng/settings.yml:
openssl rand -hex 32
SearXNG request timeout
SearXNG's default request_timeout (3.0s) is too short for several real, commonly-used engines, which can take 15-25+ seconds to respond under normal conditions. The shipped searxng/settings.yml already raises this:
outgoing:
request_timeout: 10.0
max_request_timeout: 20.0
pool_connections: 100
pool_maxsize: 20
If you're upgrading from an older deployment, copy these into your own settings.yml and restart SearXNG. If "Error reaching SearXNG: connection failed" persists after the change, verify SearXNG actually picked it up — a correctly-edited config file doesn't help if the container was never restarted. Full story, including how this was diagnosed: The SearXNG Timeout Lesson.
SEARXNG_REQUEST_TIMEOUT_SECONDS (default 25, on the Mnemolis side) is already set above SearXNG's own max_request_timeout shown here — if you raise the SearXNG-side value further, raise this to match or exceed it too, or Mnemolis will cut the connection first regardless of how generously SearXNG itself is configured to wait.
Several major engines are disabled by default in the shipped settings.yml, in favor of a smaller, more reliable set. Found via direct log inspection under real, sustained query load against a small self-hosted instance:
| Engine | Why it's disabled |
|---|---|
duckduckgo |
Its own per-engine timeout: stays at SearXNG's old factory value (10.0s) even after the global timeouts above are raised — per-engine overrides don't inherit from global settings — and it independently hit its own CAPTCHA defense under sustained querying |
google |
SearXNG's own Google scraper has a known, recurring, externally-reported bug (IndexError: list index out of range) whenever Google's HTML structure shifts — not specific to this deployment |
bing |
The identical class of scraper fragility reported against Google also affects Bing's scraper |
brave |
Hit a real rate-limit suspension (suspended_time=180) under sustained querying |
wikipedia |
Also hit a real rate-limit suspension under the same load |
mojeek and presearch are explicitly enabled in their place (both disabled by SearXNG's own default) — corroborated by an independent report of someone hitting the identical failure pattern (brave suspended, duckduckgo access-denied) and finding mojeek/startpage/presearch worked cleanly. startpage is already enabled by SearXNG's own default.
A frequently-hanging or frequently-blocked engine contributes nothing to a fused result while still costing a slow timeout or a failed request on every query that includes it. Re-enable any of the disabled engines (disabled: false in the engines: block) if your own instance doesn't see this behavior — bot-detection sensitivity and rate-limit thresholds vary by IP reputation and query volume, so what broke on one deployment may not break on another.
LLM-assisted routing
Mnemolis uses a local LLM backend in five ways:
- Source selection — when
autois used and no keyword matches, the LLM picks the best source based on the query, returning multiple sources for complex multi-topic queries to trigger fusion automatically. Also biases toward including Kiwix for "everyone's talking about X"-style discourse framing — see Routing. - Book selection — once routed to Kiwix, the LLM picks the best books from your catalog for the query, up to
KIWIX_MAX_BOOKS(default 2) - Search term disambiguation — for short, definitional Kiwix queries (e.g. "what is a galaxy"), the LLM generates 2-3 candidate disambiguation terms to break brand-name/homonym collisions. Each candidate is actually searched and scored against real Kiwix results rather than trusting a single guess — see Kiwix Internal Flow.
- Fusion source selection — when
fusionis used without specifying sources, the LLM picks the best 2-3 sources for the query - Web query expansion — for web searches of 3+ words, the LLM generates one alternate phrasing so SearXNG is queried twice and results merged, scored against your original query — see Confidence-aware fusion
Auto-fusion escalation — source="auto" now detects multi-topic queries at the keyword level too. If a query matches triggers from multiple sources (e.g. "weather" + "services up"), fusion is triggered automatically without an LLM call.
Routing decisions (including disambiguation candidates and alternate phrasings) are cached for 1 hour so repeated queries skip the LLM call entirely.
Query decomposition and conditional detection (see Query Decomposition and Conditional Query Detection above) are deliberately pure pattern matching with no LLM involvement at all — they need to run on every single query before any LLM call happens at all, including when no LLM is configured.
Supported backends via LLM_API_TYPE:
ollama— Ollama native API (default)openai— OpenAI-compatible API (llama-server, LM Studio, etc.)
All LLM calls go through a single, persistent HTTP connection pool (LLM_CONNECTION_POOL_SIZE, default 20) rather than opening a fresh connection per call — matters most if your LLM backend runs on separate hardware from Mnemolis itself, where each fresh connection pays a real, avoidable network round-trip on top of inference time.
On the Ollama-native backend, every call also sends keep_alive (LLM_KEEP_ALIVE, default 5m, matching Ollama's own server-side default) so Mnemolis has its own explicit say in how long the model stays resident in VRAM, rather than depending entirely on whatever else might be sharing the same Ollama instance. Set this higher (30m, 3h, or -1 for never-unload) if your LLM backend is dedicated to Mnemolis or otherwise idle between requests for longer than 5 minutes. Not sent on the OpenAI-compatible path — Ollama's own OpenAI-compatible endpoint is confirmed to ignore it, and other OpenAI-compatible backends (llama-server, LM Studio) have no equivalent.
The book list is built dynamically from your Kiwix catalog at startup — see Kiwix Catalog & Article Fetching for the actual discovery mechanism. To force a refresh after adding ZIMs:
curl -X POST http://your-host:8888/catalog/refresh
If LLM_URL is left blank, Mnemolis falls back to keyword-based routing and Wikipedia for all Kiwix queries.
Timezone configuration
Set TZ in docker-compose.yml to your local timezone (e.g. America/New_York). Without it, the container defaults to UTC, which causes time-window phrases in changes queries ("this morning," "while at work") to be calculated against the wrong reference time — off by your UTC offset.
environment:
TZ: "America/New_York"
This same TZ value is also used by LOCAL_TIMEZONE, a setting that converts stored UTC timestamps (every database timestamp in Mnemolis is UTC internally) into real local time for any feature that needs to bucket activity by local hour-of-day or day-of-week — set TZ once and both get the correct timezone for free. If you specifically want that conversion to use a different zone than TZ, set LOCAL_TIMEZONE explicitly; it always takes priority over TZ:
environment:
TZ: "America/New_York"
LOCAL_TIMEZONE: "America/Los_Angeles" # only if you want these to differ
Most deployments should only ever need to set TZ.
FORECAST_TIMEZONE is a separate, third setting — it tells Open-Meteo what timezone to express forecast times in, and doesn't affect TZ/LOCAL_TIMEZONE or anything else. Defaults to UTC and is independent on purpose: it's a parameter sent to an external API, not something Mnemolis's own internal time logic depends on. Set it to match your actual timezone if you want forecast sunrise/sunset times to read correctly in local time.
API key authentication (optional)
By default, Mnemolis has no authentication at all — anyone who can reach it on your network can query it, with no key required. This matches the trust model of a homelab where Mnemolis sits behind your own firewall and isn't reachable from the open internet. If Mnemolis is ever exposed beyond a fully trusted local network — a VPN with split tunneling, a reverse proxy, a port forward — set API_KEYS before doing so, not after.
To require an API key for POST /search and GET /changes:
environment:
API_KEYS: "your-secret-key-here"
Multiple keys are supported, comma-separated:
environment:
API_KEYS: "key-for-open-webui,key-for-claude-desktop"
Clients must send the key in the X-API-Key header:
curl -X POST http://your-host:8888/search \
-H "X-API-Key: your-secret-key-here" \
-H "Content-Type: application/json" \
-d '{"query": "what is nitrogen", "source": "kiwix"}'
Setting API_KEYS only protects POST /search and GET /changes — every other endpoint stays unauthenticated regardless of this setting, including /health, /cache, /logs, /backup, and /areas. This is intentional, not an oversight: it keeps monitoring tools and discovery requests from being blocked, but it means API_KEYS is not a substitute for actual network-level access control if any of that other data (query logs, cache contents, a full backup of Mnemolis's state) would be sensitive in your specific deployment.
Home Assistant setup
Generate a long-lived access token in Home Assistant:
- Go to your Profile (click your username in the sidebar)
- Scroll to Long-lived access tokens
- Click Create Token, give it a name, copy the token
- Set
HA_URLto your HA instance URL (e.g.http://192.168.1.100:8123) - Set
HA_TOKENto the generated token
The ha source handles analytical queries that go beyond HA's built-in single-entity intent handling:
- "house status summary" — lights, locks, sensors, motion, batteries
- "indoor air quality" — CO2, temperature, humidity from indoor sensors
- "security status" — locks, doors, recent motion with time-ago
- "battery status" — all device battery levels
- "outdoor conditions" — weather station sensors
- "how much power am I using" — current and historical consumption
The ha source also participates in fusion — "house status and what's the weather" automatically fuses ha + forecast.
Architecture
Voice Assistant Flow
ESP32 Voice Assistant
│
▼
Home Assistant
│
▼
Mnemolis Intents
│
▼
Mnemolis
│
├────────────────────┐
│ │
▼ ▼
LLM Backend Source Providers
│ ├─ Kiwix
│ ├─ FreshRSS
▼ ├─ SearXNG
Smart Routing ├─ Open-Meteo
├─ Single source ├─ Uptime Kuma
├─ Auto-fusion ├─ Home Assistant
└─ Decomposition ────►└─ Snapshot Engine (changes)
│
▼
Answer Synthesis ← optional: LLM composes a short, grounded
(answer_style=voice) spoken answer from the retrieved material,
│ so TTS reads an answer, not fused headers
▼
Response
│
▼
Home Assistant TTS
│
▼
ESP32
For voice specifically, Mnemolis Intents requests synthesize=true with answer_style="voice" and speaks the grounded answer when present, falling back to the raw result otherwise — so a synthesis skip or failure reads exactly what it read before synthesis existed. See the Answer Synthesis wiki page.
Multi-Client Architecture
Open WebUI Claude Desktop Cursor Home Assistant
│ │ │ (Mnemolis Intents)
REST API MCP MCP REST API
│ │ │ │
└────────────────┴─────────────┴───────────────┘
│
▼
Mnemolis
│
Smart Routing
┌───────────────┬───────────────┬───────────────┐
▼ ▼ ▼ ▼
Single Source Auto-Fusion Decomposition Conditional
(keyword or LLM) (multi-keyword (conjunction Detection
/LLM) split) ("if X, Y")
│ │ │ │
└───────────────┴───────────────┴───────────────┘
│
┌────┴────┐
▼ ▼
REST API MCP/Streamable HTTP
│ │
Home Assistant Any MCP
(Mnemolis Intents) Client
│
Voice Pipeline
Snapshot Engine
Background Scheduler (APScheduler)
│
┌──────────┬────────┬───────────┐
▼ ▼ ▼ ▼
Uptime Forecast News HA
(2 min) (30 min) (60 min) (5 min)
│ │ │ │
└──────────┴────────┴───────────┘
│
▼
Store snapshot
(SQLite, JSON for HA)
│
Retain history scaled per source
(more frequent sources keep more
rows, so every source covers
at least a full week)
│
▼
Diff consecutive snapshots
┌──────────┬────────┬───────────┐
▼ ▼ ▼ ▼
Outages/ Temp/ New Lock/door/
Recovery Precip headlines battery
(net change) changes changes
(configurable thresholds)
└──────────┴────────┴───────────┘
│
▼
GET /changes?hours=N
source="changes" (auto-routed)
│
▼
Formatted summary
"what changed today?"
Full mechanics, including why outage/weather changes are collapsed to net change while news/HA events are reported individually: Snapshot Engine & Changes.
Source Fusion
source="auto" source="fusion"
│ │
▼ ▼
Keyword scan all sources LLM picks 2-3 sources
Multiple match? → fuse (or you specify explicitly)
Single match? → direct │
│ │
└────────────┬───────────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
Kiwix HA Forecast
FreshRSS SearXNG Uptime
(any combination of available sources,
queried concurrently)
│ │ │
└───────────┴───────────┘
│
Filter empty / failed results
web/news results scored + ranked
(app/scoring.py — keyword overlap,
generic-result penalty, recency)
Partial failure OK — best effort
│
Merge with [SOURCE — DESCRIPTION] headers
(descriptive label prevents cross-source
inference, e.g. weather ≠ news location)
│
Single Response
Fusion queries all specified sources concurrently, filters empty or failed results, and merges the remainder with descriptive source attribution headers (e.g. [FORECAST — WEATHER FORECAST FOR YOUR CONFIGURED HOME LOCATION]) so the LLM reading the fused response can't mistakenly infer facts across unrelated sections. If only one source returns results, it is returned directly without headers. Full mechanics: Fusion.
Query Decomposition
source="auto"
│
▼
Nosplit check
"compare", "vs", "between", etc.
│
▼
Try every conjunction type
"and", "also", "plus", "as well as"
(not just the first one found —
keep whichever split produces
the most genuine sub-intents)
│
▼
Each candidate sub-query must contain
either a recognized intent word/noun
OR a colloquial phrase anywhere in it
("what's the deal with X",
"what's up with X", etc.)
│
┌────┴────┐
▼ ▼
Single Multiple
intent intents
│ │
│ ┌────┴────────────┐
│ ▼ ▼
│ Sub-query 1 Sub-query 2
│ │ │
│ Route Route
│ independently independently
│ │ │
│ └────────┬────────┘
│ │
│ Same source? → Merge headers
│ Different? → Keep separate
│ Resolved to internal fusion?
│ → Pass through unwrapped
│ (already self-headered)
│ │
└─────────────┤
▼
Single Response
with [SOURCE — DESCRIPTION] attribution
Decomposition only applies to source="auto". It handles casual, colloquial phrasing the same as formal phrasing, protects bare proper-noun pairs ("Iran and Israel") from being mistaken for two separate intents, and biases discourse-framing queries ("everyone keeps talking about X") toward including Kiwix rather than letting them route past it entirely. Full mechanics, including the real bugs found and fixed in this logic: Query Decomposition and The Proper-Noun-Pair Saga.
Conditional Query Detection
"if X, Y" / "should X, Y" / "in case X, Y"
│
▼
Leading-comma pattern match
(deliberately narrow — see below)
│
┌───────┴───────┐
▼ ▼
No match Match
│ │
Route normally Extract condition, consequence,
(decomposition, and any remainder text
conditional check │
re-applied to Search ONLY the condition
each sub-query) │
│ Source a structured,
│ binary signal?
│ (ha / uptime / forecast-rain)
│ │
│ ┌───────┴───────┐
│ ▼ ▼
│ No Yes
│ │ │
│ Present real State explicit verdict
│ result, note ("It IS/IS NOT the
│ it's conditional case that X...")
│ │ │
│ └───────┬───────┘
│ ▼
│ Remainder present?
│ (real intent that
│ followed the
│ conditional)
│ │
│ ┌───────┴───────┐
│ ▼ ▼
│ No Yes
│ │ │
│ Return Search remainder
│ framed independently,
│ response merge into response
└───────┬───────────────┘
▼
Final Response
Detection is deliberately narrow — only a leading "if X, Y" / "should X, Y" / "in case X, Y" form with an explicit comma is recognized, since "if" is genuinely ambiguous in English and this form sidesteps that ambiguity entirely. Mnemolis has no reminder or trigger capability, so the response is framed honestly around the cond
No comments yet
Be the first to share your take.