From 3dbb66037da44334ef7819b56757adeca5e1424e Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:31:05 +0200 Subject: [PATCH] Add self-maintaining Athena platform context MCP --- README.md | 5 +- compose.yaml | 2 +- config/operator-system-prompt.txt | 7 + dev/test_platform_context_mcp.py | 72 +++ docs/ARCHITECTURE.md | 2 + docs/COMPONENTS.md | 1 + docs/CURRENT_REFERENCE.md | 15 +- docs/DISASTER_RECOVERY.md | 4 + docs/PLATFORM_CONTEXT_MCP.md | 106 +++ docs/PLATFORM_OVERVIEW.md | 8 + docs/QWEN_OPERATOR_CONTEXT.md | 15 + platform/checks/verify-platform.sh | 13 +- platform/mcp/Dockerfile.platform-context | 14 + platform/mcp/README.md | 7 + platform/mcp/compose.yaml | 29 + platform/mcp/install-tools.sh | 17 + platform/mcp/platform-context-snapshot.py | 113 ++++ platform/mcp/platform_context_mcp.py | 609 ++++++++++++++++++ platform/recovery/create-recovery-bundle.sh | 4 +- platform/recovery/restore-recovery-bundle.sh | 10 + .../mike-ai-platform-context-snapshot.service | 16 + .../mike-ai-platform-context-snapshot.timer | 11 + 22 files changed, 1071 insertions(+), 9 deletions(-) create mode 100644 dev/test_platform_context_mcp.py create mode 100644 docs/PLATFORM_CONTEXT_MCP.md create mode 100644 platform/mcp/Dockerfile.platform-context create mode 100644 platform/mcp/platform-context-snapshot.py create mode 100644 platform/mcp/platform_context_mcp.py create mode 100644 platform/systemd/mike-ai-platform-context-snapshot.service create mode 100644 platform/systemd/mike-ai-platform-context-snapshot.timer diff --git a/README.md b/README.md index 61b5a42..98c6694 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ WireGuard-Isolation. 80:20); etwa 68 Token/s und erfolgreicher 220K-Prompt-Fülltest - Open WebUI als einzige normale Oberfläche - SearXNG/Web-MCP ohne externen API-Schlüssel -- zentrale MCP-Werkzeugebene: getrennte Container für Web, GitHub, HA, ARR, - Unraid, Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere +- zentrale MCP-Werkzeugebene: getrennte Container für Athena-Plattformwissen, + Web, GitHub, HA, ARR, Unraid, Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients - KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar - KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed @@ -80,6 +80,7 @@ keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten. - [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md) – kurze Gesamtsicht - [`docs/QWEN_OPERATOR_CONTEXT.md`](docs/QWEN_OPERATOR_CONTEXT.md) – ausführliches Kontextpaket für das lokale Operator-Modell +- [`docs/PLATFORM_CONTEXT_MCP.md`](docs/PLATFORM_CONTEXT_MCP.md) – profilunabhängiges Plattformwissen und kontrollierte Dokumentationspflege - [`config/operator-system-prompt.txt`](config/operator-system-prompt.txt) – knapper System-Prompt für ein getrenntes Operator-Profil - [Roadmap für den neuen Host](docs/NEW_HOST_ROADMAP.md) diff --git a/compose.yaml b/compose.yaml index 03b0be4..97a1236 100644 --- a/compose.yaml +++ b/compose.yaml @@ -747,7 +747,7 @@ services: # Seed native MCP connections on a fresh Open WebUI database. Secrets # stay inside the tool containers, so these internal URLs need no keys. TOOL_SERVER_CONNECTIONS: >- - [{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, GitHub/Hugging Face und Produktsuche. Nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"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."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-navidrome:3000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"navidrome-local","name":"Navidrome (Musikbibliothek)","description":"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."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}] + [{"url":"http://mike-ai-mcp-platform-context:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"athena-platform","name":"Athena Plattformwissen","description":"Zuerst aktivieren und verwenden, wenn an Athena/MikeAI, Modellen, Profilen, Router, OpenWebUI, MCPs, TTS/STT, Vision, Netzwerk oder Recovery gearbeitet wird. Liefert versionierte Dokumentation und einen begrenzten aktuellen Systemstand. Dokumentationspflege nur über Vorschau und ausdrückliche Freigabe; keine Container-, Shell-, Netzwerk-, Git- oder Secretrechte."}},{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, Hugging Face und Produktsuche. Für GitHub-Quellcode den offiziellen GitHub-MCP verwenden; nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-github:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"github-local","name":"GitHub (offiziell, read-only)","description":"Für GitHub-Repositories, Quellcode, README-Dateien, Verzeichnisbäume und Code-Suche. Strikt read-only mit genau vier Werkzeugen; nicht für allgemeine Webrecherche oder Änderungen an Repositories."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"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."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-navidrome:3000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"navidrome-local","name":"Navidrome (Musikbibliothek)","description":"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."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}] DO_NOT_TRACK: "true" SCARF_NO_ANALYTICS: "true" dns: ["${AI_DNS:-1.1.1.1}"] diff --git a/config/operator-system-prompt.txt b/config/operator-system-prompt.txt index b3bed8c..9264d9d 100644 --- a/config/operator-system-prompt.txt +++ b/config/operator-system-prompt.txt @@ -16,6 +16,13 @@ installer and configuration sources, (4) other platform documentation, and (5) old chat statements only as unverified hints. Stop before changing anything when runtime and documentation conflict. +When the Athena Platform Context MCP is enabled, start Athena/MikeAI work with +athena_get_overview and use its bounded search/read/current-state tools before +planning. Its documentation apply tool is allowed only after showing the exact +proposal and receiving explicit user approval. A local docs update is not +complete until Git commit/push and the refreshed recovery kit are separately +verified. + Athena is physically remote and normally has no KVM or on-site recovery. Never shut down, reboot, power off, alter SSH, lan0, firewall, routing, WireGuard, kernel, NVIDIA drivers, initramfs, bootloader, filesystems, partitions, mounts, diff --git a/dev/test_platform_context_mcp.py b/dev/test_platform_context_mcp.py new file mode 100644 index 0000000..f5f4640 --- /dev/null +++ b/dev/test_platform_context_mcp.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import os +import tempfile +from pathlib import Path + + +def load_module(root: Path, docs: Path, runtime: Path, state: Path): + os.environ.update({ + "ATHENA_REPO_ROOT": str(root), + "ATHENA_DOCS_ROOT": str(docs), + "ATHENA_RUNTIME_FILE": str(runtime), + "ATHENA_CONTEXT_STATE": str(state), + "ATHENA_DOC_WRITE_MODE": "enabled", + }) + source = Path(__file__).parents[1] / "platform/mcp/platform_context_mcp.py" + spec = importlib.util.spec_from_file_location("platform_context_mcp_test", source) + module = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(module) + return module + + +def main(): + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + repo = base / "repo" + docs = base / "docs" + state = base / "state" + runtime = base / "runtime.json" + (repo / "docs").mkdir(parents=True) + docs.mkdir() + (repo / "docs/PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n") + (repo / "docs/OPERATIONS.md").write_text("# Operations\nUse bounded tools.\n") + (docs / "PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n") + runtime.write_text(json.dumps({"generated_unix": 4102444800, "generated_at": "2100-01-01T00:00:00Z", "source_commit": "abc", "containers": []})) + m = load_module(repo, docs, runtime, state) + + assert len(m.TOOLS) == 9 + assert m.overview()["source"] == "docs/PLATFORM_OVERVIEW.md" + assert m.current_state()["available"] is True + assert m.search_knowledge({"query": "recovery", "max_results": 3})["count"] >= 1 + assert "Operations" in m.read_source({"path": "docs/OPERATIONS.md"})["content"] + try: + m.safe_repo_path("config/secret.env") + raise AssertionError("secret path was accepted") + except ValueError: + pass + + proposal = m.prepare_update({ + "summary": "Update overview test", + "evidence": "Verified synthetic test state", + "updates": [{"path": "docs/PLATFORM_OVERVIEW.md", "content": "# Athena\nUpdated safely.\n"}], + }) + assert (docs / "PLATFORM_OVERVIEW.md").read_text().endswith("recovery.\n") + result = m.apply_update({"proposal_id": proposal["proposal_id"], "confirmation": proposal["required_confirmation"]}) + assert result["documentation_applied"] is True + assert result["git_commit_complete"] is False + assert (docs / "PLATFORM_OVERVIEW.md").read_text().endswith("safely.\n") + assert m.maintenance_status()["latest_applied_documentation_change"] + + for tool in m.TOOLS: + for prop in tool["inputSchema"].get("properties", {}).values(): + pattern = prop.get("pattern") + if pattern: + assert pattern.startswith("^") and pattern.endswith("$") + print("PLATFORM_CONTEXT_MCP_TEST_OK") + + +if __name__ == "__main__": + main() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a5f227c..546b67d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,6 +145,7 @@ Allzweck-MCP mit sämtlichen Zugangsdaten. ```text Open WebUI ── internes Netz ───────────┬── web-mcp + ├── platform-context-mcp ├── home-assistant-mcp ├── arr-mcp ├── github-mcp-read @@ -158,6 +159,7 @@ weitere MCP-Clients ──────┴── mcp-gateway (später) ── das | Container | Werkzeugbereich | Standardrecht | |---|---|---| | `web-mcp` | Websuche, Seitenabruf, Hugging Face und öffentliche Quellen | nur lesen | +| `platform-context-mcp` | Architektur, Quellen, Snapshot und Docs-Pflege | kein Docker-Socket; Docs nur Preview/Approval | | `github-mcp-read` | Repositorysuche, Baum, Dateiinhalt und Code-Suche | vier Tools, strikt nur lesen | | `home-assistant-mcp-read` | Entities, Bereiche, Historie, Diagnose | nur lesen | | `home-assistant-mcp-write` | kontrollierte HA-Änderungen | Preview/Approval | diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index e210dda..07ecba1 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -12,6 +12,7 @@ | ARR-MCP | `arr-mcp` 1.0.1 plus dokumentierter Sonarr-Patch | eigener optionaler Container | optional | | Navidrome-MCP | Blakeem/Navidrome-MCP 2.2.0, Image per OCI-Digest | eigener optionaler Container ohne mpv | optional | | GitHub-MCP | offizieller `github/github-mcp-server` 1.10.1, vier read-only Werkzeuge | eigener optionaler Container hinter Streamable-HTTP-Brücke | optional | +| Platform Context MCP | Athena-/MikeAI-Wissen, begrenzter Laufzeitsnapshot und kontrollierte Dokumentationspflege | eigener Container ohne Docker-Socket, Shell, Egress oder Secrets | Kern | | Operator-Kontext | `docs/QWEN_OPERATOR_CONTEXT.md` plus `config/operator-system-prompt.txt` | versionierte Selbstbeschreibung und Sicherheitsregeln für Qwen | Kern | | Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional | | Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional | diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 554681a..6fa6193 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -150,12 +150,14 @@ Der isolierte Eignungs- und Ausfalltest ist in - TinySearch ausschließlich im internen Docker-Netz, ohne Host-Port - lokale ONNX-Embeddings - kompakte Web-MCP-Fassade mit vier Werkzeugen -- strukturierte API-Pfade für GitHub und Hugging Face +- strukturierter API-Pfad für Hugging Face; GitHub-Quellcode läuft über den + getrennten offiziellen GitHub-MCP ## MCP-Referenz Aktuell existieren funktionale Adapter für: +- Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege - Websuche - Home Assistant - Sonarr/Radarr @@ -164,9 +166,14 @@ Aktuell existieren funktionale Adapter für: - Unraid read-only - eigener Unraid-Administrationsserver -Der GitHub-Container und sein Streamable-HTTP-Handshake sind verifiziert. Er -startet erst produktiv, wenn `/etc/mike-ai/github-mcp.env` einen dedizierten -Read-only-Token enthält; ein leerer Platzhalter aktiviert den Dienst nicht. +Der GitHub-Container läuft produktiv. Token-Datei, interner +Streamable-HTTP-Handshake, fehlende Host-Portfreigabe und exakt vier +read-only Werkzeuge wurden am 23. August 2026 verifiziert. + +Der Platform Context MCP läuft ohne Docker-Socket, Shell, Egress oder Secrets. +Ein root-eigener Minutentimer erzeugt nur einen begrenzten Laufzeitsnapshot. +Der Schreibpfad ist auf `docs/*.md`, Vorschau, ausdrückliche Freigabe, atomare +Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt. Der frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt. diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index 28389c9..157997a 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -72,6 +72,10 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] kein Secret erscheint in Toolantworten oder Logs - [ ] `PLATFORM_OVERVIEW.md`, `QWEN_OPERATOR_CONTEXT.md` und der Operator- System-Prompt entsprechen dem wiederhergestellten Stand +- [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte + Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft +- [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und + der Recovery-Koffer wurde danach neu erzeugt ## Phase E – Vision, Bild und Sprache diff --git a/docs/PLATFORM_CONTEXT_MCP.md b/docs/PLATFORM_CONTEXT_MCP.md new file mode 100644 index 0000000..3bc080b --- /dev/null +++ b/docs/PLATFORM_CONTEXT_MCP.md @@ -0,0 +1,106 @@ +# Athena Platform Context MCP + +Stand: 23. August 2026 + +## Zweck + +`mike-ai-mcp-platform-context` gibt jedem MCP-fähigen Client dasselbe +versionierte Wissen über Athena und MikeAI. Dadurch kann in Open WebUI zwischen +Fast, Medium, Large und Ultra gewechselt werden, ohne den vollständigen +Operator-Kontext in jeden Prompt zu kopieren. + +Der MCP ist zugleich das kontrollierte Pflegefenster für seine eigene +Dokumentation. Er ist **kein** allgemeiner Athena-Administrator und erhält +weder Docker-Socket noch Shell, Netzwerkzugang, Git-Schlüssel oder Secrets. + +## Werkzeuge + +| Werkzeug | Wirkung | +|---|---| +| `athena_get_overview` | kurze Architektur und Quellenhierarchie | +| `athena_get_current_state` | begrenzter aktueller Snapshot ohne Nutzdaten | +| `athena_search_knowledge` | Suche in Dokumentation und versionierten Quellen | +| `athena_read_source` | begrenzter Ausschnitt einer ausgewählten Textdatei | +| `athena_get_change_workflow` | verbindlicher Ablauf je Änderungstyp | +| `athena_prepare_documentation_update` | erzeugt nur eine prüfbare Vorschau | +| `athena_apply_documentation_update` | schreibt nach Freigabe ausschließlich `docs/*.md` | +| `athena_get_maintenance_status` | zeigt offene Git-/Recovery-Schulden | +| `athena_close_maintenance_record` | schließt Schulden erst nach geprüftem Git-Deploy und neuerem Recovery-Koffer | + +## Aktueller Zustand ohne Docker-Socket + +`mike-ai-platform-context-snapshot.timer` startet jede Minute einen kurzen, +fest programmierten Host-Snapshot. Er erfasst ausschließlich: + +- Hostname, Debian-/Kernel-Version und Uptime +- grobe RAM- und Dateisystembelegung +- GPU-Name, UUID, VRAM-Belegung und Treiberversion +- Name, Image und Status der laufenden `mike-ai-*`-Container +- aktives Inferenzprofil +- installierten Quellcommit, Hash des Dokumentationsbaums und Status des + Recovery-Koffers + +Nicht erfasst werden Logs, Prompts, Chats, Toolinhalte, Container-Umgebungen, +Dateiinhalte außerhalb der versionierten Dokumentation oder Secretwerte. Der +Container liest nur die erzeugte JSON-Datei. Ein Snapshot älter als drei +Minuten gilt als veraltet. + +## Dokumentationspflege + +Die Pflege ist absichtlich zweistufig: + +1. Qwen prüft Laufzeit und Quellen und ruft + `athena_prepare_documentation_update` auf. +2. Das Werkzeug speichert einen Vorschlag unter + `/data/mike-ai-platform-context/pending` und liefert ID, Hashes und die + genaue Freigabezeichenfolge zurück. Noch wurde nichts geändert. +3. Qwen zeigt den Vorschlag dem Benutzer und beendet die autonome Werkzeugkette. +4. Erst nach ausdrücklicher Freigabe darf + `athena_apply_documentation_update` mit `APPLY ` aufgerufen + werden. +5. Vorherige Dateien werden unter + `/data/mike-ai-platform-context/backups` gesichert, neue Inhalte atomar + geschrieben und unter `applied` protokolliert. + +Der Server akzeptiert nur einfache Markdown-Dateien direkt unter `docs/`. +Code, Compose, Profile, Installer, Netzwerke, Services, Git und Secrets können +über diesen Schreibweg nicht verändert werden. + +## Git und Recovery + +Die kanonische Quelle ist der private Gitea-Stand +`git@192.168.1.2:michael/AI-Profile-Router.git`, Branch `main`. Das +Installationsverzeichnis `/opt/mike-ai/stack` ist eine ausgerollte Kopie und +kein Git-Working-Tree; `.mike-ai-source-commit` benennt den ausgerollten +Commit. Der offizielle GitHub-MCP ist read-only und kann dieses private +Gitea-Repository weder ändern noch pushen. Dafür ist ein getrennt +autorisiertes Git-Werkzeug beziehungsweise ein administrativer Git-Workflow +notwendig. + +Eine angewandte Dokumentationspflege ist erst vollständig abgeschlossen, wenn +getrennte, dafür autorisierte Werkzeuge Folgendes bestätigt haben: + +1. dieselbe Änderung ist im privaten Quellrepository geprüft, committed und + gepusht; +2. der Commit wurde nach Athena ausgerollt und `.mike-ai-source-commit` stimmt; +3. ein neues verschlüsseltes Recovery-Bundle und ein neues + `/data/mike-ai-recovery-kit` wurden erzeugt und geprüft. + +Der Context MCP meldet diese Punkte nach jeder Anwendung ausdrücklich als +offen. Er darf sie nicht selbst als erledigt markieren. Lokale Vorschläge, +Backups und Dokumentations-Overlays werden im verschlüsselten Recovery-Bundle +mitgesichert, sodass ungepushte Dokumentationspflege bei einem SSD-Ausfall +nicht vollständig verloren geht. Das ersetzt keinen Git-Commit. + +## Verwendung in Open WebUI + +Das Werkzeug `Athena Plattformwissen` wird nur bei Arbeiten an Athena/MikeAI +aktiviert. Ein geeigneter Startauftrag lautet: + +> Nutze zuerst das Athena-Plattformwissen. Prüfe den aktuellen Zustand und die +> relevanten Quellen. Plane danach die gewünschte Änderung mit Rückweg. Nimm +> keine risikoreiche Aktion und keine Dokumentationsanwendung ohne meine +> ausdrückliche Freigabe vor. + +Das funktioniert unabhängig vom gewählten Textprofil. Für normale Gespräche +bleibt der MCP deaktiviert und verbraucht damit keinen Werkzeugkontext. diff --git a/docs/PLATFORM_OVERVIEW.md b/docs/PLATFORM_OVERVIEW.md index d334532..aa5c51c 100644 --- a/docs/PLATFORM_OVERVIEW.md +++ b/docs/PLATFORM_OVERVIEW.md @@ -12,6 +12,13 @@ Router stellt eine OpenAI-kompatible API bereit und schaltet zwischen mehreren reproduzierbaren llama.cpp-Profilen um. Fachwerkzeuge laufen als getrennte MCP- Container; Zugangsdaten gelangen weder in llama.cpp noch in Modellprompts. +Der zuschaltbare `mike-ai-mcp-platform-context` stellt allen Textprofilen das +gleiche versionierte Plattformwissen zur Verfügung. Ein begrenzter +Host-Snapshot ersetzt einen Docker-Socket. Dokumentationsänderungen laufen nur +über Vorschau, ausdrückliche Freigabe und atomare Sicherung; Git und Recovery +bleiben getrennte, nachzuweisende Abschlussarbeiten. Details stehen in +`PLATFORM_CONTEXT_MCP.md`. + ## Hardware - Debian 13 `trixie`, Kernel 6.12 @@ -42,6 +49,7 @@ Open WebUI ---> Profile Router ---> Profile Controller ---> genau ein llama.cpp- | +-- TTS-Gateway -> XTTS-v2 -> Piper-Fallback | +-- internes MCP-Netz + +-- Athena Plattformwissen +-- Web +-- GitHub Repository read-only +-- Home Assistant diff --git a/docs/QWEN_OPERATOR_CONTEXT.md b/docs/QWEN_OPERATOR_CONTEXT.md index 80f9ce4..217422c 100644 --- a/docs/QWEN_OPERATOR_CONTEXT.md +++ b/docs/QWEN_OPERATOR_CONTEXT.md @@ -29,6 +29,14 @@ Priorität der Informationsquellen: Bei einem Widerspruch stoppst du vor jeder Änderung, benennst die Abweichung und klärst, ob Laufzeit oder Dokumentation korrigiert werden soll. +Wenn der zuschaltbare MCP `Athena Plattformwissen` verfügbar ist, beginne +Athena-/MikeAI-Aufgaben mit `athena_get_overview` und nutze danach gezielt +`athena_get_current_state`, `athena_search_knowledge` und +`athena_read_source`. Der MCP ersetzt nicht die Fachwerkzeuge. Sein +Dokumentations-Schreibweg darf erst nach Vorschau und ausdrücklicher Freigabe +verwendet werden. Eine lokale Dokumentationsänderung ist ohne separaten +Git-Commit/Push und erneuerten Recovery-Koffer nicht abgeschlossen. + ## 2. Auftrag und Einsatzumgebung MikeAI stellt lokal Inferenz, multimodale Bildanalyse, Bildgenerierung, @@ -81,6 +89,12 @@ Inferenzcontainer eingehängt. Dauerhafte Änderungen gehören zuerst in das private Repository `AI-Profile-Router`, nicht nur in einen laufenden Container. Die Hauptbestandteile sind: +Die kanonische Git-Quelle ist der private Gitea-Branch `main` unter +`git@192.168.1.2:michael/AI-Profile-Router.git`. `/opt/mike-ai/stack` ist kein +Working Tree; `.mike-ai-source-commit` bezeichnet den ausgerollten Stand. Der +offizielle GitHub-MCP ist strikt read-only und kann Gitea nicht pflegen. Für +Commit und Push ist daher ein getrennt autorisierter Git-Arbeitsweg nötig. + - Open WebUI als Benutzeroberfläche und Speicher für Arbeitsbereichsmodelle, Filter, Aktionen und Chats - Profile Router als OpenAI-kompatible API und zentrale Medien-/Profilfassade @@ -211,6 +225,7 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht. | Bereich | Aufgabe | Rechte | |---|---|---| +| Athena-Plattform | Architektur, Quellen, Laufzeitsnapshot, Dokumentationspflege | Lesen; Markdown nur Preview/Approval | | Web | aktuelle öffentliche Recherche über SearXNG/TinySearch | read-only | | GitHub | Repositorysuche, Baum, Dateiinhalt, Code-Suche | strikt read-only, vier Tools | | Home Assistant | Zustände, Historie, Diagnose, begrenzte YAML-Abläufe | Lesen; Schreiben nur Preview/Approval | diff --git a/platform/checks/verify-platform.sh b/platform/checks/verify-platform.sh index 3cd0b63..a379195 100755 --- a/platform/checks/verify-platform.sh +++ b/platform/checks/verify-platform.sh @@ -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-unraid-official; do + mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-unraid-official; do if container_healthy "$optional"; then pass "$optional aktiv" else @@ -72,6 +72,17 @@ for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \ fi done +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 diff --git a/platform/mcp/Dockerfile.platform-context b/platform/mcp/Dockerfile.platform-context new file mode 100644 index 0000000..0e2e2c1 --- /dev/null +++ b/platform/mcp/Dockerfile.platform-context @@ -0,0 +1,14 @@ +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"] diff --git a/platform/mcp/README.md b/platform/mcp/README.md index 45b3ab0..4785c2d 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -10,6 +10,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über | Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard | |---|---|---|---| +| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | Athena-Wissen, begrenzter Snapshot und kontrollierte Docs-Pflege | an | | `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | kompakte Websuche und Quellenvergleich | an | | `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` | @@ -25,6 +26,12 @@ 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. Dokumentationspflege ist auf `docs/*.md` und einen zweistufigen +Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung: +[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md). + 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 diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml index 444c168..1adbfd1 100644 --- a/platform/mcp/compose.yaml +++ b/platform/mcp/compose.yaml @@ -137,6 +137,35 @@ services: - /config:rw,noexec,nosuid,nodev,size=4m,mode=0700 networks: [tools, egress] + mcp-platform-context: + <<: *tool-common + build: + context: . + dockerfile: Dockerfile.platform-context + image: mike-ai/mcp-platform-context:1.0.0 + container_name: mike-ai-mcp-platform-context + environment: + ATHENA_REPO_ROOT: /knowledge/repo + ATHENA_DOCS_ROOT: /workspace/docs + ATHENA_RUNTIME_FILE: /runtime/runtime.json + ATHENA_CONTEXT_STATE: /state + # Writes remain confined to docs/*.md and require a prepared proposal + # plus its exact confirmation string. The MCP cannot change code, + # containers, networking, secrets, Git or recovery bundles. + ATHENA_DOC_WRITE_MODE: enabled + volumes: + - ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/knowledge/repo:ro + - ${PLATFORM_DOCS_DIR:-/opt/mike-ai/stack/docs}:/workspace/docs:rw + - ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro + - ${PLATFORM_CONTEXT_STATE_DIR:-/data/mike-ai-platform-context}:/state:rw + networks: [tools] + 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-github: <<: *tool-common build: diff --git a/platform/mcp/install-tools.sh b/platform/mcp/install-tools.sh index 1dc80a2..035fdcc 100755 --- a/platform/mcp/install-tools.sh +++ b/platform/mcp/install-tools.sh @@ -17,6 +17,23 @@ export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/se exit 1 } +# The platform context MCP never receives the Docker socket. A root-owned +# timer writes a bounded metadata snapshot instead. Only documentation files +# and the dedicated state directory are writable by the unprivileged MCP uid. +install -d -m 0755 /usr/local/libexec /var/lib/mike-ai-platform-context +install -d -o 10001 -g 10001 -m 0750 /data/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 +find /opt/mike-ai/stack/docs -type d -exec chown root:10001 {} + -exec chmod 0775 {} + +find /opt/mike-ai/stack/docs -type f -name '*.md' -exec chown root:10001 {} + -exec chmod 0664 {} + +systemctl daemon-reload +systemctl enable --now mike-ai-platform-context-snapshot.timer +systemctl start mike-ai-platform-context-snapshot.service + profiles=() if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then profiles+=(--profile homeassistant) diff --git a/platform/mcp/platform-context-snapshot.py b/platform/mcp/platform-context-snapshot.py new file mode 100644 index 0000000..dd6a7b9 --- /dev/null +++ b/platform/mcp/platform-context-snapshot.py @@ -0,0 +1,113 @@ +#!/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-")] + 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": 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() diff --git a/platform/mcp/platform_context_mcp.py b/platform/mcp/platform_context_mcp.py new file mode 100644 index 0000000..010e75a --- /dev/null +++ b/platform/mcp/platform_context_mcp.py @@ -0,0 +1,609 @@ +#!/usr/bin/env python3 +"""Bounded platform knowledge and documentation-maintenance MCP for Athena. + +This service deliberately has no Docker socket, shell tool, network egress or +secret mounts. Live data is supplied by a root-owned, fixed-command snapshot +timer. Canonical documentation may only be changed through a preview/apply +workflow and only below docs/. +""" + +from __future__ import annotations + +import hashlib +import difflib +import calendar +import json +import os +import re +import sys +import tempfile +import time +import uuid +from pathlib import Path +from typing import Any + + +SERVER_VERSION = "1.0.0" +REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo")) +DOCS_ROOT = Path(os.environ.get("ATHENA_DOCS_ROOT", "/workspace/docs")) +RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json")) +STATE_ROOT = Path(os.environ.get("ATHENA_CONTEXT_STATE", "/state")) +WRITE_MODE = os.environ.get("ATHENA_DOC_WRITE_MODE", "proposal-only") +MAX_DOCUMENT_CHARS = 24000 +MAX_SEARCH_RESULTS = 8 +MAX_UPDATE_CHARS = 120000 +ALLOWED_TEXT_SUFFIXES = {".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".service", ".timer", ".conf", ".example"} +EXCLUDED_PARTS = {".git", "__pycache__", "xtts-test-audio", ".venv", "node_modules"} + +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": ( + "USE FIRST when a request concerns Athena, MikeAI, its models, profiles, GPUs, " + "OpenWebUI, router, MCPs, TTS/STT, Vision, networking or recovery. Returns the " + "short authoritative architecture overview plus snapshot freshness. This is " + "read-only and contains no secrets. Runtime claims still require " + "athena_get_current_state or the relevant specialist MCP." + ), + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "athena_get_current_state", + "description": ( + "USE for the current bounded Athena runtime inventory: host, filesystems, GPUs, " + "active MikeAI containers, active inference profile, source commit and recovery " + "freshness. The snapshot contains no logs, prompts, chats, environment values or " + "secrets. If stale, state that explicitly. For detailed service diagnosis use the " + "specialist management tool instead of guessing." + ), + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "athena_search_knowledge", + "description": ( + "USE to find the relevant MikeAI documentation, Compose definition, installer, " + "profile, runbook or source file before planning a platform change. Returns bounded " + "matching excerpts and paths. Do not repeatedly rephrase the same search; follow up " + "with athena_read_source for the selected file." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": 2, "maxLength": 300}, + "max_results": {"type": "integer", "minimum": 1, "maximum": 8, "default": 5}, + }, + "required": ["query"], + "additionalProperties": False, + }, + }, + { + "name": "athena_read_source", + "description": ( + "USE after athena_search_knowledge to read a bounded section of one versioned " + "MikeAI source or documentation file. Secret files, .git and binary artifacts are " + "not accessible. Paths are relative to the repository, for example " + "docs/OPERATIONS.md or platform/mcp/compose.yaml." + ), + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "minLength": 3, "maxLength": 240}, + "start_line": {"type": "integer", "minimum": 1, "default": 1}, + "max_lines": {"type": "integer", "minimum": 1, "maximum": 300, "default": 160}, + }, + "required": ["path"], + "additionalProperties": False, + }, + }, + { + "name": "athena_get_change_workflow", + "description": ( + "USE before adding or replacing a model, MCP, TTS/STT, Vision/image service, " + "OpenWebUI integration, network component or recovery behavior. Returns the source " + "files, safety gates, validation steps, documentation duties, Git duties and " + "recovery duties for that change type. It performs no change." + ), + "inputSchema": { + "type": "object", + "properties": { + "change_type": { + "type": "string", + "enum": ["mcp", "model", "profile", "tts", "stt", "vision", "image", "openwebui", "network", "recovery", "other"], + } + }, + "required": ["change_type"], + "additionalProperties": False, + }, + }, + { + "name": "athena_prepare_documentation_update", + "description": ( + "USE only after a real platform change or verified documentation drift. Creates a " + "reviewable proposal; it does not alter canonical documentation. Each update must " + "target an existing or new Markdown file below docs/. Include only verified facts, " + "never secrets, prompts, chats or private content. After preview, wait for explicit " + "user approval before calling athena_apply_documentation_update." + ), + "inputSchema": { + "type": "object", + "properties": { + "summary": {"type": "string", "minLength": 5, "maxLength": 500}, + "evidence": {"type": "string", "minLength": 5, "maxLength": 2000}, + "updates": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "items": { + "type": "object", + "properties": { + "path": {"type": "string", "pattern": "^docs/[A-Za-z0-9_.-]+\\.md$"}, + "content": {"type": "string", "minLength": 1, "maxLength": 120000}, + }, + "required": ["path", "content"], + "additionalProperties": False, + }, + }, + }, + "required": ["summary", "evidence", "updates"], + "additionalProperties": False, + }, + }, + { + "name": "athena_apply_documentation_update", + "description": ( + "WRITE TOOL. Use only after the user explicitly approved the exact proposal in the " + "current conversation. Applies an already prepared proposal atomically below docs/, " + "backs up prior files and appends an audit record. It cannot change code, Compose, " + "services, secrets, Git or recovery bundles. The result always lists required Git " + "commit/push and recovery refresh work; never claim those are complete unless their " + "separate tools verify them." + ), + "inputSchema": { + "type": "object", + "properties": { + "proposal_id": {"type": "string", "pattern": "^[a-f0-9]{32}$"}, + "confirmation": {"type": "string", "minLength": 38, "maxLength": 64}, + }, + "required": ["proposal_id", "confirmation"], + "additionalProperties": False, + }, + }, + { + "name": "athena_get_maintenance_status", + "description": ( + "USE after documentation or platform work. Reports pending documentation proposals, " + "applied documentation changes awaiting Git/recovery handling, source commit and " + "recovery-kit freshness. It never commits, pushes or rebuilds recovery automatically." + ), + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "athena_close_maintenance_record", + "description": ( + "WRITE TOOL for maintenance metadata only. Use after separate tools have verified " + "that the documentation change was committed/pushed, deployed to Athena and followed " + "by a newer recovery kit. The server checks source commit and recovery timestamp before " + "moving the record to resolved. It changes no documentation, Git or recovery data." + ), + "inputSchema": { + "type": "object", + "properties": { + "proposal_id": {"type": "string", "pattern": "^[a-f0-9]{32}$"}, + "git_commit": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, + "verification": {"type": "string", "minLength": 10, "maxLength": 1000}, + "confirmation": {"type": "string", "minLength": 38, "maxLength": 64}, + }, + "required": ["proposal_id", "git_commit", "verification", "confirmation"], + "additionalProperties": False, + }, + }, +] + + +def now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def json_text(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def allowed_text_file(path: Path) -> bool: + return ( + path.suffix.lower() in ALLOWED_TEXT_SUFFIXES + or path.name.startswith("Dockerfile") + or path.name in {"LLAMA_CPP_COMMIT"} + ) + + +def safe_repo_path(relative: str) -> Path: + if not relative or relative.startswith("/") or "\\" in relative: + raise ValueError("path must be repository-relative") + parts = Path(relative).parts + if ".." in parts or any(part in EXCLUDED_PARTS for part in parts): + raise ValueError("path is outside the allowed source tree") + lowered = relative.lower() + if any(token in lowered for token in ("secret", "authorized_keys", ".env", "agekey")): + raise ValueError("secret-bearing paths are not exposed") + path = (REPO_ROOT / relative).resolve() + root = REPO_ROOT.resolve() + if root not in path.parents and path != root: + raise ValueError("path escapes repository") + if not path.is_file() or not allowed_text_file(path): + raise ValueError("path is not an allowed text source") + return path + + +def safe_doc_path(relative: str) -> Path: + match = re.fullmatch(r"docs/([A-Za-z0-9_.-]+\.md)", relative) + if not match: + raise ValueError("documentation updates are limited to docs/*.md") + path = (DOCS_ROOT / match.group(1)).resolve() + root = DOCS_ROOT.resolve() + if root not in path.parents: + raise ValueError("documentation path escapes docs root") + if path.exists() and path.is_symlink(): + raise ValueError("symbolic links are not writable") + return path + + +def read_runtime() -> dict[str, Any]: + try: + data = json.loads(RUNTIME_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return {"available": False, "error": str(exc), "instruction": "Do not infer current runtime state."} + generated = int(data.get("generated_unix", 0)) + age = max(0, int(time.time()) - generated) if generated else None + data["available"] = True + data["age_seconds"] = age + data["stale"] = age is None or age > 180 + return data + + +def overview() -> dict[str, Any]: + path = REPO_ROOT / "docs/PLATFORM_OVERVIEW.md" + text = path.read_text(encoding="utf-8")[:MAX_DOCUMENT_CHARS] + runtime = read_runtime() + return { + "source": "docs/PLATFORM_OVERVIEW.md", + "source_hierarchy": [ + "current specialist-tool evidence", + "bounded Athena runtime snapshot", + "CURRENT_REFERENCE.md and STANDARD_PROFILE_MATRIX.md", + "versioned source and runbooks", + "chat memory only as an unverified hint", + ], + "runtime_snapshot": {k: runtime.get(k) for k in ("available", "generated_at", "age_seconds", "stale", "source_commit")}, + "content": text, + "instruction": "Search or read the relevant source before proposing a change; verify mutable claims with a current tool.", + } + + +def current_state() -> dict[str, Any]: + data = read_runtime() + data["scope"] = "bounded metadata only; no logs, prompts, chats, environment values or secrets" + if data.get("stale"): + data["instruction"] = "Snapshot is stale. Do not claim current service state until a specialist tool verifies it." + return data + + +def candidate_files() -> list[Path]: + files: list[Path] = [] + for path in REPO_ROOT.rglob("*"): + try: + rel = path.relative_to(REPO_ROOT) + except ValueError: + continue + if not path.is_file() or any(part in EXCLUDED_PARTS for part in rel.parts): + continue + if not allowed_text_file(path): + continue + lowered = str(rel).lower() + if any(token in lowered for token in ("secret", "authorized_keys", ".env", "agekey")): + continue + files.append(path) + return files + + +def search_knowledge(arguments: dict[str, Any]) -> dict[str, Any]: + query = str(arguments.get("query", "")).strip() + if len(query) < 2: + raise ValueError("query is too short") + limit = max(1, min(MAX_SEARCH_RESULTS, int(arguments.get("max_results", 5)))) + terms = [term for term in re.findall(r"[a-zA-Z0-9_.-]{2,}", query.lower()) if term] + scored: list[tuple[int, str, int, str]] = [] + for path in candidate_files(): + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + continue + rel = str(path.relative_to(REPO_ROOT)) + for index, line in enumerate(lines): + lower = line.lower() + score = sum(3 if term in rel.lower() else 1 for term in terms if term in lower or term in rel.lower()) + if score: + excerpt = "\n".join(lines[max(0, index - 2): min(len(lines), index + 4)])[:1800] + scored.append((score, rel, index + 1, excerpt)) + scored.sort(key=lambda item: (-item[0], item[1], item[2])) + seen: set[tuple[str, int]] = set() + results = [] + for score, rel, line, excerpt in scored: + key = (rel, line // 20) + if key in seen: + continue + seen.add(key) + results.append({"path": rel, "line": line, "score": score, "excerpt": excerpt}) + if len(results) >= limit: + break + return {"query": query, "count": len(results), "results": results, "instruction": "Read selected sources; do not treat search excerpts as current runtime proof."} + + +def read_source(arguments: dict[str, Any]) -> dict[str, Any]: + relative = str(arguments.get("path", "")) + path = safe_repo_path(relative) + start = max(1, int(arguments.get("start_line", 1))) + max_lines = max(1, min(300, int(arguments.get("max_lines", 160)))) + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + selected = lines[start - 1:start - 1 + max_lines] + content = "\n".join(f"{start + i}: {line}" for i, line in enumerate(selected)) + return {"path": relative, "start_line": start, "end_line": start + len(selected) - 1, "total_lines": len(lines), "truncated": start - 1 + len(selected) < len(lines), "content": content[:MAX_DOCUMENT_CHARS]} + + +WORKFLOWS = { + "mcp": ["platform/mcp/compose.yaml", "platform/mcp/README.md", "compose.yaml", "docs/COMPONENTS.md", "docs/SECURITY.md", "docs/QWEN_OPERATOR_CONTEXT.md"], + "model": ["config/install.env.example", "platform/models/manifest.example.yaml", "platform/profiles/", "docs/STANDARD_PROFILE_MATRIX.md", "docs/QWEN_OPERATOR_CONTEXT.md"], + "profile": ["platform/profiles/", "router/router_profiles.json", "platform/openwebui/install-models.sh", "docs/STANDARD_PROFILE_MATRIX.md"], + "tts": ["compose.yaml", "router/xtts_worker.py", "platform/scripts/rollback-tts-production.sh", "docs/XTTS_EVALUATION_2026-08-23.md"], + "stt": ["compose.yaml", "router/stt_worker.py", "docs/COMPONENTS.md"], + "vision": ["compose.yaml", "router/ai_profile_router.py", "docs/STANDARD_PROFILE_MATRIX.md"], + "image": ["compose.yaml", "router/image_worker.py", "docs/OPERATIONS.md"], + "openwebui": ["compose.yaml", "platform/openwebui/", "docs/OPERATIONS.md", "docs/DISASTER_RECOVERY.md"], + "network": ["compose.yaml", "platform/host/", "docs/SECURITY.md", "docs/WIREGUARD_HOME_PEER.md", "docs/EMERGENCY_UNI_ACCESS.md"], + "recovery": ["platform/recovery/", "docs/BARE_METAL_RECOVERY.md", "docs/DISASTER_RECOVERY.md", "docs/RECOVERY_REQUIREMENTS.md"], + "other": ["docs/PLATFORM_OVERVIEW.md", "docs/QWEN_OPERATOR_CONTEXT.md", "docs/OPERATIONS.md"], +} + + +def change_workflow(arguments: dict[str, Any]) -> dict[str, Any]: + kind = str(arguments.get("change_type", "other")) + if kind not in WORKFLOWS: + raise ValueError("unsupported change_type") + return { + "change_type": kind, + "read_first": WORKFLOWS[kind], + "mandatory_sequence": [ + "Capture current state with the narrowest specialist tool.", + "Read relevant versioned sources and identify documentation drift.", + "Define rollback and protect SSH, LAN, WireGuard and the active inference path.", + "Change source-of-truth files, not only a running container.", + "Validate syntax/configuration and run a bounded synthetic test.", + "Verify service health and remote reachability without reading chats or private payloads.", + "Update PLATFORM_OVERVIEW/CURRENT_REFERENCE/QWEN_OPERATOR_CONTEXT and the affected runbook.", + "Commit and push the private Git repository using a separate authorized Git tool.", + "Create and verify a new encrypted recovery bundle and self-contained data-disk kit.", + ], + "hard_boundaries": [ + "This context MCP does not modify services, Docker, networking, models or secrets.", + "No shutdown, reboot, kernel/driver, SSH, firewall or VPN change without exact user approval and rollback.", + "Never claim Git or recovery is current until separately verified.", + ], + } + + +def prepare_update(arguments: dict[str, Any]) -> dict[str, Any]: + summary = str(arguments.get("summary", "")).strip() + evidence = str(arguments.get("evidence", "")).strip() + updates = arguments.get("updates") + if len(summary) < 5 or len(evidence) < 5 or not isinstance(updates, list) or not updates: + raise ValueError("summary, evidence and at least one update are required") + normalized = [] + total = 0 + for update in updates[:6]: + relative = str(update.get("path", "")) + safe_doc_path(relative) + content = str(update.get("content", "")) + if not content or len(content) > MAX_UPDATE_CHARS: + raise ValueError("invalid documentation content size") + if re.search(r"(?i)(BEGIN [A-Z ]*PRIVATE KEY|github_pat_[A-Za-z0-9_]+|GITHUB_PERSONAL_ACCESS_TOKEN\s*=\s*\S+)", content): + raise ValueError("probable secret material detected") + total += len(content) + if total > MAX_UPDATE_CHARS * 2: + raise ValueError("proposal is too large") + target = safe_doc_path(relative) + previous = target.read_text(encoding="utf-8") if target.exists() else "" + diff = "\n".join(difflib.unified_diff(previous.splitlines(), content.splitlines(), fromfile=f"a/{relative}", tofile=f"b/{relative}", lineterm="")) + normalized.append({"path": relative, "content": content, "before_sha256": hashlib.sha256(previous.encode()).hexdigest(), "after_sha256": hashlib.sha256(content.encode()).hexdigest(), "before_chars": len(previous), "after_chars": len(content), "diff_preview": diff[:12000]}) + proposal_id = uuid.uuid4().hex + proposal = {"proposal_id": proposal_id, "created_at": now_iso(), "summary": summary, "evidence": evidence, "updates": normalized, "status": "pending"} + pending = STATE_ROOT / "pending" + pending.mkdir(parents=True, exist_ok=True) + (pending / f"{proposal_id}.json").write_text(json.dumps(proposal, ensure_ascii=False, indent=2), encoding="utf-8") + return {"proposal_id": proposal_id, "summary": summary, "files": [{k: item[k] for k in ("path", "before_sha256", "after_sha256", "before_chars", "after_chars", "diff_preview")} for item in normalized], "canonical_files_changed": False, "required_confirmation": f"APPLY {proposal_id}", "instruction": "Show this proposal to the user and wait for explicit approval. Do not call apply in the same autonomous tool sequence."} + + +def apply_update(arguments: dict[str, Any]) -> dict[str, Any]: + proposal_id = str(arguments.get("proposal_id", "")) + confirmation = str(arguments.get("confirmation", "")) + if not re.fullmatch(r"[a-f0-9]{32}", proposal_id): + raise ValueError("invalid proposal_id") + if confirmation != f"APPLY {proposal_id}": + raise ValueError("confirmation does not match the exact proposal") + if WRITE_MODE != "enabled": + raise PermissionError("documentation writes are in proposal-only mode") + proposal_path = STATE_ROOT / "pending" / f"{proposal_id}.json" + if not proposal_path.is_file(): + raise ValueError("proposal not found or already applied") + proposal = json.loads(proposal_path.read_text(encoding="utf-8")) + backup_root = STATE_ROOT / "backups" / f"{int(time.time())}-{proposal_id}" + backup_root.mkdir(parents=True, exist_ok=False) + changed = [] + for item in proposal["updates"]: + target = safe_doc_path(item["path"]) + current = target.read_text(encoding="utf-8") if target.exists() else "" + current_hash = hashlib.sha256(current.encode()).hexdigest() + if current_hash != item["before_sha256"]: + raise RuntimeError(f"documentation drift after preview: {item['path']}") + if target.exists(): + (backup_root / target.name).write_text(current, encoding="utf-8") + target.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(item["content"]) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, 0o664) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + changed.append(item["path"]) + applied = STATE_ROOT / "applied" + applied.mkdir(parents=True, exist_ok=True) + proposal["status"] = "applied_docs_only" + proposal["applied_at"] = now_iso() + proposal["backup_dir"] = str(backup_root) + destination = applied / proposal_path.name + destination.write_text(json.dumps(proposal, ensure_ascii=False, indent=2), encoding="utf-8") + proposal_path.unlink() + return { + "documentation_applied": True, + "changed_files": changed, + "backup_dir": str(backup_root), + "git_commit_complete": False, + "git_push_complete": False, + "recovery_refresh_complete": False, + "required_next_steps": [ + "Use an authorized Git tool to apply the same documentation change to the private source repository, review diff, commit and push.", + "Deploy the committed source back to Athena so .mike-ai-source-commit matches.", + "Create and verify a new encrypted recovery bundle and self-contained /data recovery kit.", + "Run athena_get_maintenance_status and the platform verification checklist.", + ], + "instruction": "Do not say the platform is fully documented or recoverable until all three false fields are separately verified.", + } + + +def maintenance_status() -> dict[str, Any]: + pending_dir = STATE_ROOT / "pending" + applied_dir = STATE_ROOT / "applied" + pending = sorted(path.stem for path in pending_dir.glob("*.json")) if pending_dir.exists() else [] + applied = sorted(applied_dir.glob("*.json"), key=lambda path: path.stat().st_mtime, reverse=True) if applied_dir.exists() else [] + runtime = read_runtime() + latest_applied = None + if applied: + data = json.loads(applied[0].read_text(encoding="utf-8")) + latest_applied = {"proposal_id": data.get("proposal_id"), "summary": data.get("summary"), "applied_at": data.get("applied_at"), "status": data.get("status")} + return { + "pending_proposals": pending, + "latest_applied_documentation_change": latest_applied, + "source_commit": runtime.get("source_commit"), + "documentation_tree_sha256": runtime.get("documentation_tree_sha256"), + "recovery_kit": runtime.get("recovery_kit"), + "attention_required": bool(pending or latest_applied), + "instruction": "Applied records mean Git and recovery may still be stale; verify them with their dedicated workflow before clearing the maintenance debt.", + } + + +def close_maintenance(arguments: dict[str, Any]) -> dict[str, Any]: + proposal_id = str(arguments.get("proposal_id", "")) + git_commit = str(arguments.get("git_commit", "")) + confirmation = str(arguments.get("confirmation", "")) + verification = str(arguments.get("verification", "")).strip() + if not re.fullmatch(r"[a-f0-9]{32}", proposal_id): + raise ValueError("invalid proposal_id") + if not re.fullmatch(r"[a-f0-9]{40}", git_commit): + raise ValueError("invalid git_commit") + if confirmation != f"CLOSE {proposal_id}": + raise ValueError("confirmation does not match the exact record") + if len(verification) < 10: + raise ValueError("verification summary is required") + record = STATE_ROOT / "applied" / f"{proposal_id}.json" + if not record.is_file(): + raise ValueError("applied maintenance record not found") + data = json.loads(record.read_text(encoding="utf-8")) + runtime = read_runtime() + if runtime.get("stale"): + raise RuntimeError("runtime snapshot is stale") + if runtime.get("source_commit") != git_commit: + raise RuntimeError("deployed source commit does not match the verified Git commit") + applied_at = int(calendar.timegm(time.strptime(data["applied_at"], "%Y-%m-%dT%H:%M:%SZ"))) + recovery = runtime.get("recovery_kit") or {} + if not recovery.get("present") or int(recovery.get("modified_unix", 0)) <= applied_at: + raise RuntimeError("recovery kit is absent or older than the documentation change") + data.update({"status": "resolved", "resolved_at": now_iso(), "git_commit": git_commit, "verification": verification, "recovery_kit": recovery}) + resolved = STATE_ROOT / "resolved" + resolved.mkdir(parents=True, exist_ok=True) + destination = resolved / record.name + destination.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + record.unlink() + return {"resolved": True, "proposal_id": proposal_id, "git_commit": git_commit, "recovery_kit": recovery.get("target"), "instruction": "Maintenance debt is closed because deployed Git and a newer recovery kit were both verified."} + + +def call_tool(name: str, arguments: dict[str, Any]) -> str: + if name == "athena_get_overview": + result = overview() + elif name == "athena_get_current_state": + result = current_state() + elif name == "athena_search_knowledge": + result = search_knowledge(arguments) + elif name == "athena_read_source": + result = read_source(arguments) + elif name == "athena_get_change_workflow": + result = change_workflow(arguments) + elif name == "athena_prepare_documentation_update": + result = prepare_update(arguments) + elif name == "athena_apply_documentation_update": + result = apply_update(arguments) + elif name == "athena_get_maintenance_status": + result = maintenance_status() + elif name == "athena_close_maintenance_record": + result = close_maintenance(arguments) + else: + raise ValueError(f"unknown tool: {name}") + return json_text(result) + + +def response(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id} + payload["error" if error is not None else "result"] = error if error is not None else result + sys.stdout.write(json_text(payload) + "\n") + sys.stdout.flush() + + +def handle(message: dict[str, Any]) -> None: + method = message.get("method") + request_id = message.get("id") + if method == "initialize": + response(request_id, {"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"), "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "mike-ai-platform-context", "version": SERVER_VERSION}}) + elif method == "tools/list": + response(request_id, {"tools": TOOLS}) + elif method == "tools/call": + params = message.get("params", {}) + try: + text = call_tool(str(params.get("name", "")), params.get("arguments") or {}) + response(request_id, {"content": [{"type": "text", "text": text}], "structuredContent": json.loads(text), "isError": False}) + except Exception as exc: + response(request_id, {"content": [{"type": "text", "text": f"ERROR: {exc}"}], "isError": True}) + elif request_id is not None: + response(request_id, error={"code": -32601, "message": f"Method not found: {method}"}) + + +def main() -> None: + STATE_ROOT.mkdir(parents=True, exist_ok=True) + 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() diff --git a/platform/recovery/create-recovery-bundle.sh b/platform/recovery/create-recovery-bundle.sh index 25375f3..2cce2a3 100755 --- a/platform/recovery/create-recovery-bundle.sh +++ b/platform/recovery/create-recovery-bundle.sh @@ -32,7 +32,9 @@ mkdir -p "$stage/rootfs" "$stage/payload" for source in \ /etc/mike-ai \ /root/mike-ai-install.env \ - /usr/local/bin/runraid; do + /usr/local/bin/runraid \ + /opt/mike-ai/stack/docs \ + /data/mike-ai-platform-context; do [[ -e $source ]] || continue rsync -aR "$source" "$stage/rootfs/" done diff --git a/platform/recovery/restore-recovery-bundle.sh b/platform/recovery/restore-recovery-bundle.sh index 1971316..4acb4d8 100755 --- a/platform/recovery/restore-recovery-bundle.sh +++ b/platform/recovery/restore-recovery-bundle.sh @@ -60,6 +60,16 @@ if [[ $status == 20 || $status == 21 ]]; then 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 != / && \ diff --git a/platform/systemd/mike-ai-platform-context-snapshot.service b/platform/systemd/mike-ai-platform-context-snapshot.service new file mode 100644 index 0000000..d37fe48 --- /dev/null +++ b/platform/systemd/mike-ai-platform-context-snapshot.service @@ -0,0 +1,16 @@ +[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 + diff --git a/platform/systemd/mike-ai-platform-context-snapshot.timer b/platform/systemd/mike-ai-platform-context-snapshot.timer new file mode 100644 index 0000000..b7fce26 --- /dev/null +++ b/platform/systemd/mike-ai-platform-context-snapshot.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Refresh bounded MikeAI platform context snapshot + +[Timer] +OnBootSec=30s +OnUnitActiveSec=60s +AccuracySec=10s +Persistent=true + +[Install] +WantedBy=timers.target