Simplify Athena stack and recovery

This commit is contained in:
Mikei386
2026-08-25 22:27:26 +02:00
parent c4851305d1
commit 069da8b4f0
70 changed files with 887 additions and 5806 deletions
+1 -12
View File
@@ -64,7 +64,7 @@ else
fi
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-operator; do
mike-ai-mcp-github mike-ai-mcp-athena-operator mike-ai-backup; do
if container_healthy "$optional"; then
pass "$optional aktiv"
else
@@ -78,17 +78,6 @@ else
pass "kein GraphQL-basierter Unraid-MCP vorhanden"
fi
if [[ -s /var/lib/mike-ai-platform-context/runtime.json ]]; then
snapshot_age=$(( $(date +%s) - $(stat -c %Y /var/lib/mike-ai-platform-context/runtime.json) ))
if (( snapshot_age <= 180 )); then
pass "Platform-Kontext-Snapshot aktuell (${snapshot_age}s)"
else
fail "Platform-Kontext-Snapshot veraltet (${snapshot_age}s)"
fi
else
fail "Platform-Kontext-Snapshot fehlt"
fi
if docker exec mike-ai-router python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=3)" \
>/dev/null 2>&1; then
@@ -84,7 +84,6 @@ start_proxy 8642 hermes:8642
# MCP endpoints. Optional services keep their listener even while stopped and
# begin working automatically as soon as their container is started.
start_proxy 8201 mcp-platform-context:8000
start_proxy 8202 mcp-athena-operator:8000
# Portable general web MCP for Pi, Hermes and other clients. OpenWebUI uses
# its native broad search by default; both paths are site-agnostic.
+9 -58
View File
@@ -77,20 +77,16 @@ agent:
max_web_searches: 8
max_subagents: 8
# Compact before a tool-heavy session can grow beyond the selected model's
# usable window. Large old tool results are pruned without an LLM call first;
# lean tail retention avoids several expensive back-to-back summary passes.
# Keep ample room for long agent work. Compression starts at 82% of whichever
# router profile is selected and retains a useful 35% instead of collapsing a
# large conversation to a tiny summary.
compression:
enabled: true
progress_notices: true
threshold: 0.65
# A common absolute ceiling keeps all router profiles responsive. It also
# prevents a long Medium/Large/Ultra chat from becoming impossible to move
# back to Fast later.
threshold_tokens: 60000
target_ratio: 0.15
threshold: 0.82
target_ratio: 0.35
tail_mode: "lean"
protect_last_n: 8
protect_last_n: 12
protect_first_n: 0
proactive_prune_tokens: 50000
proactive_prune_min_result_chars: 4000
@@ -158,51 +154,6 @@ timeouts:
concurrent_batch: 900
sequential_call: 900
mcp_servers:
athena-platform:
url: "http://mcp-platform-context:8000/mcp"
timeout: 180
connect_timeout: 30
supports_parallel_tool_calls: false
athena-operator:
url: "http://mcp-athena-operator:8000/mcp"
timeout: 900
connect_timeout: 30
supports_parallel_tool_calls: false
web-general:
url: "http://tinysearch:8000/mcp"
timeout: 180
connect_timeout: 30
supports_parallel_tool_calls: false
github:
url: "http://mcp-github:8000/mcp"
timeout: 300
connect_timeout: 30
supports_parallel_tool_calls: false
homeassistant-admin:
url: "http://mcp-homeassistant:8000/mcp"
timeout: 300
connect_timeout: 30
supports_parallel_tool_calls: false
arr:
url: "http://mcp-arr:8000/mcp"
timeout: 600
connect_timeout: 30
supports_parallel_tool_calls: false
navidrome:
url: "http://mike-ai-mcp-navidrome:3000/mcp"
timeout: 300
connect_timeout: 30
supports_parallel_tool_calls: false
deemix:
url: "http://mcp-deemix:8000/mcp"
timeout: 300
connect_timeout: 30
supports_parallel_tool_calls: false
unraid:
url: "${MUA_MCP_URL}"
headers:
Authorization: "Bearer ${MUA_MCP_BEARER_TOKEN}"
timeout: 900
connect_timeout: 30
supports_parallel_tool_calls: false
# BEGIN MANAGED MCP SERVERS
mcp_servers: {}
# END MANAGED MCP SERVERS
+3
View File
@@ -78,6 +78,9 @@ if placeholder not in text:
raise SystemExit("ROUTER_API_KEY placeholder missing from managed Hermes config")
path.write_text(text.replace(placeholder, os.environ["ROUTER_API_KEY"], 1))
PY
python3 "$STACK_DIR/platform/mcp/sync-clients.py" \
--registry "$STACK_DIR/config/mcp-registry.json" \
--hermes "$HERMES_DATA_DIR/config.yaml"
install -m 0600 "$STACK_DIR/platform/hermes/SOUL.md" "$HERMES_DATA_DIR/SOUL.md"
chown -R 10000:10000 "$HERMES_DATA_DIR"
"$STACK_DIR/platform/hermes/install-skills.sh"
+10 -2
View File
@@ -30,8 +30,10 @@ create_profile() {
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.max_turns 64
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set agent.reasoning_effort minimal
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set auxiliary.title_generation.enabled false
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold_tokens 60000
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 8
docker exec "$HERMES_CONTAINER" hermes -p "$name" config unset compression.threshold_tokens || true
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.threshold 0.82
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.target_ratio 0.35
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_last_n 12
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set compression.protect_first_n 0
docker exec "$HERMES_CONTAINER" hermes -p "$name" config set platform_toolsets.cli \
'["web","terminal","file","skills","todo","memory","vision","tts"]'
@@ -80,6 +82,12 @@ for path in paths:
path.write_text(text)
PY
sync_args=(--registry "${STACK_DIR:-/opt/mike-ai/stack}/config/mcp-registry.json")
while IFS= read -r config; do
sync_args+=(--hermes "$config")
done < <(find "$HERMES_DATA_DIR" -name config.yaml -type f -print)
python3 "${STACK_DIR:-/opt/mike-ai/stack}/platform/mcp/sync-clients.py" "${sync_args[@]}"
"${STACK_DIR:-/opt/mike-ai/stack}/platform/hermes/install-skills.sh"
docker exec "$HERMES_CONTAINER" hermes profile list
printf 'HERMES_PROFILES_OK\n'
@@ -4,22 +4,22 @@ description: Understand, operate and extend the Athena AI host.
license: MIT
metadata:
hermes:
version: 1.0.0
version: 2.0.0
author: Michael Roll
platforms: [linux]
tags: [athena, docker, mcp, models, recovery]
tags: [athena, docker, mcp, models, backup]
---
# Athena Operator
Use this skill for work on Athena itself: Docker, MCPs, models, profiles,
Hermes, OpenWebUI, TTS, STT, image generation, Git and recovery.
Hermes, OpenWebUI, TTS, STT, image generation, Git and backups.
## Start
1. Read `ATHENA.md` through Athena Platform Context. It is the normal source
of architectural truth.
2. Call `athena_operator_inspect` once for the affected area.
1. Call `athena_operator_inspect` with `subject=guide`; it returns the current
`ATHENA.md` as the architectural truth.
2. Inspect the affected live area only if needed.
3. Search for the concrete source file, then read only the required lines.
Do not rediscover the complete platform for every task. Do not read entire
@@ -32,8 +32,9 @@ apply the smallest durable change. The operator owns the Git worktree and
deployment access; do not clone another repository or request another SSH key.
Afterwards run focused checks, verify the affected service, commit and push.
Create a recovery kit after the complete change works, not after every
intermediate edit.
The scheduled Docker-data backup is automatic. After storage-affecting work,
run one manual backup and verify its archive instead of building a special
recovery kit.
For a new MCP, normally change only its server code, Dockerfile, MCP Compose
service, env example, client registration and a focused test. Reuse an existing
@@ -47,7 +48,7 @@ backend instead of installing a duplicate service.
- Prefer specialist MCPs for Home Assistant, Unraid, ARR, Navidrome and other
external systems.
- A healthy container is not proof; perform one bounded functional check.
- Never claim a write, deploy, commit, push or recovery succeeded without its
- Never claim a write, deploy, commit, push or backup succeeded without its
actual result.
## Remote-host boundary
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
ARG MCP_PROXY_VERSION=0.12.0
RUN pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp==1.29.0"
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
COPY platform_context_mcp.py /app/platform_context_mcp.py
RUN chown -R 10001:10001 /app
USER 10001:10001
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
CMD ["python", "/app/platform_context_mcp.py"]
-342
View File
@@ -1,342 +0,0 @@
# Zentrale MCP-Werkzeugebene
MCP-Werkzeuge sind **keine llama.cpp-Startparameter**. Sie laufen als kleine,
voneinander getrennte Container und werden von OpenWebUI, Hermes oder einem
anderen MCP-Client gezielt ausgewählt. Das hält Tool-Schemas aus normalen
Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
200.000 Tokens und macht Werkzeuge unabhängig vom geladenen Modellprofil.
## Container
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|---|---|---|---|
| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | kurze read-only Athena-Auskunft und begrenzter Snapshot | an |
| `mcp-athena-operator` | `http://mike-ai-mcp-athena-operator:8000/mcp` | vollständiger Betrieb plus breites begrenztes Terminal | an |
| `tinysearch` | `http://tinysearch:8000/mcp` | allgemeine portable Websuche und Seitenabruf | an |
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | frühere spezialisierte Web-Fassade | nur Profil `legacy-web` |
| `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` |
| `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` |
| `mcp-navidrome` | `http://mike-ai-mcp-navidrome:3000/mcp` | Navidrome-Bibliothek, Suche, Playlists, Favoriten und Hörverlauf | Profil `navidrome` |
| `mcp-github` | `http://mike-ai-mcp-github:8000/mcp` | offizieller GitHub-MCP, auf drei kleine Repository-Lesewerkzeuge begrenzt | Profil `github` |
| `mcp-unraid-ssh` | `http://mike-ai-mcp-unraid-ssh:8000/mcp` | erweiterte Diagnose über einen erzwungenen SSH-Befehl | optional (`extended`) |
Unraid wird produktiv ausschließlich über das auf dem HomeServer laufende
MUA-Plugin (`http://192.168.1.2:3002/mcp`) angebunden. Open WebUI führt davon
zwei Ansichten: `mua-readonly-local` für automatische Diagnose und `mua` für
bewusst aktivierte Verwaltungsaktionen. Ein GraphQL-basierter Unraid-MCP ist
nicht Bestandteil des Stacks.
Die drei Websuch-Container verwenden `AI_DNS` aus
`/etc/mike-ai/stack.env`. Der Web-MCP hängt zusätzlich am getrennten
`mike-ai-tools-egress`-Netz, weil er gefundene öffentliche Seiten nach der
SSRF-Prüfung selbst abrufen muss. Ohne diese beiden Einstellungen kann die
Werkzeugauswahl korrekt wirken, während alle Suchmaschinen und Seitenabrufe
gleichzeitig fehlschlagen.
Der Platform Context MCP hat keinen Docker-Socket, keine Shell, keinen Egress
und keine Secrets. Sein aktueller Zustand stammt aus einem fest programmierten
Host-Snapshot. Er liefert `ATHENA.md` sowie kleine, begrenzte Such- und
Leseausschnitte. Vollständige Beschreibung:
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
Der Athena Operator MCP ist die einzige Bedienebene für Arbeiten an der lokalen
KI-Plattform. Seine sechs sichtbaren Werkzeuge sind Inspect, Suche, begrenztes
Lesen, Terminal, direkte Änderung und Jobstatus. Qwen kann damit MCPs und
Docker-Dienste bauen/deployen, Modelle laden, Benchmarks starten, Profile und
OpenWebUI pflegen, Git veröffentlichen und Recovery erzeugen. Zusätzlich bietet er ein breites, ausgabebegrenztes Terminal für
unvorhergesehene Docker-, Datei-, Git-, HTTP-, Modell- und Remote-SSH-Aufgaben.
Die MCP-Fassade sieht nur einen lokalen Unix-Socket; Root-Rechte verbleiben im
Executor. Strombefehle und Änderungen an Athenas SSH, LAN, WireGuard, Firewall,
Boot, Kernel, Mounts und Partitionen werden serverseitig blockiert.
Für Git-Publishing besitzt Athena ein eigenes Schlüsselpaar unter
`/etc/mike-ai/athena-operator-git{,.pub}`. Nur der öffentliche Schlüssel wird
in Gitea als schreibberechtigter Deploy-Key für `AI-Profile-Router` hinterlegt.
Der private Schlüssel verlässt Athena nicht und wird weder an den MCP-Container
noch an das Modell ausgegeben.
Der Gitea-Endpunkt ist als `ssh://...:33/...` konfiguriert; sein auf dem
Administrator-Mac verifizierter Ed25519-Hostschlüssel ist in
`config/athena-operator-known-hosts` fest gebunden. Ein unerwarteter
Hostschlüsselwechsel stoppt Git-Zugriffe, statt ihn still zu akzeptieren.
TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem
Container-Neustart vollständig verworfen.
TinySearch 0.6.1 ist der allgemeine portable Web-MCP. Auf VPN-Port 8203 können Hermes,
Pi und andere Clients seine vier Upstream-Werkzeuge direkt nutzen. SearXNG ist
der Such-Backenddienst. Die historische eigene Web-Fassade ist nur Rollback.
Die fünf Open-WebUI-Profile Fast, Medium, Large, Ultra und Uncensored halten für
allgemeine öffentliche Recherche Open WebUIs native Werkzeuge `search_web` und
`fetch_url` verfügbar. Neue Websites benötigen keine neue Selector-Regel.
Ein gemeinsamer Systemhinweis der fünf Profile verlangt Webprüfung bei
aktuellen, veränderlichen oder wesentlich unsicheren Tatsachen. Stabiles
Allgemeinwissen soll ohne unnötige Suche beantwortet werden. Die Anweisung
fordert gezielte statt wiederholter Synonymsuchen, Quellenlinks, transparente
Unsicherheit und behandelt Webseiteninhalte grundsätzlich als nicht
vertrauenswürdige Daten statt als Anweisungen.
## Entscheidungshilfe für das Modell
Die Server- und Werkzeugbeschreibungen grenzen die Zuständigkeiten voneinander
ab. Das Modell beginnt mit den breitesten geeigneten Grundfähigkeiten und nutzt
Fach-MCPs dort, wo strukturierte Daten oder Aktionen benötigt werden:
| Aufgabe | Werkzeugserver | Nicht zusätzlich verwenden |
|---|---|---|
| Aktuelle öffentliche Informationen, Quellen, Hugging Face, Produkte | Web | HA, ARR, Unraid |
| GitHub-Repository finden, README/Quellcode/API-Routen gezielt lesen | GitHub Repository | Web, HA, ARR |
| Entitäten, Zustände, Historie, Automationen und Dashboards | Home Assistant | Web, Unraid |
| Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web |
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
| Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | MUA · Unraid-Diagnose (read-only) | MUA-Verwaltung |
| Athena-KI-Plattform entwickeln, testen, deployen, Modelle/Git/Recovery pflegen | Athena Operator | Platform Context für reine Architekturauskunft |
| Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel |
Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende
Werkzeuge oder leicht veränderte Suchbegriffe erneut auszuführen. Das Modell
soll die Grenze transparent nennen und gezielt nachfragen, wenn eine Freigabe
oder ein anderes Werkzeug benötigt wird.
## Sicherheitsmodell
- Kein MCP-Port wird auf der physischen Universitätsadresse veröffentlicht.
Über Athenas WireGuard-Adresse sind die Fach-MCPs direkt auf den in
`docs/VPN_SERVICE_PORTS.md` dokumentierten Ports erreichbar.
- Nur Clients im privaten Docker-Netz `mike-ai-tools` erreichen die Endpunkte.
- Secrets bleiben in Dateien unter `/etc/mike-ai` und werden read-only
eingehängt. Sie gehören weder in Git noch in OpenWebUI-Tooldefinitionen.
- Jeder Container ist read-only, verliert Linux-Capabilities und hat
`no-new-privileges`.
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
- Das allgemeine Terminal ist Bestandteil des Athena Operators auf Port 8202;
ein zweiter Shell-MCP ist nicht erforderlich.
## Start
```bash
sudo platform/mcp/install-tools.sh
```
Der Grundstart enthält Plattformwissen, den Athena Operator und das allgemeine
TinySearch-Webwerkzeug. Bereits konfigurierte Fachbereiche werden explizit
ergänzt:
Das Skript erkennt vorhandene Secret-Dateien und aktiviert dadurch automatisch
`homeassistant`, `arr`, `navidrome` und `github`. Ohne Fach-Secrets bleiben die
secretfreien Grunddienste aktiv.
Für den derzeit migrierten Container kann der Name `Open-WebUI` lauten. Der
Netzwerkbefehl ist idempotent zu behandeln.
Die lokale Installation benötigt die vorhandenen Secret-Dateien:
```text
/etc/mike-ai/homeassistant-admin-mcp.env
/etc/mike-ai/arr-mcp.env
/etc/mike-ai/navidrome-mcp.env
/etc/mike-ai/github-mcp.env
/etc/mike-ai/mua-mcp.env
```
`mua-mcp.env` enthält ausschließlich MUA-Endpunkt und Bearer-Token. Der
Installer legt daraus die vollständige MUA-Verbindung und eine strikt auf
Lesewerkzeuge begrenzte automatische Ansicht an. Die Datei ist root-only
(Modus `0600`) und wird nur verschlüsselt im Recovery-Bundle gesichert.
Die erweiterte Unraid-Diagnose benötigt zusätzlich die Konfigurationsdatei,
den eingeschränkten Schlüssel und die bekannte Hostsignatur. Sie wird nur mit
`--profile extended` gestartet.
TinySearch speichert sein lokales Embedding-Modell in einem Docker-Volume.
Nach einer Erstinstallation wird das Modell einmalig im Container mit
`tinysearch setup` geladen. Das Volume bleibt bei Containerupdates erhalten.
## Navidrome
Der Navidrome-MCP basiert auf `Blakeem/Navidrome-MCP` 2.2.0; das amd64-Image
ist per OCI-Digest festgeschrieben. Ein kleiner Build-Patch ergänzt bei zwei
Internetradio-URL-Schemas das von llama.cpp verlangte abschließende `$`;
Verhalten und API-Aufrufe bleiben unverändert. Der Container veröffentlicht keinen
Host-Port, besitzt keinen Dateizugriff auf die Musikbibliothek und enthält
bewusst kein `mpv`. Er kann daher nicht auf Athena selbst Musik wiedergeben.
Navidrome sollte einen eigenen normalen Benutzer `mcp` erhalten. Dessen
Zugangsdaten liegen ausschließlich in der root-only Datei
`/etc/mike-ai/navidrome-mcp.env`; die Vorlage steht unter
`config/navidrome-mcp.env.example`. Anschließend genügt:
```bash
sudo install -m 0600 config/navidrome-mcp.env.example /etc/mike-ai/navidrome-mcp.env
sudoedit /etc/mike-ai/navidrome-mcp.env
sudo platform/mcp/install-tools.sh
sudo platform/openwebui/install-filters.sh
```
Der Upstream-Server stellt ohne Playback noch immer über 40 Werkzeuge bereit.
Darum wird Navidrome **nicht** als Standardwerkzeug an jedes Modellprofil
gehängt. Es wird in OpenWebUI nur für konkrete Musikaufgaben ausgewählt und
danach wieder ausgeschaltet. Schreibende Funktionen wie Playlist-Änderungen,
Favoriten und Bewertungen wirken unmittelbar im Konto des MCP-Benutzers.
Optional aktiviert `LASTFM_API_KEY` in derselben Secret-Datei sieben öffentliche
Empfehlungswerkzeuge für ähnliche Künstler/Titel, Trends und ergänzende
Metadaten. Das Last.fm Shared Secret ist dafür nicht erforderlich und wird
nicht gespeichert. Die Integration greift damit weder auf das persönliche
Last.fm-Profil noch auf dessen Hörverlauf zu.
## GitHub
Der GitHub-Container verwendet unverändert den offiziellen
`github/github-mcp-server` 1.10.1. Da dessen lokaler Container stdio spricht,
wandelt `mcp-proxy` 0.12.0 ausschließlich den Transport in Streamable HTTP für
Open WebUI und weitere interne Clients um. Basisimage und GitHub-Image sind per
OCI-Digest festgeschrieben; die Brücke implementiert keine GitHub-Operationen.
`mcp-proxy` läuft bewusst **stateless**. Die zuerst getestete Supergateway-
Brücke war mit Open WebUIs Python-MCP-Client nicht zuverlässig kompatibel: Im
stateful Betrieb konnten Sitzungen ablaufen; im stateless Betrieb beantwortete
sie die reguläre `notifications/initialized`-Nachricht mit HTTP 400. Beides
führte trotz gesundem GitHub-Server und gültigem Token zu
`Failed to connect to MCP server 'github-local'`. Der jetzt verwendete Proxy
ist derselbe Transport, der sich bereits beim Athena Platform Context MCP
bewährt hat.
Die Brücke wird mit `--pass-environment` gestartet. Ohne diese ausdrückliche
Option sieht zwar der Proxy-Prozess den per Docker-Envfile injizierten PAT, der
von ihm gestartete GitHub-stdio-Unterprozess jedoch nicht; der offizielle
Server fällt dann irreführend auf die interaktive GitHub-Geräteanmeldung
zurück. Der Token bleibt dabei eine Umgebungsvariable und erscheint weder in
Kommandozeile noch Image, Log oder Open-WebUI-Konfiguration.
Dem Modell werden ausschließlich `search_repositories`, `get_file_contents`
und `search_code` angeboten. Rekursive Komplettbäume wurden entfernt, nachdem
ein einzelner Aufruf mehr als 100.000 Zeichen erzeugte und die Antwort
verdrängte. Der offizielle Server wird
zusätzlich explizit mit `--read-only` gestartet; die Umgebungsvariablen im
Compose-Stack bleiben als zweite, deklarative Sicherung erhalten. Damit sind
Schreiboperationen auch serverseitig ausgeschlossen. Der Container
veröffentlicht keinen Host-Port und speichert den Token nicht in Open WebUI.
Einrichtung:
```bash
sudo install -m 0600 config/github-mcp.env.example /etc/mike-ai/github-mcp.env
sudoedit /etc/mike-ai/github-mcp.env
sudo platform/mcp/install-tools.sh
sudo platform/openwebui/install-filters.sh
```
Der Token muss eigens für Athena erzeugt werden und ausschließlich lesenden
Zugriff auf die tatsächlich benötigten Repositories erhalten. Die drei
begrenzten Werkzeuge werden bei vorhandener Secret-Datei an die fünf
MikeAI-Profile geheftet. Dadurch kann das Modell Repositoryfragen selbständig
prüfen, ohne den großen GitHub-Standardwerkzeugkatalog in den Kontext zu laden.
## Client-Auswahl
Große Fachwerkzeuge werden nicht pauschal an jedes Modell gehängt. Nur die
native OpenWebUI-Websuche und die drei GitHub-Lesewerkzeuge sind allgemein verfügbar.
Für Home-Assistant-Fragen wird HA ausgewählt, für Medien ARR und für die NAS
Unraid. Weitere Werkzeuge werden nur aktiviert, wenn die Aufgabe tatsächlich
mehrere Bereiche verbindet.
Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und
einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals
eine read-only Policy aufheben.
## Home Assistant: abgesicherter YAML-Zugang
Die Referenzinstallation überlagert im nativen `czechbol/hass-mcp` das Werkzeug
`ha_yaml_config` mit
`patches/hass_mcp/yaml_config.py`. Es erlaubt strukturierte Zugriffe nur auf
`automations.yaml`, `scripts.yaml` und `scenes.yaml`. Für auskommentierte Blöcke
stehen begrenztes `read_source` und `find_source` zur Verfügung;
`configuration.yaml` darf dabei ebenfalls gelesen werden. Beliebige Pfade und
`secrets.yaml` sind konstruktiv ausgeschlossen. Verdächtige Inline-Zugangsdaten
und selbst Namen von `!secret`-Referenzen werden in Rohtextantworten maskiert.
Jede Änderung arbeitet zweistufig: Vorschau mit einmaligem Ticket, anschließend
derselbe unveränderte Aufruf mit `confirm=true` nach ausdrücklicher Zustimmung.
Vor dem atomaren Schreiben wird eine lokale Sicherung erzeugt. Danach läuft die
vollständige Home-Assistant-Konfigurationsprüfung; bei Fehlern oder gescheitertem
Reload wird automatisch zurückgerollt. `configuration.yaml` wird nicht live neu
geladen und meldet deshalb nach einer erfolgreichen Änderung
`restart_required=true`.
Installation auf dem Host, der das Home-Assistant-Konfigurationsverzeichnis
besitzt:
```bash
sudo platform/mcp/install-hass-mcp-yaml-guard.sh \
/mnt/user/appdata/HomeAssistant/config
```
Danach Home Assistant kontrolliert neu starten und den Werkzeugkatalog prüfen.
Der Installer aktiviert **nicht** pauschal Schreibrechte: In Home Assistant unter
**Einstellungen → Geräte & Dienste → Native MCP for Home Assistant → Konfigurieren**
muss `Allow write tools` bewusst eingeschaltet werden. Das gibt auch anderen
nicht-destruktiven Schreibwerkzeugen dieser Integration Zugriff und sollte daher
nur zusammen mit sichtbarer Tool-Freigabe im Client aktiviert werden.
## Sonarr: sichere Episodensuche
Der lokale Sonarr-Patch stellt bewusst keine freie Sonarr-Command-API bereit.
Der erlaubte Schreibablauf ist eng auf fehlende Episoden begrenzt:
1. `preview_episode_search` bekommt Serien-ID, Staffel und die exakten
Episodennummern. Es liest Sonarr-Metadaten, entfernt bereits vorhandene
Episoden und erzeugt eine konkrete Vorschau samt kurzlebigem Ticket.
2. Der Client zeigt diese Vorschau unverändert an. Ohne ausdrückliche
Benutzerfreigabe endet der Ablauf hier.
3. `start_episode_search` akzeptiert nur denselben Umfang, `confirm=true` und
das passende Ticket. Erst dann startet Sonarr eine `EpisodeSearch` über die
dort konfigurierten Indexer.
`EpisodeSearch` ist keine bloße Ergebnisvorschau: Sonarr kann dabei sofort das
beste akzeptierte Release pro Episode an den Download-Client übergeben. Das
gilt auch für explizit ausgewählte, momentan nicht überwachte Episoden;
Monitoring steuert vor allem die spätere automatische/RSS-Verarbeitung.
Der Ablauf ändert weder Serien- noch Staffel-Monitoring und erlaubt weder
beliebige Commands noch direkte URL-Downloads. Tickets gelten zehn Minuten,
sind einmalig und an genau die angezeigte Auswahl gebunden.
### Sonarr: ein konkretes Release oder Staffelpaket laden
Eine automatische Episodensuche ist **kein Ersatz** für die Auswahl eines
bestimmten Releases. Wenn ein Benutzer etwa ausdrücklich ein FuN-Staffelpaket
verlangt, gilt stattdessen dieser Ablauf:
1. `search_releases` sucht ausschließlich über Sonarrs konfigurierte Indexer.
Für Gruppen- oder Staffelpaketfragen werden `release_group` und
`season_pack_only=true` direkt gesetzt; vorhandene Bibliotheksdateien sind
kein Beleg dafür, was aktuell auf den Indexern verfügbar ist.
2. `preview_release_grab` bekommt Serien-ID, Staffel und die **exakte GUID** des
ausgewählten Suchergebnisses. Sonarr wird erneut abgefragt; Titel, Größe,
Indexer, Ablehnungsgründe und vorhandene Episodendateien werden angezeigt.
3. Erst nach ausdrücklicher Freigabe darf `grab_release` mit demselben Umfang,
`confirm=true` und dem kurzlebigen Ticket aufgerufen werden. Es übergibt
exakt dieses Release an Sonarrs konfigurierten Download-Client.
Hat Sonarr das Release abgelehnt oder `downloadAllowed=false` gemeldet, muss
bereits die Vorschau nach gesonderter Zustimmung `force=true` enthalten. Das
Ticket ist auch daran gebunden. Der MCP löscht keine vorhandenen Dateien und
verspricht keine Überschreibung: Ob eine vorhandene Episode nach dem Download
ersetzt wird, entscheiden Sonarrs Qualitätsprofil-, Upgrade- und Importregeln.
## Deemix MCP
`mcp-deemix` steuert ausschließlich die bereits vorhandene Deemix-Instanz auf
Unraid unter `192.168.1.2:6595`. Es installiert kein zweites Deemix. Die
root-only Datei `/etc/mike-ai/deemix-mcp.env` aktiviert das Compose-Profil.
Hermes erreicht den Dienst intern als `http://mcp-deemix:8000/mcp`, OpenWebUI
als `http://mike-ai-mcp-deemix:8000/mcp`. Im Single-User-Modus übernimmt der
MCP die in Deemix gespeicherte Anmeldung nur kurzzeitig im Arbeitsspeicher;
Secrets werden nicht in Tool-Ausgaben zurückgegeben. Nach Änderungen immer
Handshake, `deemix_status`, eine begrenzte Suche und eine unveränderte Queue
prüfen. Reinstall: Env-Beispiel nach `/etc/mike-ai/deemix-mcp.env` kopieren,
Modus `0600` setzen und `platform/mcp/install-tools.sh` ausführen.
+5 -5
View File
@@ -10,7 +10,7 @@ import sys
from typing import Any
VERSION = "3.0.0"
VERSION = "3.1.0"
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
if hasattr(sys.stdin, "reconfigure"):
@@ -22,11 +22,11 @@ if hasattr(sys.stdout, "reconfigure"):
TOOLS = [
{
"name": "athena_operator_inspect",
"description": "START HERE once for Athena work. Inspect the requested live area without changing it.",
"description": "START HERE with subject=guide. Returns ATHENA.md or one compact live area without changing it.",
"inputSchema": {
"type": "object",
"properties": {
"subject": {"type": "string", "enum": ["overview", "git_status", "containers", "models", "jobs"], "default": "overview"},
"subject": {"type": "string", "enum": ["guide", "overview", "git_status", "containers", "models", "jobs"], "default": "guide"},
},
"additionalProperties": False,
},
@@ -82,7 +82,7 @@ TOOLS = [
"description": (
"Apply one durable change that the user has requested. Operations: patch_update, "
"file_update, mcp_release, run_checks, compose_deploy, container_action, "
"openwebui_sync, git_publish, model_download, benchmark, recovery. Use a small "
"openwebui_sync, git_publish, model_download, benchmark, backup. Use a small "
"patch for edits and file_update only for a new or intentionally replaced file. "
"mcp_release can perform the complete MCP build/test/deploy/publish workflow."
),
@@ -91,7 +91,7 @@ TOOLS = [
"properties": {
"operation": {
"type": "string",
"enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"],
"enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "backup"],
},
"payload": {"type": "object"},
},
+10 -46
View File
@@ -1,5 +1,3 @@
name: mike-ai-tools
x-tool-common: &tool-common
restart: unless-stopped
read_only: true
@@ -28,7 +26,7 @@ services:
# needs both the private tool network and the explicitly separated egress
# network; keeping it on `tools` only makes search discovery work while
# every page fetch fails.
networks: [tools, egress]
networks: [tools, tools-egress]
dns: ["${AI_DNS:-1.1.1.1}"]
environment:
TINYSEARCH_MCP_URL: http://tinysearch:8000/mcp
@@ -52,7 +50,7 @@ services:
dns: ["${AI_DNS:-1.1.1.1}"]
volumes:
- ${SEARXNG_SETTINGS_FILE:-../web-search/searxng-settings.example.yml}:/etc/searxng/settings.yml:ro
networks: [tools, egress]
networks: [tools, tools-egress]
tinysearch:
<<: *tool-common
@@ -80,7 +78,7 @@ services:
SEARXNG_URL: http://searxng:8080/search
depends_on: [searxng]
cap_add: [SETUID, SETGID, CHOWN]
networks: [tools, egress]
networks: [tools, tools-egress]
# The image's built-in `tinysearch doctor` also requires a writable
# configuration directory, although normal server operation does not.
# Check the service socket instead so read-only hardening remains intact.
@@ -108,7 +106,7 @@ services:
volumes:
- ${HA_ENV_FILE:-/etc/mike-ai/homeassistant-admin-mcp.env}:/run/secrets/homeassistant.env:ro
cap_add: [CHOWN, SETUID, SETGID]
networks: [tools, egress]
networks: [tools, tools-egress]
mcp-arr:
<<: *tool-common
@@ -127,7 +125,7 @@ services:
# Upstream's generic "Execute any Radarr API action" text gives small
# models no routing boundary. This overlay changes guidance only.
- ${ARR_RADARR_PATCH:-./patches/mcp_radarr.py}:/usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py:ro
networks: [tools, egress]
networks: [tools, tools-egress]
mcp-navidrome:
<<: *tool-common
@@ -153,7 +151,7 @@ services:
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
networks: [tools, egress]
networks: [tools, tools-egress]
mcp-deemix:
<<: *tool-common
@@ -167,30 +165,7 @@ services:
- ${DEEMIX_MCP_ENV_FILE:-/etc/mike-ai/deemix-mcp.env}
environment:
PORT: "8000"
networks: [tools, egress]
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
mcp-platform-context:
<<: *tool-common
build:
context: .
dockerfile: Dockerfile.platform-context
image: mike-ai/mcp-platform-context:2.0.0
container_name: mike-ai-mcp-platform-context
environment:
ATHENA_REPO_ROOT: /knowledge/repo
ATHENA_RUNTIME_FILE: /runtime/runtime.json
volumes:
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/knowledge/repo:ro
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
# Documentation is strictly read-only. Runtime inspection and all changes
# belong to the Athena Operator instead of a second maintenance workflow.
networks: [tools]
networks: [tools, tools-egress]
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
interval: 30s
@@ -203,7 +178,7 @@ services:
build:
context: .
dockerfile: Dockerfile.athena-operator
image: mike-ai/mcp-athena-operator:3.0.0
image: mike-ai/mcp-athena-operator:3.1.0
container_name: mike-ai-mcp-athena-operator
environment:
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
@@ -235,7 +210,7 @@ services:
# for broader toolsets. The token itself must also remain read-only.
GITHUB_TOOLS: search_repositories,get_file_contents,search_code
GITHUB_READ_ONLY: "1"
networks: [tools, egress]
networks: [tools, tools-egress]
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
interval: 30s
@@ -259,18 +234,7 @@ services:
- ${UNRAID_SSH_KEY:-/etc/mike-ai/keys/unraid_root}:/etc/mike-ai/keys/unraid_root:ro
- ${UNRAID_KNOWN_HOSTS:-/etc/mike-ai/ssh/known_hosts_unraid_ai}:/etc/mike-ai/ssh/known_hosts_unraid_ai:ro
- unraid-audit:/var/log/mike-ai
networks: [tools, egress]
networks:
tools:
name: mike-ai-tools
internal: true
ipam:
config: [{subnet: 172.30.40.0/24}]
egress:
name: mike-ai-tools-egress
ipam:
config: [{subnet: 172.30.50.0/24}]
networks: [tools, tools-egress]
volumes:
tinysearch-models:
+14 -17
View File
@@ -4,12 +4,13 @@ set -Eeuo pipefail
[[ $EUID -eq 0 ]] || { echo "Bitte als root ausführen." >&2; exit 1; }
MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "$MCP_DIR/../.." && pwd)"
STACK_ENV=${STACK_ENV:-/etc/mike-ai/stack.env}
COMPOSE=(docker compose)
if [[ -s $STACK_ENV ]]; then
COMPOSE+=(--env-file "$STACK_ENV")
fi
COMPOSE+=(-f "$MCP_DIR/compose.yaml")
COMPOSE+=(-f "$STACK_DIR/compose.yaml")
export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/searxng-settings.yml}"
[[ -s $SEARXNG_SETTINGS_FILE ]] || {
@@ -17,48 +18,44 @@ export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/se
exit 1
}
# The read-only platform context MCP never receives the Docker socket. A
# root-owned timer writes a bounded metadata snapshot instead.
install -d -m 0755 /usr/local/libexec /var/lib/mike-ai-platform-context
install -m 0755 "$MCP_DIR/platform-context-snapshot.py" \
/usr/local/libexec/mike-ai-platform-context-snapshot
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.service" \
/etc/systemd/system/mike-ai-platform-context-snapshot.service
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.timer" \
/etc/systemd/system/mike-ai-platform-context-snapshot.timer
systemctl daemon-reload
systemctl enable --now mike-ai-platform-context-snapshot.timer
systemctl start mike-ai-platform-context-snapshot.service
docker network inspect mike-ai-tools >/dev/null 2>&1 || \
docker network create --internal --subnet 172.30.40.0/24 mike-ai-tools >/dev/null
docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
docker network create --subnet 172.30.50.0/24 mike-ai-tools-egress >/dev/null
# One user-facing Athena Operator MCP controls the local AI platform through a
# root-side executor. The facade exposes six bounded tools and no Docker socket
# or host paths to its unprivileged container.
# One administrative MCP exposes ATHENA.md plus the bounded host operator.
"$MCP_DIR/../operator/install-operator.sh"
profiles=()
services=(searxng tinysearch mcp-athena-operator)
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
profiles+=(--profile homeassistant)
services+=(mcp-homeassistant)
else
echo "Home Assistant bleibt aus: Secret-Datei fehlt."
fi
if [[ -s /etc/mike-ai/arr-mcp.env ]]; then
profiles+=(--profile arr)
services+=(mcp-arr)
else
echo "ARR bleibt aus: Secret-Datei fehlt."
fi
if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then
profiles+=(--profile navidrome)
services+=(mcp-navidrome)
else
echo "Navidrome bleibt aus: Secret-Datei fehlt."
fi
if [[ -s /etc/mike-ai/deemix-mcp.env ]]; then
profiles+=(--profile deemix)
services+=(mcp-deemix)
else
echo "Deemix MCP bleibt aus: Konfigurationsdatei fehlt."
fi
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
profiles+=(--profile github)
services+=(mcp-github)
else
echo "GitHub bleibt aus: dedizierter Read-only-Token fehlt."
fi
@@ -81,7 +78,7 @@ if ! docker run --rm --entrypoint test \
-c 'from tinysearch.services.onnx_bundle_service import ensure_onnx_bundle_sync; ensure_onnx_bundle_sync("fast")'
fi
"${COMPOSE[@]}" "${profiles[@]}" up -d --build
"${COMPOSE[@]}" "${profiles[@]}" up -d --build "${services[@]}"
for webui in mike-ai-open-webui Open-WebUI; do
if docker container inspect "$webui" >/dev/null 2>&1; then
+125 -13
View File
@@ -1,38 +1,150 @@
"""Radarr action-routed MCP tool with model-oriented routing guidance."""
"""Small, model-oriented Radarr tools built on the upstream API client."""
import json
from typing import Any
from agent_utilities.mcp_utilities import dispatch, run_blocking
from agent_utilities.mcp_utilities import dispatch, public_actions, run_blocking
from fastmcp import FastMCP
from pydantic import Field
from arr_mcp.auth import get_radarr_client
BLOCKED_ACTIONS = {
"request", "get_", "get_api", "get_content_path", "get_path",
"get_login", "get_logout", "post_login", "post_system_restart",
"post_system_shutdown",
}
def _safe_actions(client: Any) -> list[str]:
"""Hide transport, authentication and service-power implementation methods."""
return [name for name in public_actions(client) if name not in BLOCKED_ACTIONS]
def _codec_aliases(value: str) -> set[str]:
aliases = {
"h264": {"h264", "x264", "avc"},
"x264": {"h264", "x264", "avc"},
"avc": {"h264", "x264", "avc"},
"h265": {"h265", "x265", "hevc"},
"x265": {"h265", "x265", "hevc"},
"hevc": {"h265", "x265", "hevc"},
}
requested: set[str] = set()
for item in value.split(","):
key = item.strip().casefold()
if key:
requested.update(aliases.get(key, {key}))
return requested
def _compact_inventory(
movies: list[dict[str, Any]], *, codecs: str = "", query: str = "",
offset: int = 0, limit: int = 200,
) -> dict[str, Any]:
wanted = _codec_aliases(codecs)
needle = query.strip().casefold()
rows: list[dict[str, Any]] = []
for movie in movies:
movie_file = movie.get("movieFile") or {}
if not movie.get("hasFile") or not movie_file:
continue
media = movie_file.get("mediaInfo") or {}
codec = str(media.get("videoCodec") or "unknown")
if wanted and codec.casefold() not in wanted:
continue
title = str(movie.get("title") or "")
if needle and needle not in title.casefold():
continue
quality = movie_file.get("quality") or {}
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
size = int(movie_file.get("size") or 0)
rows.append({
"radarrId": movie.get("id"),
"title": title,
"year": movie.get("year"),
"movieFileId": movie_file.get("id"),
"relativePath": movie_file.get("relativePath"),
"sizeBytes": size,
"sizeGiB": round(size / 1073741824, 2),
"quality": quality_name,
"resolution": media.get("resolution"),
"videoCodec": codec,
"videoBitDepth": media.get("videoBitDepth"),
"audioCodec": media.get("audioCodec"),
"audioLanguages": media.get("audioLanguages"),
"subtitles": media.get("subtitles"),
})
rows.sort(key=lambda row: (str(row["title"]).casefold(), row.get("year") or 0))
total = len(rows)
start = max(0, int(offset))
count = min(500, max(1, int(limit)))
selected = rows[start:start + count]
return {
"totalMatched": total,
"offset": start,
"returned": len(selected),
"hasMore": start + len(selected) < total,
"movies": selected,
}
def register_radarr_tools(mcp: FastMCP) -> None:
@mcp.tool(tags={"radarr"})
async def radarr_movie_codec_inventory(
video_codecs: str = Field(
default="",
description="Optional comma-separated filter, e.g. h264, x264, h265, x265 or hevc.",
),
query: str = Field(default="", description="Optional case-insensitive title fragment."),
offset: int = Field(default=0, ge=0),
limit: int = Field(default=200, ge=1, le=500),
) -> Any:
"""Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings."""
client = get_radarr_client()
response = await run_blocking(client.get_movie)
movies = response.get("result", response) if isinstance(response, dict) else response
if not isinstance(movies, list):
raise RuntimeError("Radarr get_movie returned an unexpected response")
return _compact_inventory(
movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
)
@mcp.tool(tags={"radarr"})
async def radarr_action(
action: str = Field(
description=(
"Choose one Radarr operation. Common read choices include get_movie for the "
"movie library and get_system_status/get_health for Radarr diagnostics. Use "
"list_actions only when an unusual Radarr operation is genuinely required. "
"Never guess a modifying action and never change Radarr without explicit user approval."
"A named Radarr API operation. Prefer radarr_movie_codec_inventory for "
"library/file/codec questions. Use list_actions once for unusual operations. "
"Raw request, authentication and Radarr restart/shutdown methods are unavailable."
)
),
params_json: str = Field(
default="{}",
description=(
"JSON object encoded as a string containing only parameters required by the "
"selected Radarr action. Use \"{}\" for actions without parameters; never "
"invent movie IDs, paths, profile IDs or monitoring settings."
),
description="JSON object string with only the selected action's required parameters.",
),
) -> Any:
"""USE ONLY for movies managed by Radarr: inspect the movie library, wanted/queue/history state, releases, profiles, or Radarr health. DO NOT use for TV episodes (use Sonarr), public-web research, media playback, filesystem copying, or direct downloads. Prefer read actions; any mutation requires explicit user approval."""
"""Other Radarr operations for movies, queue, history, releases, profiles and health. TV episodes belong to Sonarr. Mutations require an explicit user request."""
client = get_radarr_client()
kwargs = {k: v for k, v in json.loads(params_json).items() if v is not None}
actions = _safe_actions(client)
if action in {"list_actions", "actions", "help", "capabilities"}:
return {"service": "arr-radarr", "actions": actions}
if action not in actions:
return {
"ok": False,
"error": "unknown or unavailable Radarr action",
"action": action,
"retry": False,
"hint": "Use list_actions once or radarr_movie_codec_inventory for file/codec questions.",
}
try:
parsed = json.loads(params_json)
except json.JSONDecodeError as exc:
raise ValueError(f"params_json is not valid JSON: {exc.msg}") from exc
if not isinstance(parsed, dict):
raise ValueError("params_json must encode a JSON object")
kwargs = {key: value for key, value in parsed.items() if value is not None}
return await run_blocking(
dispatch, client, action, kwargs, service="arr-radarr"
)
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""Create a bounded, payload-free Athena runtime snapshot for the context MCP."""
from __future__ import annotations
import hashlib
import json
import os
import pathlib
import subprocess
import tempfile
import time
OUTPUT = pathlib.Path("/var/lib/mike-ai-platform-context/runtime.json")
STACK = pathlib.Path("/opt/mike-ai/stack")
def command(*args: str) -> str:
try:
return subprocess.run(args, check=True, text=True, capture_output=True, timeout=15).stdout.strip()
except (OSError, subprocess.SubprocessError):
return ""
def docs_hash() -> str | None:
digest = hashlib.sha256()
files = sorted((STACK / "docs").glob("*.md"))
if not files:
return None
for path in files:
digest.update(path.name.encode())
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def containers() -> list[dict[str, str]]:
raw = command("docker", "ps", "--filter", "name=mike-ai-", "--format", "{{.Names}}|{{.Image}}|{{.Status}}")
result = []
for line in raw.splitlines():
fields = line.split("|", 2)
if len(fields) == 3:
result.append({"name": fields[0], "image": fields[1], "status": fields[2]})
return sorted(result, key=lambda item: item["name"])
def gpus() -> list[dict[str, object]]:
raw = command("nvidia-smi", "--query-gpu=uuid,name,memory.total,memory.used,driver_version", "--format=csv,noheader,nounits")
result = []
for line in raw.splitlines():
fields = [field.strip() for field in line.split(",")]
if len(fields) == 5:
result.append({"uuid": fields[0], "name": fields[1], "memory_total_mib": int(fields[2]), "memory_used_mib": int(fields[3]), "driver": fields[4]})
return result
def filesystems() -> list[dict[str, object]]:
raw = command("df", "-B1", "--output=target,fstype,size,used,avail,pcent", "/", "/data")
result = []
for line in raw.splitlines()[1:]:
fields = line.split()
if len(fields) == 6:
result.append({"mount": fields[0], "fstype": fields[1], "size_bytes": int(fields[2]), "used_bytes": int(fields[3]), "available_bytes": int(fields[4]), "used_percent": fields[5]})
return result
def recovery_status() -> dict[str, object]:
link = pathlib.Path("/data/mike-ai-recovery-kit")
if not link.exists():
return {"present": False}
target = link.resolve()
checksums = target / "SHA256SUMS"
return {"present": True, "target": str(target), "modified_unix": int(target.stat().st_mtime), "checksums_present": checksums.is_file()}
def main() -> None:
generated = int(time.time())
active = [item["name"].removeprefix("mike-ai-llama-") for item in containers() if item["name"].startswith("mike-ai-llama-")]
git_commit = command("git", "-C", str(STACK), "rev-parse", "HEAD")
commit_file = STACK / ".mike-ai-source-commit"
data = {
"generated_unix": generated,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(generated)),
"hostname": command("hostname"),
"os_release": command("sh", "-c", ". /etc/os-release && printf '%s %s' \"$ID\" \"$VERSION_ID\""),
"kernel": command("uname", "-r"),
"uptime_seconds": float(pathlib.Path("/proc/uptime").read_text().split()[0]),
"memory": {"summary": command("free", "-b", "--si").splitlines()[1] if command("free", "-b", "--si") else ""},
"filesystems": filesystems(),
"gpus": gpus(),
"containers": containers(),
"active_inference_profiles": active,
"source_commit": git_commit or (commit_file.read_text().strip() if commit_file.is_file() else None),
"documentation_tree_sha256": docs_hash(),
"recovery_kit": recovery_status(),
"privacy_scope": "No logs, prompts, chats, container environment values, file contents outside versioned docs, or secrets are collected.",
}
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".runtime.", dir=OUTPUT.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.chmod(temporary, 0o644)
os.replace(temporary, OUTPUT)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
if __name__ == "__main__":
main()
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python3
"""Small read-only Athena knowledge MCP.
The normal entry point is ATHENA.md. Large historical documentation remains
available through bounded search/read tools but is never loaded automatically.
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
VERSION = "2.0.0"
REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo")).resolve()
RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json"))
MAX_OVERVIEW_CHARS = 14_000
MAX_READ_LINES = 160
MAX_SEARCH_RESULTS = 8
ALLOWED_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt"}
BLOCKED_PARTS = {".git", "secrets", "private", "credentials"}
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
TOOLS = [
{
"name": "athena_get_overview",
"description": (
"START HERE for Athena architecture or administration. Returns the compact, "
"authoritative ATHENA.md. Do not read additional platform documents unless a "
"specific unresolved question remains."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_get_current_state",
"description": "Return the compact generated runtime snapshot: active profile, containers, GPUs, source commit and recovery status.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_get_external_services",
"description": "List known services outside Athena so an existing Unraid or home-network backend is reused instead of duplicated.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_search_reference",
"description": (
"Search ATHENA.md and documentation for one concrete term. Returns at most eight "
"short excerpts. Use only when ATHENA.md did not answer the question."
),
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string", "minLength": 2, "maxLength": 120}},
"required": ["query"],
"additionalProperties": False,
},
},
{
"name": "athena_read_reference",
"description": (
"Read a bounded line range from one known documentation file. Missing paths are "
"reported as a normal not-found result and must not be retried by guessing."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "pattern": "^[A-Za-z0-9_.+/-]{1,200}$"},
"start_line": {"type": "integer", "minimum": 1, "maximum": 1000000, "default": 1},
"line_count": {"type": "integer", "minimum": 1, "maximum": MAX_READ_LINES, "default": 80},
},
"required": ["path"],
"additionalProperties": False,
},
},
]
def result_error(message: str, **details: Any) -> dict[str, Any]:
return {"ok": False, "error": message, "retry": False, **details}
def safe_path(relative: str) -> Path | None:
if not relative or relative.startswith("/"):
return None
candidate = Path(relative)
if ".." in candidate.parts or any(part.lower() in BLOCKED_PARTS for part in candidate.parts):
return None
target = (REPO_ROOT / candidate).resolve(strict=False)
try:
target.relative_to(REPO_ROOT)
except ValueError:
return None
if candidate.name != "ATHENA.md" and (not candidate.parts or candidate.parts[0] != "docs"):
return None
if target.suffix.lower() not in ALLOWED_SUFFIXES:
return None
return target
def read_text(path: Path, limit: int | None = None) -> str:
text = path.read_text(encoding="utf-8", errors="replace")
return text if limit is None else text[:limit]
def overview() -> dict[str, Any]:
path = REPO_ROOT / "ATHENA.md"
try:
content = read_text(path, MAX_OVERVIEW_CHARS)
except (OSError, PermissionError) as exc:
return result_error("ATHENA.md is unavailable", path="ATHENA.md", detail=str(exc))
return {"ok": True, "source": "ATHENA.md", "content": content, "truncated": path.stat().st_size > len(content.encode())}
def current_state() -> dict[str, Any]:
try:
value = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return result_error("runtime snapshot is unavailable", detail=str(exc))
containers = value.get("containers") or []
return {
"ok": True,
"generated_at": value.get("generated_at"),
"hostname": value.get("hostname"),
"active_inference_profiles": value.get("active_inference_profiles") or [],
"source_commit": value.get("source_commit"),
"gpus": value.get("gpus") or [],
"containers": containers,
"container_count": len(containers),
"recovery_kit": value.get("recovery_kit") or {"present": False},
}
def external_services() -> dict[str, Any]:
path = REPO_ROOT / "config/service-catalog.json"
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return result_error("service catalog is unavailable", detail=str(exc))
services = []
for item in value.get("services", []):
services.append({key: item.get(key) for key in ("id", "name", "host", "address", "port", "protocol", "purpose") if item.get(key) is not None})
return {"ok": True, "services": services, "count": len(services)}
def reference_files() -> list[Path]:
files = [REPO_ROOT / "ATHENA.md"]
docs = REPO_ROOT / "docs"
try:
files.extend(sorted(path for path in docs.glob("*.md") if path.is_file()))
except OSError:
pass
return files
def search_reference(arguments: dict[str, Any]) -> dict[str, Any]:
query = str(arguments.get("query", "")).strip()
if len(query) < 2:
return result_error("query must contain at least two characters")
pattern = re.compile(re.escape(query), re.IGNORECASE)
matches: list[dict[str, Any]] = []
for path in reference_files():
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for number, line in enumerate(lines, 1):
if pattern.search(line):
matches.append({
"path": str(path.relative_to(REPO_ROOT)),
"line": number,
"excerpt": line.strip()[:280],
})
if len(matches) >= MAX_SEARCH_RESULTS:
return {"ok": True, "query": query, "matches": matches, "truncated": True}
return {"ok": True, "query": query, "matches": matches, "truncated": False}
def read_reference(arguments: dict[str, Any]) -> dict[str, Any]:
relative = str(arguments.get("path", ""))
path = safe_path(relative)
if path is None:
return result_error("path is not an allowed documentation path", path=relative)
if not path.is_file():
return result_error("documentation file not found", path=relative)
start = max(1, int(arguments.get("start_line", 1)))
count = min(MAX_READ_LINES, max(1, int(arguments.get("line_count", 80))))
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as exc:
return result_error("documentation file is unreadable", path=relative, detail=str(exc))
selected = lines[start - 1:start - 1 + count]
return {
"ok": True,
"path": relative,
"start_line": start,
"end_line": start + len(selected) - 1 if selected else start - 1,
"total_lines": len(lines),
"content": "\n".join(selected),
"truncated": start - 1 + len(selected) < len(lines),
}
def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
if name == "athena_get_overview":
return overview()
if name == "athena_get_current_state":
return current_state()
if name == "athena_get_external_services":
return external_services()
if name == "athena_search_reference":
return search_reference(arguments)
if name == "athena_read_reference":
return read_reference(arguments)
return result_error("unknown tool", tool=name)
def emit(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
message = {"jsonrpc": "2.0", "id": request_id}
message["error" if error else "result"] = error or result
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def handle(message: dict[str, Any]) -> None:
method, request_id = message.get("method"), message.get("id")
if method == "initialize":
emit(request_id, {
"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"),
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "mike-ai-platform-context", "version": VERSION},
})
elif method == "tools/list":
emit(request_id, {"tools": TOOLS})
elif method == "tools/call":
params = message.get("params") or {}
value = call_tool(str(params.get("name", "")), params.get("arguments") or {})
emit(request_id, {
"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, separators=(",", ":"))}],
"structuredContent": value,
"isError": False,
})
elif request_id is not None:
emit(request_id, error={"code": -32601, "message": "method not found"})
def main() -> None:
for line in sys.stdin:
try:
if line.strip():
handle(json.loads(line))
except Exception as exc:
sys.stderr.write(f"MCP input error: {exc}\n")
sys.stderr.flush()
if __name__ == "__main__":
main()
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Generate Hermes and OpenWebUI MCP registrations from one JSON registry."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sqlite3
import time
BEGIN = "# BEGIN MANAGED MCP SERVERS"
END = "# END MANAGED MCP SERVERS"
def env_file(path: str) -> dict[str, str]:
values: dict[str, str] = {}
source = pathlib.Path(path)
if not source.is_file():
return values
for raw in source.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def enabled(item: dict) -> bool:
required = item.get("required_file")
if required and not pathlib.Path(required).is_file():
return False
source = item.get("env_file")
if source:
values = env_file(source)
return bool(values.get(item.get("url_env", ""))) and bool(values.get(item.get("key_env", "")))
return True
def resolved(item: dict) -> tuple[str, str]:
if item.get("env_file"):
values = env_file(item["env_file"])
return values[item["url_env"]], values[item["key_env"]]
return item["url"], ""
def active(registry: pathlib.Path, client: str) -> list[dict]:
document = json.loads(registry.read_text(encoding="utf-8"))
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
raise SystemExit("Unsupported MCP registry schema")
return [item for item in document["servers"] if client in item.get("clients", []) and enabled(item)]
def yaml_quote(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def hermes_block(items: list[dict]) -> str:
lines = [BEGIN, "mcp_servers:"]
for item in items:
url, key = resolved(item)
lines.extend([
f" {item.get('hermes_id', item['id'])}:",
f" url: {yaml_quote(url)}",
])
if key:
lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"])
lines.extend([
f" timeout: {int(item.get('timeout', 300))}",
" connect_timeout: 30",
" supports_parallel_tool_calls: false",
])
lines.append(END)
return "\n".join(lines) + "\n"
def update_hermes(path: pathlib.Path, block: str) -> None:
if not path.is_file():
return
text = path.read_text(encoding="utf-8")
if BEGIN in text and END in text:
prefix, rest = text.split(BEGIN, 1)
_, suffix = rest.split(END, 1)
text = prefix.rstrip() + "\n\n" + block + suffix.lstrip("\n")
else:
marker = "\nmcp_servers:"
if marker in text:
text = text.split(marker, 1)[0].rstrip() + "\n\n" + block
else:
text = text.rstrip() + "\n\n" + block
path.write_text(text, encoding="utf-8")
def openwebui_connection(item: dict) -> dict:
url, key = resolved(item)
config = {"enable": True, "access_grants": []}
if item.get("functions"):
config["function_name_filter_list"] = item["functions"]
return {
"url": url, "path": "", "type": "mcp",
"auth_type": item.get("auth_type", "none"), "headers": None,
"key": key, "config": config,
"info": {"id": item["id"], "name": item["name"], "description": item["description"]},
}
def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
con = sqlite3.connect(db)
now = int(time.time())
row = con.execute("select value from config where key=?", ("tool_server.connections",)).fetchone()
old = json.loads(row[0]) if row else []
if not isinstance(old, list):
raise SystemExit("Unexpected OpenWebUI tool_server.connections format")
managed_ids = {
"athena-platform", "athena-operator-local", "web-general-local", "github-local",
"homeassistant-local", "arr-local", "navidrome-local", "deemix-local", "mua",
"mua-readonly-local", "athena-terminal-local", "unraid-readonly-local", "web-local",
}
keep = [entry for entry in old if str((entry.get("info") or {}).get("id", "")) not in managed_ids]
keep.extend(openwebui_connection(item) for item in items)
with con:
con.execute(
"""insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
("tool_server.connections", json.dumps(keep, ensure_ascii=False), now),
)
con.close()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--registry", type=pathlib.Path, required=True)
parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[])
parser.add_argument("--openwebui-db", type=pathlib.Path)
args = parser.parse_args()
if args.hermes:
block = hermes_block(active(args.registry, "hermes"))
for path in args.hermes:
update_hermes(path, block)
if args.openwebui_db:
update_openwebui(args.openwebui_db, active(args.registry, "openwebui"))
print("MCP_CLIENT_SYNC_OK")
if __name__ == "__main__":
main()
@@ -29,7 +29,7 @@ class Filter:
"unraid": "server:mcp:mua-readonly-local",
"unraid_admin": "server:mcp:mua",
"navidrome": "server:mcp:navidrome-local",
"platform": "server:mcp:athena-platform",
"platform": "server:mcp:athena-operator-local",
"operator": "server:mcp:athena-operator-local",
"web": "server:mcp:web-general-local",
}
@@ -300,8 +300,7 @@ class Filter:
(
r"\bathena\b", r"\bmikeai\b", r"\bki[- ]host\b",
r"\bai[- ]profile[- ]router\b", r"\bprofil[- ]router\b",
r"\brecovery[- ](?:koffer|bundle|skript)\b",
r"\b(?:disaster|bare metal)[- ]recovery\b",
r"\b(?:backup|restore|wiederherstellung|neuinstallation)\b",
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
r"\bplattform(?:wissen|dokumentation)?\b",
),
+35 -478
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env bash
# Synchronise OpenWebUI functions and MCPs from versioned sources.
set -Eeuo pipefail
umask 077
CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui}
VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
FILTER_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/filters" && pwd)
ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/actions" && pwd)
MUA_MCP_ENV_FILE=${MUA_MCP_ENV_FILE:-/etc/mike-ai/mua-mcp.env}
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
FILTER_DIR="$ROOT/platform/openwebui/filters"
ACTION_DIR="$ROOT/platform/openwebui/actions"
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
for file in reasoning_default_off.py thinking.py auto_tool_selector.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do
[[ -s $FILTER_DIR/$file ]] || die "Filterdatei fehlt: $file"
done
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt: quick_actions.py"
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt."
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$VOLUME")
db=$volume_path/webui.db
@@ -24,136 +25,67 @@ if [[ $(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)
was_running=true
docker stop "$CONTAINER" >/dev/null
fi
restart_on_exit() {
if [[ $was_running == true ]]; then
docker start "$CONTAINER" >/dev/null 2>&1 || true
fi
}
restart_on_exit() { [[ $was_running == false ]] || docker start "$CONTAINER" >/dev/null 2>&1 || true; }
trap restart_on_exit EXIT
stamp=$(date +%Y%m%d-%H%M%S)
backup=$volume_path/webui.db.before-filter-install-$stamp
backup=$volume_path/webui.db.before-managed-sync-$stamp
cp -a "$db" "$backup"
rm -f "$volume_path/webui.db-wal" "$volume_path/webui.db-shm"
navidrome_enabled=false
[[ -s /etc/mike-ai/navidrome-mcp.env ]] && navidrome_enabled=true
deemix_enabled=false
[[ -s /etc/mike-ai/deemix-mcp.env ]] && deemix_enabled=true
github_enabled=false
if [[ -s /etc/mike-ai/github-mcp.env ]] && \
grep -Eq '^GITHUB_PERSONAL_ACCESS_TOKEN=.+$' /etc/mike-ai/github-mcp.env; then
github_enabled=true
fi
python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" "$navidrome_enabled" "$github_enabled" "$deemix_enabled" "$MUA_MCP_ENV_FILE" <<'PY'
python3 - "$db" "$FILTER_DIR" "$ACTION_DIR" "${OPENWEBUI_FILTER_OWNER_ID:-}" <<'PY'
import json
import copy
import pathlib
import sqlite3
import sys
import time
(
db, filter_dir, action_dir, requested_owner, navidrome_enabled_raw,
github_enabled_raw, deemix_enabled_raw, mua_mcp_env_file,
) = sys.argv[1:]
navidrome_enabled = navidrome_enabled_raw.lower() == "true"
github_enabled = github_enabled_raw.lower() == "true"
deemix_enabled = deemix_enabled_raw.lower() == "true"
db, filter_dir, action_dir, owner = sys.argv[1:]
con = sqlite3.connect(db)
columns = {row[1] for row in con.execute("pragma table_info(function)")}
required = {
"id", "user_id", "name", "type", "content", "meta", "valves",
"is_active", "is_global", "updated_at", "created_at",
}
required = {"id", "user_id", "name", "type", "content", "meta", "valves", "is_active", "is_global", "updated_at", "created_at"}
if not required <= columns:
raise SystemExit("Unbekanntes OpenWebUI-Function-Schema; keine Änderung vorgenommen.")
config_columns = {row[1] for row in con.execute("pragma table_info(config)")}
if not {"key", "value", "updated_at"} <= config_columns:
raise SystemExit("Unbekanntes OpenWebUI-Config-Schema; keine Änderung vorgenommen.")
owner = requested_owner
raise SystemExit("Unknown OpenWebUI function schema")
if not owner:
existing = con.execute(
"select user_id from function where id in (?, ?, ?, ?, ?, ?, ?, ?) order by id limit 1",
(
"reasoning_default_off", "thinking", "auto_tool_selector", "stability_guard",
"secret_redaction", "spoken_tool_status", "local_performance_metrics",
"quick_actions",
),
).fetchone()
existing = con.execute("select user_id from function where id='reasoning_default_off'").fetchone()
if existing:
owner = existing[0]
if not owner:
admins = con.execute("select id from user where role='admin'").fetchall()
if len(admins) != 1:
raise SystemExit(
"Filter-Eigentümer ist nicht eindeutig; OPENWEBUI_FILTER_OWNER_ID setzen."
)
raise SystemExit("OPENWEBUI_FILTER_OWNER_ID is not unambiguous")
owner = admins[0][0]
now = int(time.time())
functions = [
("reasoning_default_off", "Reasoning Default Off", "filter", 10, filter_dir, ""),
("thinking", "Thinking", "filter", 20, filter_dir, ""),
(
"auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir,
"Hält die allgemeine Websuche verfügbar und ergänzt automatisch alle fachlich passenden MCP-Domänen. "
"Die Auswahl ist Komfort und keine Schranke oder Freigabe für schreibende Aktionen.",
),
("auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir, "Keeps general web search available and selects relevant MCP domains."),
("stability_guard", "MikeAI Stability Guard", "filter", 30, filter_dir, ""),
("secret_redaction", "MikeAI Secret Redaction", "filter", 40, filter_dir, ""),
(
"spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir,
"Spielt bei aktiver automatischer Sprachausgabe einmalig eine kurze lokale Ansage, "
"sobald ein echtes Werkzeug gestartet wird.",
),
("spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir, "Plays one short local status phrase when a real tool starts."),
("local_performance_metrics", "MikeAI Local Performance Metrics", "filter", 90, filter_dir, ""),
(
"quick_actions", "MikeAI Quick Actions", "action", 100, action_dir,
"Lokale, geprüfte Aktionen für Zusammenfassung, Diagnose, Quellen und Markdown.",
),
("quick_actions", "MikeAI Quick Actions", "action", 100, action_dir, "Local actions for summaries, diagnosis, sources and Markdown."),
]
with con:
for function_id, name, function_type, priority, source_dir, description in functions:
content = pathlib.Path(source_dir, f"{function_id}.py").read_text()
for function_id, name, kind, priority, source_dir, description in functions:
content = pathlib.Path(source_dir, function_id + ".py").read_text()
con.execute(
"""
insert into function
(id,user_id,name,type,content,meta,valves,is_active,is_global,updated_at,created_at)
values (?,?,?,?,?,?,?,?,?,?,?)
on conflict(id) do update set
name=excluded.name,
type=excluded.type,
content=excluded.content,
meta=excluded.meta,
valves=excluded.valves,
is_active=excluded.is_active,
is_global=excluded.is_global,
updated_at=excluded.updated_at
""",
(
function_id, owner, name, function_type, content,
json.dumps({"description": description}),
json.dumps({"priority": priority}), True, True, now, now,
),
"""insert into function
(id,user_id,name,type,content,meta,valves,is_active,is_global,updated_at,created_at)
values (?,?,?,?,?,?,?,?,?,?,?)
on conflict(id) do update set name=excluded.name,type=excluded.type,
content=excluded.content,meta=excluded.meta,valves=excluded.valves,
is_active=excluded.is_active,is_global=excluded.is_global,updated_at=excluded.updated_at""",
(function_id, owner, name, kind, content, json.dumps({"description": description}),
json.dumps({"priority": priority}), True, True, now, now),
)
con.execute(
"""
insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set
value=excluded.value,
updated_at=excluded.updated_at
""",
("task.follow_up.enable", "false", now),
)
for key, value in (
("task.follow_up.enable", False),
("audio.tts.engine", "openai"),
("audio.tts.model", "piper"),
("audio.tts.voice", "alloy"),
("audio.tts.openai.api_base_url", "http://router:8081/v1"),
# Reproduce the working keyless native web search after a fresh install
# or database restore. No query or result content is stored here.
("web.search.enable", True),
("web.search.engine", "duckduckgo"),
("web.search.ddgs_backend", "duckduckgo"),
@@ -162,392 +94,17 @@ with con:
("web.search.confirmation.enable", False),
):
con.execute(
"""
insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set
value=excluded.value,
updated_at=excluded.updated_at
""",
"""insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
(key, json.dumps(value), now),
)
# Improve model-side tool selection without touching URLs, credentials,
# access grants or enable flags from a restored Open WebUI database.
row = con.execute(
"select value from config where key=?",
("tool_server.connections",),
).fetchone()
if row:
connections = json.loads(row[0])
if not isinstance(connections, list):
raise SystemExit("Unbekanntes Format in tool_server.connections.")
before_count = len(connections)
connections = [
connection for connection in connections
if not (
isinstance(connection, dict)
and (
str((connection.get("info") or {}).get("id", "")).lower()
in {"athena-terminal-local", "unraid-readonly-local", "web-local"}
or "mike-ai-mcp-athena-terminal" in str(connection.get("url", "")).lower()
or "mike-ai-mcp-unraid-official" in str(connection.get("url", "")).lower()
or "mike-ai-mcp-web" in str(connection.get("url", "")).lower()
)
)
]
descriptions = {
"web-general-local": (
"Allgemeines Web (TinySearch)",
"Breite, portable Websuche und Seitenabruf für beliebige öffentliche Websites. "
"In OpenWebUI ist native search_web/fetch_url standardmäßig verfügbar; dieser "
"Upstream-MCP ist die portable Alternative für Hermes, Pi und manuelle Nutzung. "
"Kurze, gezielte Resultate anfordern und niemals private Dateiinhalte senden.",
),
"homeassistant-local": (
"Home Assistant (lokal)",
"Für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards, "
"HA-Diagnose und freigegebene YAML-Dateien. YAML-Lesen ist begrenzt; Änderungen "
"benötigen serverseitige Vorschau, explizite Freigabe, Sicherung und Validierung. "
"Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche.",
),
"arr-local": (
"Sonarr und Radarr (lokal)",
"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über "
"konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen "
"Vorschau und Freigabe.",
),
"mua-readonly-local": (
"MUA · Unraid-Diagnose (read-only)",
"Automatisch verwendbarer, serverseitig in Open WebUI auf reine Lese- und "
"Diagnosewerkzeuge begrenzter MUA-Zugang. Für Containerbestand, Logs, System, "
"Storage, Shares, kompakte Datei-/Medieninventare und Netzwerkstatus. "
"Für Bibliotheksprüfungen unraid_files_inventory statt wiederholter "
"ls/find-Aufrufe verwenden. Keine Start/Stop-, Installations-, "
"Änderungs- oder freie Shell-Funktion. Bei einer ausdrücklich verlangten "
"Änderung stellt die automatische Auswahl zusätzlich MUA-Verwaltung bereit.",
),
"mua": (
"MUA (Unraid-Verwaltung)",
"Nur für vom Benutzer in der aktuellen Nachricht ausdrücklich verlangte "
"Unraid-Verwaltungsaktionen. Gemeinsam mit MUA read-only als geordnete "
"Kette verwenden: Zustand prüfen, engste Änderung ausführen, Ergebnis "
"read-only verifizieren. Für mehrere bestätigte Image-Updates immer den "
"gebündelten, zustandserhaltenden Updateablauf verwenden.",
),
"navidrome-local": (
"Navidrome (Musikbibliothek)",
"Nur für die persönliche Navidrome-Musikbibliothek: Titel, Alben, Künstler, "
"Playlists, Favoriten und Hörverlauf. Nicht für Sonarr/Radarr, allgemeine "
"Websuche oder Audioausgabe auf dem KI-Host. Wegen des großen Werkzeugkatalogs "
"nur bei Musikaufgaben aktivieren.",
),
"deemix-local": (
"Deemix (bestehende Unraid-Instanz)",
"Durchsucht Deezer über die vorhandene Deemix-Instanz auf Unraid und "
"verwaltet deren Download-Queue. Keine zweite Deemix-Instanz. Schreibende "
"Queue-Aktionen nur auf ausdrücklichen Benutzerauftrag; Status und Suche "
"sind read-only.",
),
"github-local": (
"GitHub Repository (offiziell, read-only)",
"Für Repository-Suche, echte Datei-Inhalte und gezielte "
"Code-Suche auf GitHub. Bei Fragen zu Implementierung, README, API-Routen oder "
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
"Pull Requests, Actions, rekursiven Komplettbäume oder Schreibzugriffe. Bei einem "
"konkreten Repository zuerst README beziehungsweise Wurzel einmal lesen, danach "
"höchstens drei gezielte Code-Suchen und nur relevante Trefferdateien öffnen.",
),
"athena-operator-local": (
"Athena Operator",
"Zentrale Bedienebene für Athenas KI-Plattform: MCPs entwickeln und deployen, "
"Docker-Dienste verwalten, Modelle laden und testen, Profile/OpenWebUI ändern, "
"prüfen, dokumentieren, versionieren und Recovery erzeugen. Enthält außerdem ein "
"breites, ausgabebegrenztes Terminal als Ausweg für neue Aufgaben einschließlich "
"Docker, Git, HTTP und SSH zu konfigurierten Zielsystemen. Stromversorgung sowie "
"Athenas SSH, LAN, WireGuard, Firewall, Boot, Kernel, Mounts und Partitionen bleiben "
"blockiert, damit der entfernte Host erreichbar bleibt.",
),
}
changed = len(connections) != before_count
for connection in connections:
if not isinstance(connection, dict):
continue
info = connection.get("info")
if not isinstance(info, dict):
info = {}
connection["info"] = info
identity = str(info.get("id", "")).lower()
url = str(connection.get("url", "")).lower()
if identity in descriptions:
match = identity
elif "192.168.1.2:3002" in url:
match = "mua"
elif "tinysearch:8000" in url:
match = "web-general-local"
elif "mike-ai-mcp-homeassistant" in url:
match = "homeassistant-local"
elif "mike-ai-mcp-arr" in url:
match = "arr-local"
elif "mike-ai-mcp-navidrome" in url:
match = "navidrome-local"
elif "mike-ai-mcp-github" in url:
match = "github-local"
elif "mike-ai-mcp-deemix" in url:
match = "deemix-local"
elif "mike-ai-mcp-athena-operator" in url:
match = "athena-operator-local"
else:
continue
name, description = descriptions[match]
if info.get("name") != name or info.get("description") != description:
info["name"] = name
info["description"] = description
changed = True
if match == "github-local":
bounded_config = dict(connection.get("config") or {})
bounded_config["enable"] = True
bounded_config["function_name_filter_list"] = (
"search_repositories,get_file_contents,search_code"
)
bounded_config.setdefault("access_grants", [])
if connection.get("config") != bounded_config:
connection["config"] = bounded_config
changed = True
# Clone the existing authenticated MUA connection into a second
# OpenWebUI connection whose exposed function list is strictly
# read-only. The bearer value remains in the database and is neither
# printed nor copied into Git. Automatic routing uses only this clone;
# the original MUA connection remains available for deliberate admin.
mua_source = next(
(
connection for connection in connections
if isinstance(connection, dict)
and str((connection.get("info") or {}).get("id", "")).lower() == "mua"
),
None,
)
if mua_source is None and pathlib.Path(mua_mcp_env_file).is_file():
mua_env = {}
for raw_line in pathlib.Path(mua_mcp_env_file).read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
mua_env[key.strip()] = value.strip().strip('"').strip("'")
mua_url = mua_env.get("MUA_MCP_URL", "")
mua_token = mua_env.get("MUA_MCP_BEARER_TOKEN", "")
if mua_url and mua_token:
name, description = descriptions["mua"]
mua_source = {
"url": mua_url,
"path": "",
"type": "mcp",
"auth_type": "bearer",
"headers": None,
"key": mua_token,
"config": {"enable": True, "access_grants": []},
"info": {"id": "mua", "name": name, "description": description},
}
connections.append(mua_source)
changed = True
if mua_source is not None:
readonly_functions = ",".join((
"unraid_docker_list", "unraid_docker_inspect", "unraid_docker_logs",
"unraid_docker_analyze_logs", "unraid_docker_processes",
"unraid_docker_stats", "unraid_docker_info",
"unraid_docker_update_status", "unraid_ca_search",
"unraid_network_inventory", "unraid_network_list",
"unraid_network_inspect", "unraid_network_host_state",
"unraid_network_audit_tcp", "unraid_network_lan_probe",
"unraid_system_health", "unraid_storage_status",
"unraid_disk_health", "unraid_notifications_list",
"unraid_shares_list", "unraid_share_inspect",
"unraid_files_inventory",
"unraid_system_connection_test", "unraid_system_shell_readonly",
))
readonly = copy.deepcopy(mua_source)
readonly["config"] = {
"enable": True,
"function_name_filter_list": readonly_functions,
"access_grants": [],
}
name, description = descriptions["mua-readonly-local"]
readonly["info"] = {
"id": "mua-readonly-local",
"name": name,
"description": description,
}
existing_index = next(
(
index for index, connection in enumerate(connections)
if isinstance(connection, dict)
and str((connection.get("info") or {}).get("id", "")).lower()
== "mua-readonly-local"
),
None,
)
if existing_index is None:
connections.append(readonly)
changed = True
elif connections[existing_index] != readonly:
connections[existing_index] = readonly
changed = True
if navidrome_enabled and not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-navidrome:3000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "navidrome-local"
)
for connection in connections
):
name, description = descriptions["navidrome-local"]
connections.append(
{
"url": "http://mike-ai-mcp-navidrome:3000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "navidrome-local",
"name": name,
"description": description,
},
}
)
changed = True
if deemix_enabled and not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-deemix:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "deemix-local"
)
for connection in connections
):
name, description = descriptions["deemix-local"]
connections.append(
{
"url": "http://mike-ai-mcp-deemix:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {"id": "deemix-local", "name": name, "description": description},
}
)
changed = True
if not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower() == "http://tinysearch:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "web-general-local"
)
for connection in connections
):
name, description = descriptions["web-general-local"]
connections.append(
{
"url": "http://tinysearch:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "web-general-local",
"name": name,
"description": description,
},
}
)
changed = True
if not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-athena-operator:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "athena-operator-local"
)
for connection in connections
):
name, description = descriptions["athena-operator-local"]
connections.append(
{
"url": "http://mike-ai-mcp-athena-operator:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "athena-operator-local",
"name": name,
"description": description,
},
}
)
changed = True
if github_enabled and not any(
isinstance(connection, dict)
and (
str(connection.get("url", "")).lower()
== "http://mike-ai-mcp-github:8000/mcp"
or str((connection.get("info") or {}).get("id", "")).lower()
== "github-local"
)
for connection in connections
):
name, description = descriptions["github-local"]
connections.append(
{
"url": "http://mike-ai-mcp-github:8000/mcp",
"path": "",
"type": "mcp",
"auth_type": "none",
"headers": None,
"key": "",
"config": {"enable": True, "access_grants": []},
"info": {
"id": "github-local",
"name": name,
"description": description,
},
}
)
changed = True
if changed:
con.execute(
"""
insert into config (key,value,updated_at) values (?,?,?)
on conflict(key) do update set
value=excluded.value,
updated_at=excluded.updated_at
""",
(
"tool_server.connections",
json.dumps(connections, ensure_ascii=False),
now,
),
)
print(
"OpenWebUI konfiguriert: Default Off=10, Thinking=20, Auto Tool Selector=25, "
"Stability Guard=30, Secret Redaction=40, Spoken Tool Status=80, Local Metrics=90, "
"Quick Actions=100, Folgefragen=aus, Piper-TTS=aktiv, Werkzeugwahl=optimiert"
)
con.close()
PY
python3 "$ROOT/platform/mcp/sync-clients.py" \
--registry "$ROOT/config/mcp-registry.json" \
--openwebui-db "$db"
if [[ $was_running == true ]]; then
docker start "$CONTAINER" >/dev/null
deadline=$((SECONDS + 180))
@@ -557,4 +114,4 @@ if [[ $was_running == true ]]; then
done
fi
trap - EXIT
printf 'Datenbanksicherung: %s\n' "$backup"
printf 'OPENWEBUI_SYNC_OK backup=%s\n' "$backup"
+23 -50
View File
@@ -29,7 +29,7 @@ from pathlib import Path
from typing import Any
VERSION = "3.0.0"
VERSION = "3.1.0"
STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve()
REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", str(STACK))).resolve()
STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve()
@@ -58,7 +58,7 @@ PROTECTED_CONTAINERS = {
}
ALLOWED_OPERATIONS = {
"file_update", "patch_update", "mcp_release", "run_checks", "compose_deploy", "container_action",
"openwebui_sync", "git_publish", "model_download", "benchmark", "recovery",
"openwebui_sync", "git_publish", "model_download", "benchmark", "backup",
}
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
@@ -70,7 +70,7 @@ ALLOWED_CHECKS = {
# runtime environment. Validating without it reports required model/token
# variables as missing even though the deployed stack is valid.
"compose-main": ["docker", "compose", "--env-file", "/etc/mike-ai/stack.env", "-f", "compose.yaml", "config", "-q"],
"compose-mcp": ["docker", "compose", "--env-file", "/etc/mike-ai/stack.env", "-f", "platform/mcp/compose.yaml", "config", "-q"],
"compose-mcp": ["docker", "compose", "--env-file", "/etc/mike-ai/stack.env", "-f", "compose.yaml", "config", "-q"],
}
# Athena is physically remote. The general terminal is intentionally broad,
@@ -253,7 +253,7 @@ def ensure_repository() -> None:
if not (REPOSITORY / ".git").is_dir():
raise RuntimeError(
f"canonical Git checkout is missing at {REPOSITORY}; "
"restore /opt/mike-ai/stack with the documented recovery script"
"restore /opt/mike-ai/stack from Git and run the documented restore command"
)
remote = os.environ.get("ATHENA_OPERATOR_GIT_REMOTE", "").strip()
if remote:
@@ -264,6 +264,10 @@ def ensure_repository() -> None:
def inspect(subject: str, arguments: dict[str, Any]) -> dict[str, Any]:
ensure_repository()
if subject == "guide":
guide = REPOSITORY / "ATHENA.md"
text = guide.read_text(encoding="utf-8", errors="replace")
return {"guide": text, "source": "ATHENA.md", "sha256": sha(guide.read_bytes())}
if subject == "overview":
return {
"version": VERSION,
@@ -448,9 +452,7 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
checks = payload.get("checks") or ["operator-tests", "compose-mcp"]
if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks):
raise ValueError("unknown release check suite")
compose_file = str(payload.get("compose_file", "platform/mcp/compose.yaml"))
if compose_file != "platform/mcp/compose.yaml":
raise ValueError("MCP releases must use platform/mcp/compose.yaml")
compose_file = "compose.yaml"
services = payload.get("services") or []
if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services):
raise ValueError("invalid MCP service list")
@@ -461,23 +463,18 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
selected = [str(safe_relative(str(path))) for path in paths]
if len(set(selected)) != len(selected) or not set(item["path"] for item in files).issubset(set(selected)):
raise ValueError("release paths must be unique and include every patched file")
label = str(payload.get("recovery_label", time.strftime("%Y%m%d-%H%M")))
if not SAFE_NAME.fullmatch(label):
raise ValueError("invalid recovery label")
normal = {
"files": files, "checks": checks, "compose_file": compose_file,
"services": [str(x) for x in services], "build": bool(payload.get("build", True)),
"openwebui_sync": bool(payload.get("openwebui_sync", True)),
"hermes_sync": bool(payload.get("hermes_sync", False)),
"message": message, "paths": selected,
"create_recovery": bool(payload.get("create_recovery", True)), "recovery_label": label,
}
preview = (
f"ONE MCP RELEASE\nServices: {', '.join(normal['services'])}\n"
f"Checks: {', '.join(checks)}\nOpenWebUI sync: {normal['openwebui_sync']}\n"
f"Hermes sync: {normal['hermes_sync']}\n"
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n"
f"Recovery: {normal['create_recovery']} ({label})\n\n"
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n\n"
+ "\n".join(part for part in (import_preview, patch_preview) if part)
)
return normal, preview
@@ -488,7 +485,7 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
return {"checks": checks}, "Will run: " + ", ".join(checks)
if operation == "compose_deploy":
compose_file = str(payload.get("compose_file", ""))
if compose_file not in {"compose.yaml", "platform/mcp/compose.yaml"}:
if compose_file != "compose.yaml":
raise ValueError("unsupported compose file")
services = payload.get("services") or []
if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services):
@@ -544,11 +541,8 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
if not isinstance(args, list) or len(args) > 20 or any(not isinstance(x, str) or len(x) > 200 or re.search(r"[\x00\n\r]", x) for x in args):
raise ValueError("invalid benchmark arguments")
return {"script": str(script), "arguments": args}, f"Run versioned benchmark {script} with {args!r}"
if operation == "recovery":
label = str(payload.get("label", time.strftime("%Y%m%d")))
if not SAFE_NAME.fullmatch(label):
raise ValueError("invalid recovery label")
return {"label": label}, f"Create encrypted recovery bundle and self-contained data kit: {label}"
if operation == "backup":
return {}, "Create one Docker-data backup now in /data/docker-backups."
raise AssertionError(operation)
@@ -631,30 +625,6 @@ def publish_paths(message: str, selected: list[str]) -> dict[str, Any]:
return {"commit": head, "commit_output": commit, "push_output": pushed}
def perform_recovery(label: str) -> dict[str, Any]:
dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip()
if dirty:
raise RuntimeError("publish repository changes before creating a recovery kit")
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
marker = (STACK / ".mike-ai-source-commit").read_text().strip()
if marker != head:
raise RuntimeError("deployed source marker and operator repository HEAD differ")
encrypted = Path(f"/data/athena-recovery-{label}.tar.age")
source_bundle = Path(f"/data/athena-source-{label}.git.bundle")
release = Path(f"/data/mike-ai-recovery-kit-{label}")
for target in (encrypted, source_bundle, release):
if target.exists():
raise FileExistsError(target)
git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True)
encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True)
identity = Path("/data/mike-ai-recovery-kit/recovery.agekey")
if not identity.is_file():
raise RuntimeError("existing recovery identity is unavailable")
kit_result = run([str(STACK / "platform/recovery/create-self-contained-data-kit.sh"), str(encrypted), str(identity), str(source_bundle), str(release)], timeout=7200, check=True)
verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True)
return {"commit": head, "recovery_bundle": str(encrypted), "source_bundle": str(source_bundle), "self_contained_kit": str(release), "git_bundle": git_bundle, "encrypted_bundle": encrypted_result, "kit": kit_result, "verification": verify}
def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
if operation in {"file_update", "patch_update"}:
backup = STATE / "backups" / f"{now()}-{ticket}"
@@ -689,8 +659,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
hermes_synced = True
publication = publish_paths(payload["message"], payload["paths"])
published = True
recovery = perform_recovery(payload["recovery_label"]) if payload["create_recovery"] else None
return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "hermes_sync": hermes_sync, "publication": publication, "recovery": recovery, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "hermes_sync": hermes_sync, "publication": publication, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
except Exception:
if not published:
restore_files(payload["files"], backup)
@@ -750,10 +719,14 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
interpreter = "python3" if script.suffix == ".py" else "bash"
return run([interpreter, str(script), *payload["arguments"]], cwd=REPOSITORY, timeout=86400)
return start_job(ticket, operation, benchmark)
if operation == "recovery":
def recovery():
return perform_recovery(payload["label"])
return start_job(ticket, operation, recovery)
if operation == "backup":
def backup():
result = run(["docker", "exec", "mike-ai-backup", "backup"], timeout=7200, check=True)
latest = Path("/data/docker-backups/athena-latest.tar.gz")
if not latest.is_file():
raise RuntimeError("backup completed without latest archive")
return {"backup": result, "archive": str(latest), "bytes": latest.stat().st_size}
return start_job(ticket, operation, backup)
raise AssertionError(operation)
@@ -783,7 +756,7 @@ def execute(arguments: dict[str, Any]) -> dict[str, Any]:
json_write(completed, record)
path.unlink()
audit("executed", ticket=ticket, operation=record["operation"], binding=record["binding"])
return {"ticket": ticket, "operation": record["operation"], "result": result, "instruction": "Verify health and Git/recovery state before declaring the work complete."}
return {"ticket": ticket, "operation": record["operation"], "result": result, "instruction": "Verify service health and Git state before declaring the work complete."}
except Exception:
record["status"] = "failed"
json_write(path, record)
@@ -1,97 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
OUTPUT=${1:-}
RECIPIENT_FILE=${AGE_RECIPIENT_FILE:-/etc/mike-ai/recovery.age-recipient}
OPENWEBUI_VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
OPENWEBUI_CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui}
STACK_DIR=${STACK_DIR:-/opt/mike-ai/stack}
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -n $OUTPUT ]] || die "Aufruf: $0 /sicheres/offhost-ziel/athena-recovery-YYYYMMDD.tar.age"
[[ -s $RECIPIENT_FILE ]] || die "Age-Empfängerdatei fehlt: $RECIPIENT_FILE"
command -v age >/dev/null || die "age ist nicht installiert."
command -v docker >/dev/null || die "Docker ist nicht installiert."
recipient=$(awk '/^age1[[:alnum:]]+$/ {print; exit}' "$RECIPIENT_FILE")
[[ -n $recipient ]] || die "Keine gültige öffentliche age-Adresse gefunden."
install -d -m 0700 "$(dirname "$OUTPUT")"
[[ ! -e $OUTPUT ]] || die "Zieldatei existiert bereits: $OUTPUT"
stage=$(mktemp -d /tmp/mike-ai-recovery.XXXXXX)
sqlite_snapshot=""
cleanup() {
[[ -z $sqlite_snapshot ]] || rm -f -- "$sqlite_snapshot"
rm -rf "$stage"
}
trap cleanup EXIT
mkdir -p "$stage/rootfs" "$stage/payload"
for source in \
/etc/mike-ai \
/root/mike-ai-install.env \
/opt/mike-ai/stack/docs \
/data/mike-ai-platform-context \
/data/hermes \
/data/hermes-webui/state; do
[[ -e $source ]] || continue
rsync -aR "$source" "$stage/rootfs/"
done
tar -C "$stage/rootfs" -czf "$stage/payload/host-config.tar.gz" .
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$OPENWEBUI_VOLUME")
[[ -s $volume_path/webui.db ]] || die "OpenWebUI-Datenbank fehlt oder ist leer."
# OpenWebUI uses SQLite. The online backup API creates a transactionally
# consistent snapshot while the service remains available. The archive omits
# the live DB/WAL/SHM and stores that snapshot under the canonical DB name.
[[ $(docker inspect -f '{{.State.Running}}' "$OPENWEBUI_CONTAINER" 2>/dev/null || true) == true ]] || \
die "OpenWebUI läuft nicht; Online-Datenbanksicherung nicht möglich."
sqlite_snapshot="$volume_path/.mike-ai-recovery-webui.db"
rm -f -- "$sqlite_snapshot"
docker exec -i "$OPENWEBUI_CONTAINER" python - <<'PY'
import os
import sqlite3
source = "/app/backend/data/webui.db"
snapshot = "/app/backend/data/.mike-ai-recovery-webui.db"
if os.path.exists(snapshot):
os.unlink(snapshot)
with sqlite3.connect(source) as src, sqlite3.connect(snapshot) as dst:
src.backup(dst)
with sqlite3.connect(snapshot) as check:
result = check.execute("PRAGMA integrity_check").fetchone()
if not result or result[0] != "ok":
raise SystemExit("SQLite integrity_check failed")
PY
[[ -s $sqlite_snapshot ]] || die "Konsistenter OpenWebUI-Snapshot wurde nicht erzeugt."
tar -C "$volume_path" \
--exclude='./webui.db' \
--exclude='./webui.db-wal' \
--exclude='./webui.db-shm' \
--transform='s#\.mike-ai-recovery-webui\.db#webui.db#' \
-czf "$stage/payload/openwebui-data.tar.gz" .
tar -tzf "$stage/payload/openwebui-data.tar.gz" ./webui.db >/dev/null 2>&1 || \
die "OpenWebUI-Archiv enthält den konsistenten Datenbanksnapshot nicht."
source_commit=unknown
[[ ! -s $STACK_DIR/.mike-ai-source-commit ]] || source_commit=$(<"$STACK_DIR/.mike-ai-source-commit")
cat >"$stage/payload/METADATA" <<EOF
created_utc=$(date -u +%FT%TZ)
hostname=$(hostname)
source_commit=$source_commit
openwebui_volume=$OPENWEBUI_VOLUME
EOF
(
cd "$stage/payload"
sha256sum host-config.tar.gz openwebui-data.tar.gz METADATA >SHA256SUMS
tar -czf "$stage/bundle.tar.gz" \
host-config.tar.gz openwebui-data.tar.gz METADATA SHA256SUMS
)
age -r "$recipient" -o "$OUTPUT.partial" "$stage/bundle.tar.gz"
mv "$OUTPUT.partial" "$OUTPUT"
chmod 0600 "$OUTPUT"
printf 'RECOVERY_BUNDLE_OK %s\n' "$OUTPUT"
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
RECOVERY_BUNDLE=${1:-}
AGE_IDENTITY=${2:-}
SOURCE_BUNDLE=${3:-}
RELEASE_DIR=${4:-}
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -s $RECOVERY_BUNDLE ]] || die "Recovery-Bundle fehlt."
[[ -s $AGE_IDENTITY ]] || die "age-Identität fehlt."
[[ -s $SOURCE_BUNDLE ]] || die "Git-Quellbundle fehlt."
[[ -n $RELEASE_DIR && $RELEASE_DIR == /data/* ]] || \
die "Release-Ziel muss ein eindeutiger Pfad unter /data sein."
[[ ! -e $RELEASE_DIR ]] || die "Release-Ziel existiert bereits."
command -v age >/dev/null || die "age fehlt."
command -v git >/dev/null || die "git fehlt."
stage=$(mktemp -d /tmp/mike-ai-data-kit.XXXXXX)
trap 'rm -rf "$stage"' EXIT
age -d -i "$AGE_IDENTITY" -o "$stage/bundle.tar.gz" "$RECOVERY_BUNDLE"
tar -C "$stage" -xzf "$stage/bundle.tar.gz" METADATA SHA256SUMS \
host-config.tar.gz openwebui-data.tar.gz
(cd "$stage" && sha256sum -c SHA256SUMS)
commit=$(sed -n 's/^source_commit=//p' "$stage/METADATA" | head -n 1)
[[ $commit =~ ^[0-9a-f]{40}$ ]] || die "Recovery-Commit in METADATA ist ungültig."
git clone -q "$SOURCE_BUNDLE" "$stage/source-check"
git -C "$stage/source-check" cat-file -e "$commit^{commit}"
install -d -m 0700 "$RELEASE_DIR"
install -m 0600 "$RECOVERY_BUNDLE" "$RELEASE_DIR/recovery.tar.age"
install -m 0600 "$AGE_IDENTITY" "$RELEASE_DIR/recovery.agekey"
install -m 0600 "$SOURCE_BUNDLE" "$RELEASE_DIR/source.git.bundle"
install -m 0700 "$ROOT_DIR/platform/recovery/reinstall-from-data.sh" \
"$RELEASE_DIR/reinstall-athena.sh"
cat >"$RELEASE_DIR/kit.env" <<EOF
RECOVERY_COMMIT=$commit
RECOVERY_BUNDLE=recovery.tar.age
SOURCE_BUNDLE=source.git.bundle
AGE_IDENTITY=recovery.agekey
EOF
cat >"$RELEASE_DIR/README.txt" <<'EOF'
ATHENA SELF-CONTAINED DATA-DISK RECOVERY
Auf einem frischen Debian die bestehende Data-SSD unter /data einhängen und
als root ausführen:
/data/mike-ai-recovery-kit/reinstall-athena.sh
Das Skript prüft alle Dateien, stellt einen persistenten /data-Mount her,
installiert minimale Werkzeuge und rekonstruiert den vollständigen MikeAI-
Stack. Erforderliche Treiber-Neustarts werden höchstens dreimal automatisch
fortgesetzt.
SICHERHEIT: Dieses Kit enthält den Entschlüsselungsschlüssel. Wer die Data-SSD
lesen kann, kann daher auch die enthaltenen Infrastruktur-Secrets entschlüsseln.
EOF
chmod 0600 "$RELEASE_DIR/kit.env" "$RELEASE_DIR/README.txt"
(
cd "$RELEASE_DIR"
sha256sum recovery.tar.age recovery.agekey source.git.bundle \
reinstall-athena.sh kit.env README.txt >SHA256SUMS
)
chmod 0600 "$RELEASE_DIR/SHA256SUMS"
link=/data/mike-ai-recovery-kit
[[ ! -e $link || -L $link ]] || die "$link existiert und ist kein verwalteter Symlink."
ln -sfn "$(basename "$RELEASE_DIR")" "$link"
printf 'SELF_CONTAINED_DATA_KIT_OK %s -> %s\n' "$link" "$RELEASE_DIR"
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Self-contained Athena reinstall entry point. This file is copied into a
# root-only recovery kit on /data; it is not run during normal installation.
set -Eeuo pipefail
umask 077
KIT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
CONFIG="$KIT_DIR/kit.env"
WORK_DIR=/var/lib/mike-ai-data-reinstall
SERVICE=/etc/systemd/system/mike-ai-data-reinstall.service
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
log() { printf '\n==> %s\n' "$*"; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -s $CONFIG ]] || die "kit.env fehlt im Recovery-Kit."
# shellcheck disable=SC1090
source "$CONFIG"
required=(RECOVERY_COMMIT RECOVERY_BUNDLE SOURCE_BUNDLE AGE_IDENTITY)
for name in "${required[@]}"; do
[[ -n ${!name:-} ]] || die "Pflichtwert $name fehlt."
done
for file in "$RECOVERY_BUNDLE" "$SOURCE_BUNDLE" "$AGE_IDENTITY"; do
[[ -s $KIT_DIR/$file ]] || die "Recovery-Datei fehlt: $file"
done
log "Recovery-Kit kryptografisch prüfen"
(cd "$KIT_DIR" && sha256sum -c SHA256SUMS)
log "Persistenten Mount für die Data-SSD sicherstellen"
[[ $(findmnt -n -o TARGET --target "$KIT_DIR") == /data ]] || \
die "Recovery-Kit liegt nicht auf dem eingehängten /data-Dateisystem."
if ! findmnt -s -n -o TARGET | grep -qx /data; then
source_device=$(findmnt -n -o SOURCE --target "$KIT_DIR")
source_uuid=$(blkid -s UUID -o value "$source_device")
source_type=$(findmnt -n -o FSTYPE --target "$KIT_DIR")
[[ -n $source_uuid && -n $source_type ]] || \
die "UUID oder Dateisystemtyp der Data-SSD konnte nicht bestimmt werden."
printf 'UUID=%s /data %s defaults,nofail,x-systemd.device-timeout=30 0 2\n' \
"$source_uuid" "$source_type" >>/etc/fstab
systemctl daemon-reload
fi
log "Minimale Wiederherstellungswerkzeuge installieren"
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
age ca-certificates git rsync
install -d -m 0700 "$WORK_DIR"
if [[ ! -d $WORK_DIR/repository/.git ]]; then
git clone "$KIT_DIR/$SOURCE_BUNDLE" "$WORK_DIR/repository"
fi
git -C "$WORK_DIR/repository" checkout --detach "$RECOVERY_COMMIT"
[[ $(git -C "$WORK_DIR/repository" rev-parse HEAD) == "$RECOVERY_COMMIT" ]] || \
die "Der geforderte Recovery-Commit ist nicht ausgecheckt."
attempt=0
[[ ! -s $WORK_DIR/attempt ]] || attempt=$(<"$WORK_DIR/attempt")
(( attempt += 1 ))
printf '%s\n' "$attempt" >"$WORK_DIR/attempt"
(( attempt <= 3 )) || die "Mehr als drei automatische Recovery-Versuche; Abbruch gegen Bootschleife."
log "Bare-Metal-Wiederherstellung ausführen (Versuch $attempt/3)"
set +e
"$WORK_DIR/repository/platform/recovery/restore-recovery-bundle.sh" \
"$KIT_DIR/$RECOVERY_BUNDLE" "$KIT_DIR/$AGE_IDENTITY"
status=$?
set -e
if [[ $status == 20 || $status == 21 ]]; then
log "Ein kontrollierter Treiber-/Netzwerk-Neustart ist erforderlich"
cat >"$SERVICE" <<EOF
[Unit]
Description=Resume MikeAI data-disk disaster recovery
After=network-online.target local-fs.target
Wants=network-online.target
RequiresMountsFor=/data
[Service]
Type=oneshot
ExecStart=$KIT_DIR/reinstall-athena.sh --resume
StandardOutput=append:/var/log/mike-ai-data-reinstall.log
StandardError=append:/var/log/mike-ai-data-reinstall.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable mike-ai-data-reinstall.service
sync
systemctl reboot
exit 0
fi
[[ $status == 0 ]] || die "Wiederherstellung ist mit Status $status fehlgeschlagen."
systemctl disable mike-ai-data-reinstall.service >/dev/null 2>&1 || true
rm -f "$SERVICE" "$WORK_DIR/attempt"
systemctl daemon-reload
printf '\nDATA_DISK_REINSTALL_OK\n'
printf 'OpenWebUI, Router, Modelle und MCPs wurden wiederhergestellt.\n'
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
BUNDLE=${1:-}
IDENTITY=${2:-}
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
OPENWEBUI_VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data}
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
log() { printf '\n==> %s\n' "$*"; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
[[ -s $BUNDLE ]] || die "Recovery-Bundle fehlt."
[[ -s $IDENTITY ]] || die "Age-Identität fehlt."
if ! command -v age >/dev/null || ! command -v rsync >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends age rsync
fi
stage=$(mktemp -d /tmp/mike-ai-restore.XXXXXX)
trap 'rm -rf "$stage"' EXIT
age -d -i "$IDENTITY" -o "$stage/bundle.tar.gz" "$BUNDLE"
tar -C "$stage" -xzf "$stage/bundle.tar.gz"
(
cd "$stage"
sha256sum -c SHA256SUMS
)
tar -tzf "$stage/openwebui-data.tar.gz" ./webui.db >/dev/null 2>&1 || \
die "OpenWebUI-Archiv enthält keine Datenbank."
recorded_commit=$(sed -n 's/^source_commit=//p' "$stage/METADATA" | head -n 1)
current_commit=$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true)
if [[ -n $recorded_commit && $recorded_commit != unknown && \
$current_commit != "$recorded_commit" ]]; then
die "Repository-Commit stimmt nicht mit dem Backup überein: erwartet $recorded_commit"
fi
log "Root-only Konfiguration und freigegebene Secrets wiederherstellen"
mkdir -p "$stage/rootfs"
tar -C "$stage/rootfs" -xzf "$stage/host-config.tar.gz"
[[ -s $stage/rootfs/root/mike-ai-install.env ]] || \
die "Installationskonfiguration fehlt im Bundle."
install -d -m 0700 /etc/mike-ai
rsync -a "$stage/rootfs/etc/mike-ai/" /etc/mike-ai/
install -m 0600 "$stage/rootfs/root/mike-ai-install.env" /root/mike-ai-install.env
log "Reproduzierbaren Host-Installer ausführen"
set +e
"$ROOT_DIR/install.sh" --config /root/mike-ai-install.env
status=$?
set -e
if [[ $status == 20 || $status == 21 ]]; then
printf 'REBOOT_REQUIRED code=%s\n' "$status"
printf 'Nach dem Neustart denselben Restore-Befehl erneut ausführen.\n'
exit "$status"
fi
[[ $status == 0 ]] || die "Host-Installer ist mit Status $status fehlgeschlagen."
log "Gesicherten Platform-Kontext und lokale Dokumentationspflege wiederherstellen"
if [[ -d $stage/rootfs/opt/mike-ai/stack/docs ]]; then
rsync -a "$stage/rootfs/opt/mike-ai/stack/docs/" /opt/mike-ai/stack/docs/
fi
if [[ -d $stage/rootfs/data/mike-ai-platform-context ]]; then
install -d -o 10001 -g 10001 -m 0750 /data/mike-ai-platform-context
rsync -a "$stage/rootfs/data/mike-ai-platform-context/" /data/mike-ai-platform-context/
chown -R 10001:10001 /data/mike-ai-platform-context
fi
log "OpenWebUI-Zustand atomar wiederherstellen"
volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$OPENWEBUI_VOLUME")
[[ -d $volume_path && $volume_path == /* && $volume_path != / && \
$volume_path != /data && $volume_path != /var && \
$volume_path != /var/lib && $volume_path != /var/lib/docker ]] || \
die "Unsicherer Docker-Volume-Pfad: $volume_path"
fallback=/data/openwebui-before-disaster-restore-$(date +%Y%m%d-%H%M%S).tar.gz
docker stop mike-ai-open-webui >/dev/null 2>&1 || true
tar -C "$volume_path" -czf "$fallback" .
find "$volume_path" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
tar -C "$volume_path" -xzf "$stage/openwebui-data.tar.gz"
docker start mike-ai-open-webui >/dev/null
deadline=$((SECONDS + 240))
until [[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-open-webui 2>/dev/null || true) == healthy ]]; do
(( SECONDS < deadline )) || die "OpenWebUI wurde nicht rechtzeitig gesund."
sleep 3
done
log "Versionierte Modelle, Filter und Tool-Verbindungen nachziehen"
"$ROOT_DIR/platform/openwebui/install-models.sh"
"$ROOT_DIR/platform/openwebui/install-filters.sh"
"$ROOT_DIR/platform/mcp/install-tools.sh"
if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then
"$ROOT_DIR/platform/mcp/verify-navidrome.sh"
fi
"$ROOT_DIR/dev/verify_mcp_catalogs.sh"
printf 'BARE_METAL_RECOVERY_OK\n'
printf 'Rückfallsicherung des leeren OpenWebUI-Stands: %s\n' "$fallback"
@@ -1,16 +0,0 @@
[Unit]
Description=Create bounded MikeAI platform context snapshot
After=docker.service local-fs.target
RequiresMountsFor=/data
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/mike-ai-platform-context-snapshot
User=root
Group=root
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/mike-ai-platform-context
@@ -1,11 +0,0 @@
[Unit]
Description=Refresh bounded MikeAI platform context snapshot
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=10s
Persistent=true
[Install]
WantedBy=timers.target