From 8641464a6c72a04a63344106a438b77cc82f599e Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Sat, 5 Sep 2026 22:47:35 +0200 Subject: [PATCH] Initial commit --- .gitea/workflows/ci.yaml | 17 ++ .gitignore | 19 ++ CHANGELOG.md | 25 ++ LICENSE | 21 ++ Makefile | 14 + README.md | 208 ++++++++++++++ config/config.env.example | 22 ++ config/hermes.env.example | 16 ++ config/llama.env.example | 17 ++ config/signal-cli.env.example | 5 + docs/LLAMA_INSTALL.md | 77 +++++ docs/MIGRATION.md | 83 ++++++ docs/RTX3080.md | 98 +++++++ docs/SECURITY.md | 12 + docs/VALIDATION.md | 22 ++ docs/WORKSTATION.md | 97 +++++++ install.sh | 135 +++++++++ libexec/hermes-stack-doctor | 58 ++++ libexec/hermes-stack-health | 55 ++++ libexec/hermes-stack-hermes-update | 236 ++++++++++++++++ libexec/hermes-stack-llama-profile | 35 +++ libexec/hermes-stack-llama-report | 19 ++ libexec/hermes-stack-llama-restart | 14 + libexec/hermes-stack-llama-run | 107 +++++++ libexec/hermes-stack-notify | 83 ++++++ libexec/hermes-stack-signal-health | 45 +++ libexec/hermes-stack-signal-run | 24 ++ libexec/hermes-stack-signal-update | 108 ++++++++ libexec/hermes-stack-signal-update-check | 20 ++ scripts/install-llama-cpp.sh | 61 ++++ scripts/install-signal-cli.sh | 5 + systemd/user/hermes-stack-health.service | 6 + systemd/user/hermes-stack-health.timer | 11 + .../user/hermes-stack-hermes-update.service | 9 + systemd/user/hermes-stack-hermes-update.timer | 11 + .../user/hermes-stack-signal-health.service | 6 + systemd/user/hermes-stack-signal-health.timer | 11 + .../hermes-stack-signal-update-check.service | 8 + .../hermes-stack-signal-update-check.timer | 11 + systemd/user/llama-qwen.service | 13 + systemd/user/signal-cli.service | 14 + tests/smoke.sh | 38 +++ tests/test_llama.py | 262 ++++++++++++++++++ uninstall.sh | 34 +++ 44 files changed, 2192 insertions(+) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 config/config.env.example create mode 100644 config/hermes.env.example create mode 100644 config/llama.env.example create mode 100644 config/signal-cli.env.example create mode 100644 docs/LLAMA_INSTALL.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/RTX3080.md create mode 100644 docs/SECURITY.md create mode 100644 docs/VALIDATION.md create mode 100644 docs/WORKSTATION.md create mode 100755 install.sh create mode 100755 libexec/hermes-stack-doctor create mode 100755 libexec/hermes-stack-health create mode 100755 libexec/hermes-stack-hermes-update create mode 100644 libexec/hermes-stack-llama-profile create mode 100644 libexec/hermes-stack-llama-report create mode 100644 libexec/hermes-stack-llama-restart create mode 100755 libexec/hermes-stack-llama-run create mode 100755 libexec/hermes-stack-notify create mode 100755 libexec/hermes-stack-signal-health create mode 100755 libexec/hermes-stack-signal-run create mode 100755 libexec/hermes-stack-signal-update create mode 100755 libexec/hermes-stack-signal-update-check create mode 100644 scripts/install-llama-cpp.sh create mode 100755 scripts/install-signal-cli.sh create mode 100644 systemd/user/hermes-stack-health.service create mode 100644 systemd/user/hermes-stack-health.timer create mode 100644 systemd/user/hermes-stack-hermes-update.service create mode 100644 systemd/user/hermes-stack-hermes-update.timer create mode 100644 systemd/user/hermes-stack-signal-health.service create mode 100644 systemd/user/hermes-stack-signal-health.timer create mode 100644 systemd/user/hermes-stack-signal-update-check.service create mode 100644 systemd/user/hermes-stack-signal-update-check.timer create mode 100644 systemd/user/llama-qwen.service create mode 100644 systemd/user/signal-cli.service create mode 100755 tests/smoke.sh create mode 100644 tests/test_llama.py create mode 100755 uninstall.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..d28286a --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,17 @@ +name: CI + +on: + push: + pull_request: + +jobs: + smoke: + runs-on: ubuntu-latest + container: + image: archlinux:latest + steps: + - uses: actions/checkout@v4 + - name: Install test dependencies + run: pacman -Syu --noconfirm bash shellcheck ripgrep python jq + - name: Run smoke tests + run: ./tests/smoke.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..834bb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Local configuration and secrets +config/config.env +config/hermes.env +config/llama.env +config/signal-cli.env +*.local +*.secret +*.key + +# Runtime and generated files +dist/ +*.tar.zst +*.log +*.rej +.idea/ +.vscode/ +/hermes-test-*/ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c8a3e51 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# 2026-09-05 — RTX-3080-/Qwen-Deployment + +- CUDA-Quellbuild mit expliziter Revision, dokumentierter SHA, drei Tools und + getrennten Build-Verzeichnissen. Bestehende Binary-Auswahl bleibt erhalten. +- Modellwahl per HF oder lokaler GGUF, optional lokaler Vision-Projektor, + stabiler Hermes-Modellalias. +- Vier Profile mit 64K/48K/32K Kontext und 1536/1280 MiB Fit-Zielmarge; + ursprüngliche Parameter bleiben Standard und Rückweg. +- Vorprüfung bekannter Optionsnamen ohne Modellstart. Keine automatische + Hardware-/Treiberänderung oder Behauptung einer garantierten VRAM-Reserve. +- Profilwahl mit Backup ohne Neustart; separater Neustart mit ausdrücklicher + Bestätigung, dass Jobs fertig sind. Geteiltes Maintenance-Lock. +- Healthcheck startet den laufenden llama-server bei HTTP-Timeouts nicht neu. + Automatischer Start inaktiver llama-Dienste standardmäßig deaktiviert. +- Installationssicherung vorhandener Wrapper/Units; bestehende Konfigurationen + und Gateway-Unit bleiben erhalten. CUDA-Installation ausdrücklich optional. +- Anleitungen für neue Rechner, bestehende Workstation, Messung und Rücknahme. +- Isolierte Tests für Profilwerte, Legacy-Konfiguration, Pfade mit Leerzeichen, + Modellquellen, Optionsprüfung, Locks, Neustartschutz, HTTP-Fehler, CUDA-Build- + Veröffentlichung/Fehler und Signal-JSON-RPC-Payloads ohne echte Nachrichten. + +Nicht hier getestet: echte CUDA-Kompilierung, Qwen-Modell-/Projektor-Download, +GPU-Offload, Hyprland-Spitzenlast, Inferenzqualität, tatsächlicher Hermes-Update- +Rollback oder externe Signal-/Telegram-Zustellung. Das Repository ist ein +Deployment-Paket, kein vollständiges Image deiner privaten Installation. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65d1e7a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Johannes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7388842 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: test install doctor archive + +test: + ./tests/smoke.sh + +install: + ./install.sh + +doctor: + ~/.local/libexec/hermes-stack-doctor + +archive: + mkdir -p dist + git archive --format=tar.gz --prefix=hermes-arch-stack/ -o dist/hermes-arch-stack.tar.gz HEAD diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8dc033 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# Hermes Arch Stack + +Reproduzierbares Benutzer-Deployment für Hermes Agent auf Arch Linux mit: + +- offizieller Hermes-Git-Installation unter `~/.hermes/hermes-agent` +- von Hermes erzeugtem systemd-User-Service für den Messaging Gateway +- optionalem lokalen llama.cpp/OpenAI-kompatiblen Modellendpunkt +- optionalem signal-cli HTTP-Daemon +- robustem Hermes-Update mit Vorprüfung, Retry, Backup und Rollback +- regelmäßigen Healthchecks und Selbstheilungsversuchen +- Statusmeldungen an Telegram und Signal +- systemd-Timern für Updates und Zustandsprüfungen +- optionalem CUDA-Build von llama-server, llama-cli und llama-bench +- Qwen-/RTX-3080-Profilen mit prüfbarem Wechsel und Rückweg + +Stand: 2026-09-05. Für deine **bestehende Workstation** zuerst +[`docs/WORKSTATION.md`](docs/WORKSTATION.md) lesen. Der Schnellstart unten ist für +eine neue Maschine. Hardware-Leistungsgewinne sind noch nicht auf deiner GPU +gemessen. Es wird kein Gitea-Server angelegt und nichts automatisch gepusht. + +Das Repository enthält **keine Zugangsdaten, Signal-Sitzung oder +Modellgewichte**. Diese Daten werden lokal angelegt und sind durch `.gitignore` +ausgeschlossen. + +## Schnellstart auf einer neuen Arch-Maschine + +```bash +git clone ssh://git@GITEA/OWNER/hermes-arch-stack.git +cd hermes-arch-stack + +./install.sh --install-packages --install-hermes +``` + +Für den lokalen GPU-Endpunkt anschließend den +[`CUDA-Installer`](docs/LLAMA_INSTALL.md) ausführen. Ein vorhandener Server kann +stattdessen über `LLAMA_SERVER_BIN` verwendet werden. Der Installer benötigt +funktionierende NVIDIA-Treiber; diese werden nicht von diesem Repo eingerichtet. +`--install-packages` führt ein vollständiges `pacman -Syu` aus: bei Kernel-/ +Treiberänderungen einen Neustart einplanen. + +Danach die beim ersten Lauf erzeugten lokalen Konfigurationen bearbeiten: + +```bash +$EDITOR ~/.config/hermes-stack-maintenance/config.env +$EDITOR ~/.config/hermes-stack-maintenance/llama.env +$EDITOR ~/.config/hermes-stack-maintenance/signal-cli.env +$EDITOR ~/.hermes/.env +``` + +Hermes und die Messaging-Plattformen konfigurieren: + +```bash +hermes setup +hermes gateway setup +hermes gateway install --force +``` + +In Hermes als lokalen OpenAI-kompatiblen Endpunkt `http://127.0.0.1:8080/v1` +und als Modell-ID `qwen38-27b-local` wählen. Bestehende Hermes-Konfigurationen, +Profile, Skills und Memories werden nicht durch generische Vorgaben ersetzt. +Für Neuinstallationen diese Einstellungen interaktiv vornehmen oder die eigenen +Daten gemäß Migration sicher übernehmen. Der Alias allein setzt kein Kontextlimit. + +Signal benötigt zusätzlich eine installierte/verknüpfte signal-cli-Instanz. +Nach Konfiguration kann `./scripts/install-signal-cli.sh` den vorhandenen +Signal-Updater aufrufen; siehe dessen Konfiguration und `docs/MIGRATION.md`. + +Optionale Dienste erst nach ihrer Konfiguration aktivieren: + +```bash +systemctl --user enable --now signal-cli.service +systemctl --user enable --now llama-qwen.service +``` + +Maintenance aktivieren: + +```bash +systemctl --user enable --now \ + hermes-stack-hermes-update.timer \ + hermes-stack-health.timer \ + hermes-stack-signal-health.timer \ + hermes-stack-signal-update-check.timer +``` + +Für Start ohne interaktive Anmeldung: + +```bash +sudo loginctl enable-linger "$USER" +``` + +Abschließend: + +```bash +~/.local/libexec/hermes-stack-doctor +``` + +## Installationsoptionen + +```text +./install.sh [Optionen] + + --install-packages Arch-Pakete mit pacman installieren + --install-hermes offiziellen Hermes-Installer ausführen + --install-llama CUDA-Build installieren (benötigt funktionierenden Treiber) + --llama-ref REF expliziter llama.cpp-Tag/Commit oder bewusst master + --enable-timers Maintenance-Timer sofort aktivieren + --skip-browser Hermes ohne Browser-Automation installieren (Standard) + --with-browser Browser-Automation nicht überspringen +``` + +Ohne Optionen installiert das Skript nur die versionierten Dateien und legt +fehlende Beispielkonfigurationen an. Bestehende lokale Konfigurationen werden +nicht überschrieben. +Ersetzte Wrapper und Units werden vorher gesichert. Eine vorhandene +hermes-gateway.service wird nicht neu erzeugt. Ein Modell wird erst beim echten +Start des llama-Dienstes geladen, nicht bei der Konfigurationsprüfung. + +## RTX 3080 und Qwen + +```bash +~/.local/libexec/hermes-stack-llama-run --check +~/.local/libexec/hermes-stack-llama-profile list +~/.local/libexec/hermes-stack-llama-report +``` + +`baseline` behält deine bisherigen 64K Kontext / 1536 MiB Fit-Zielmarge. +`balanced` reduziert nur den Kontext auf 48K; `vram-plus` danach optional die +Marge auf 1280 MiB. `compact` bietet 32K bei 1536 MiB. Das sind Vergleichsprofile, +keine Garantie gegen Desktop-Speicherdruck. Details, Messprotokoll und Rücknahme: +[`docs/RTX3080.md`](docs/RTX3080.md). + +Profilwechsel ändern zunächst nur eine separate Auswahl-Datei. Ein Neustart +benötigt `hermes-stack-llama-restart --jobs-finished`. Der Healthcheck teilt das +Update-Lock und löst bei Backend-HTTP-Timeouts keine Inferenz-Neustarts mehr aus. + +## Update-Verhalten + +`hermes-stack-hermes-update` führt folgende Schritte aus: + +1. exklusives Lock und Prüfung auf lokale Änderungen +2. `origin/main`-Fetch mit begrenzten Retries und letztem HTTP/1.1-Fallback +3. schneller Abschluss ohne Backup, wenn kein Update vorliegt +4. komprimiertes Backup vor jeder Änderung +5. Stoppen des Gateways und Aufruf von `hermes update` +6. einmaliger Retry bei eindeutig transientem Fetch-Fehler +7. Konfigurationsmigration und frischer Gateway-Neustart +8. Gateway-Healthcheck; bei Fehler Wiederherstellung des gesicherten Checkouts und der erfassten Konfigurationsdateien +9. separater llama.cpp-Healthcheck ohne unsinnigen Hermes-Rollback +10. Aufbewahrung der drei neuesten Hermes-Backups + +Ein Fehler von Telegram oder Signal kann den Updateprozess niemals fehlschlagen +lassen. Detaillierte Zwischenstände landen im Journal; nur zusammengefasste +Ergebnisse werden versendet. + +## Bedienung + +```bash +# Update manuell +systemctl --user start hermes-stack-hermes-update.service + +# Status und Logs +systemctl --user status hermes-stack-hermes-update.service +journalctl --user -u hermes-stack-hermes-update.service -n 200 +journalctl --user -t hermes-stack-maintenance -n 100 + +# Benachrichtigung testen +~/.local/libexec/hermes-stack-notify \ + "Hermes-Stack: Benachrichtigungstest" + +# signal-cli nach einer Update-Meldung aktualisieren +~/.local/libexec/hermes-stack-signal-update + +# Gesamtprüfung +~/.local/libexec/hermes-stack-doctor +``` + +## Migration + +Deployment-Code und Benutzerdaten werden bewusst getrennt behandelt. Siehe +[`docs/MIGRATION.md`](docs/MIGRATION.md) für die Übernahme von Hermes-Daten, +Signal-Sitzung und Modellgewichten. + +## Gitea + +Nach dem Entpacken oder Erzeugen des Repositories: + +```bash +git init +git add . +git commit -m "Initial Hermes Arch deployment" +git branch -M main +git remote add origin ssh://git@GITEA/OWNER/hermes-arch-stack.git +git push -u origin main +``` + +Die mitgelieferte Gitea-Actions-Workflowdatei führt Syntax- und Smoke-Tests aus. +`make test` führt zusätzlich isolierte Funktionstests mit simulierten Programmen +aus. Ein echter NVIDIA-/Arch-/Signal-/Hermes-End-to-End-Test bleibt auf der +Zielmaschine erforderlich. `make archive` verwendet den letzten Commit (also +zuerst eigene Änderungen prüfen und committen). + +## Grenzen der Reproduktion + +Das Repo reproduziert die Deployment-Logik, nicht deine privaten Daten oder ein +eingefrorenes Arch-Abbild. Für denselben Modell-/Backendstand Quell-SHA sowie +Modell-/Projektor-Prüfsummen notieren. Der offizielle Hermes-Installer und Arch +liefern beim Neuaufsetzen sonst den dann verfügbaren Stand. Technische Quellen +stehen in `docs/LLAMA_INSTALL.md` und `docs/RTX3080.md`. diff --git a/config/config.env.example b/config/config.env.example new file mode 100644 index 0000000..080eb88 --- /dev/null +++ b/config/config.env.example @@ -0,0 +1,22 @@ +# Wird als Bash-Konfiguration eingelesen. Keine Leerzeichen um '=' verwenden. +HERMES_REPO="$HOME/.hermes/hermes-agent" +HERMES_ENV="$HOME/.hermes/.env" +HERMES_REMOTE="origin" +HERMES_BRANCH="main" + +GATEWAY_SERVICE="hermes-gateway.service" +LLAMA_SERVICE="llama-qwen.service" +LLAMA_URL="http://127.0.0.1:8080" +# 1: inaktiven Dienst wieder starten. HTTP-Timeouts allein starten nie neu. +# Standard 0 verhindert unerwünschte Modellstarts während Wartung/Tuning. +LLAMA_HEALTH_AUTORESTART=0 +SIGNAL_SERVICE="signal-cli.service" + +# Zusammengefasste Maintenance-Meldungen. Credentials/Ziele stehen in HERMES_ENV. +TELEGRAM_NOTIFY=1 +SIGNAL_NOTIFY=1 + +# Backup- und Prüfverhalten +BACKUP_KEEP=3 +GATEWAY_HEALTH_ATTEMPTS=12 +GATEWAY_HEALTH_DELAY=5 diff --git a/config/hermes.env.example b/config/hermes.env.example new file mode 100644 index 0000000..42d3e81 --- /dev/null +++ b/config/hermes.env.example @@ -0,0 +1,16 @@ +# Diese Datei dient nur als Feldübersicht. Auf dem Zielsystem verwendet Hermes +# standardmäßig ~/.hermes/.env. Niemals die ausgefüllte Datei committen. + +# Telegram +TELEGRAM_BOT_TOKEN= +TELEGRAM_HOME_CHANNEL= +TELEGRAM_ALLOWED_USERS= + +# Signal +SIGNAL_HTTP_URL=http://127.0.0.1:8090 +SIGNAL_ACCOUNT= +SIGNAL_HOME_CHANNEL= +SIGNAL_ALLOWED_USERS= + +# Modellprovider bzw. lokaler OpenAI-kompatibler Endpunkt werden bevorzugt mit +# `hermes setup` bzw. `hermes model` konfiguriert. diff --git a/config/llama.env.example b/config/llama.env.example new file mode 100644 index 0000000..2e179fd --- /dev/null +++ b/config/llama.env.example @@ -0,0 +1,17 @@ +# Bash-Konfiguration für hermes-stack-llama-run +# Leer: zuerst den verwalteten CUDA-Build, dann llama-server aus PATH verwenden. +# Auf deiner bestehenden Workstation explizit /usr/bin/llama-server eintragen. +LLAMA_SERVER_BIN="" +LLAMA_MODEL="" +LLAMA_HF_REPO="ggml-org/Qwen3.8-27B-GGUF:Q4_K_M" +LLAMA_ALIAS="qwen38-27b-local" +LLAMA_MMPROJ="" +LLAMA_HOST="127.0.0.1" +LLAMA_PORT="8080" + +# Erster Start unverändert mit 64K / 1536 MiB Fit-Zielmarge. +# Anschließend erst balanced, danach ggf. vram-plus vergleichen. +LLAMA_PROFILE="baseline" +LLAMA_ARGS=() +# HF lädt beim ersten echten Start herunter. Für offline/reproduzierbar: +# LLAMA_MODEL und optional LLAMA_MMPROJ setzen; LLAMA_HF_REPO leeren. diff --git a/config/signal-cli.env.example b/config/signal-cli.env.example new file mode 100644 index 0000000..b8f2716 --- /dev/null +++ b/config/signal-cli.env.example @@ -0,0 +1,5 @@ +# Bash-Konfiguration für hermes-stack-signal-run +SIGNAL_CLI_BIN="$HOME/.local/bin/signal-cli" +SIGNAL_ACCOUNT="" +SIGNAL_HTTP_BIND="127.0.0.1:8090" +SIGNAL_CONFIG_DIR="$HOME/.local/share/signal-cli" diff --git a/docs/LLAMA_INSTALL.md b/docs/LLAMA_INSTALL.md new file mode 100644 index 0000000..67591c8 --- /dev/null +++ b/docs/LLAMA_INSTALL.md @@ -0,0 +1,77 @@ +# llama.cpp samt Server, CLI und Benchmark installieren + +Der Installer baut die drei Programme `llama-server`, `llama-cli`, `llama-bench` +aus dem offiziellen Repository mit CUDA. Er überschreibt weder `/usr/bin` noch +einen vorhandenen Build und startet keinen Dienst. Kein AUR-Helper erforderlich. + +## Neue Arch-Maschine + +Zuerst einen zur GPU passenden NVIDIA-Treiber einrichten. `nvidia-smi` muss +funktionieren. Treiberwahl und Kernelupdates werden nicht automatisiert. + +```bash +sudo pacman -Syu --needed base-devel git cmake ninja cuda openssl +``` + +Dies ist eine vollständige Arch-Systemaktualisierung. Bei Kernel-/Treiberupdate +Jobs beenden und neu starten, bevor du die GPU-Installation testest. Keine +partielle Aktualisierung durch Ausschließen einzelner Systempakete erzwingen. + +Dann aus dem Deployment-Repo: + +```bash +# master bewusst ausgewählt, keine als stabil behauptete Release-Version: +./scripts/install-llama-cpp.sh --ref master --jobs 4 --cuda-arch 86 +``` + +`86` ist für RTX 3080 gewählt. Für einen anderen Rechner dessen GPU-Architektur +angeben. Mit `--ref ` lässt sich derselbe Quellstand erneut +bauen. Tags/Branches werden aufgelöst und im jeweiligen `BUILD-INFO.txt` zusammen +mit der SHA gespeichert. CUDA/Compiler/Arch-Pakete bleiben rollende Abhängigkeiten; +das ist kein bitidentisch eingefrorenes Betriebssystem-Image. +`--prefix /absoluter/pfad` erlaubt einen anderen Build-Speicherort. Dann den +ausgegebenen Binary-Pfad ausdrücklich in `LLAMA_SERVER_BIN` konfigurieren. + +Falls CUDA den installierten Hostcompiler ablehnt: kompatiblen Compiler nach +CUDA-/Arch-Dokumentation wählen und CMake z.B. über `CC`, `CXX`, `CUDAHOSTCXX` +konfigurieren. Kein automatisches `--allow-unsupported-compiler` und keine +Treiber-/Systemdateipatches. Ein fehlgeschlagener Build lässt aktive Versionen +unverändert und bleibt zur Diagnose erhalten. + +Der Installer kopiert das Build-bin-Verzeichnis einschließlich dortiger +Begleitdateien. Die Projektbibliotheken werden statisch gebaut; CUDA- und +Systembibliotheken bleiben Laufzeitabhängigkeiten. Auf neuer Hardware neu bauen, +nicht nur die Binary übertragen. + +## Auswahl + +- Erster verwalteter Build: Link `~/.local/opt/llama.cpp/current` wird angelegt. +- Spätere Builds: der Link bleibt stehen, der neue Pfad wird ausgegeben. +- Expliziter `LLAMA_SERVER_BIN` in `llama.env` hat Vorrang vor dem Link. +- Ohne expliziten Pfad: verwalteter Link, sonst `llama-server` aus PATH. + +Auf der bisherigen Workstation **zunächst `/usr/bin/llama-server` beibehalten**. +Einen Backend-Versionswechsel und VRAM-Tuning nicht im selben Vergleich vermischen. +Für einen späteren Build dessen ausgegebenen Pfad in `LLAMA_SERVER_BIN` eintragen, +prüfen und erst nach Jobende mit dem Restart-Helfer aktivieren. Den alten Pfad +notieren; Rücknahme bedeutet Pfad zurücksetzen und erneut kontrolliert starten. + +```bash +~/.local/libexec/hermes-stack-llama-run --check +``` + +Die Prüfung lädt kein Modell. Das erste echte Starten mit `LLAMA_HF_REPO` +benötigt Netzwerk, genug Plattenplatz und ein vorhandenes HF-Repository. Bei +Download-/Modellproblemen die vom Nutzer angegebene Modell-ID prüfen; sie wurde +hier nicht heruntergeladen. Für offline und feste Modellversionen lokale +GGUF-Dateien und ggf. passenden Vision-Projektor verwenden, SHA256 notieren. +Modell und Projektor werden separat gesichert, nicht im Git-Repo abgelegt. + +## Offizielle Grundlagen + +- [llama.cpp bauen, CUDA und GPU-Architekturen](https://github.com/ggml-org/llama.cpp/blob/master/docs/build.md) +- [Arch CUDA-Paket](https://archlinux.org/packages/extra/x86_64/cuda/) +- [NVIDIA Compute Capability](https://developer.nvidia.com/cuda-gpus) + +Ein echter CUDA-Build und Modellbetrieb müssen auf der Zielmaschine geprüft +werden. Die mitgelieferten Tests benutzen keine GPU und laden keine Gewichte. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..5539fdb --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,83 @@ +# Migration auf eine neue Arch-Linux-Maschine + +Das Gitea-Repository reproduziert Programme, Units und Wartungslogik. Persönliche +Hermes-Daten, Secrets, Signal-Geräteidentität und Modellgewichte werden separat +übertragen. + +## 1. Deployment installieren + +```bash +git clone ssh://git@GITEA/OWNER/hermes-arch-stack.git +cd hermes-arch-stack +./install.sh --install-packages --install-hermes +``` + +## 2. Hermes-Benutzerdaten übertragen + +Auf der alten Maschine Hermes und Gateway stoppen und ein verschlüsseltes +Backup erstellen. Mindestens relevant sind: + +- `~/.hermes/config.yaml` +- `~/.hermes/.env` +- `~/.hermes/auth.json`, sofern vorhanden +- Profile, Sessions, Memories, Skills und Cron-Daten unter `~/.hermes/` + +Der Upstream-Checkout `~/.hermes/hermes-agent/` muss nicht übertragen werden; +er wird vom offiziellen Installer neu erzeugt. Ein unverschlüsseltes Archiv mit +Secrets darf weder ins Repository noch auf einen ungeschützten Fileserver. + +Alternativ den jeweils aktuellen Hermes-Backup-/Import-Befehl verwenden und +vorher dessen Hilfe prüfen: + +```bash +hermes backup --help +hermes import --help +``` + +## 3. Signal + +Entweder signal-cli auf dem neuen Rechner als neues verknüpftes Gerät anmelden +oder das vollständige signal-cli-Datenverzeichnis verschlüsselt übertragen: + +```text +~/.local/share/signal-cli/ +``` + +Dieses Verzeichnis enthält Zugangsdaten und ist wie ein Passwort zu behandeln. +Es darf niemals committed werden. Anschließend Account und Port in +`~/.config/hermes-stack-maintenance/signal-cli.env` sowie in `~/.hermes/.env` +abgleichen. +Eine kopierte Signal-Geräteidentität nicht auf beiden Maschinen gleichzeitig +betreiben. Für Parallelbetrieb die neue Maschine als separates Gerät verknüpfen. + +## 4. llama.cpp und Modell + +`llama-server` mit [LLAMA_INSTALL.md](LLAMA_INSTALL.md) installieren (CUDA-Build +inklusive CLI und Benchmark) oder vorhandene Binary verwenden. Die GGUF-Datei +und einen ggf. benötigten Vision-Projektor übertragen oder beim ersten Start +über HF beziehen. Danach +`~/.config/hermes-stack-maintenance/llama.env` anpassen. Modellgewichte gehören +wegen Größe und Lizenz nicht in dieses Repository. + +Die RTX-3080-Profile sind für 10 GB VRAM und die bisherige CPU ausgelegt; auf +anderer Hardware neu messen. `LLAMA_SERVER_BIN`, Modellpfad, Port und Alias +zwischen Hermes und llama.cpp abgleichen. Der neue Rechner braucht seine eigenen +NVIDIA-/Kernelpakete. Keine Binärkopie des alten CUDA-Builds erzwingen. + +## 5. Aktivieren und prüfen + +```bash +hermes gateway install --force +systemctl --user daemon-reload +systemctl --user enable --now signal-cli.service llama-qwen.service +systemctl --user enable --now \ + hermes-stack-hermes-update.timer \ + hermes-stack-health.timer \ + hermes-stack-signal-health.timer \ + hermes-stack-signal-update-check.timer + +~/.local/libexec/hermes-stack-doctor +``` + +Erst wenn der Doctor erfolgreich ist, die alte Maschine bzw. deren Signal-Gerät +außer Betrieb nehmen. diff --git a/docs/RTX3080.md b/docs/RTX3080.md new file mode 100644 index 0000000..877ea87 --- /dev/null +++ b/docs/RTX3080.md @@ -0,0 +1,98 @@ +# Qwen auf RTX 3080 10 GB / Hyprland + +Zielhardware: RTX 3080 mit 10 GiB, i9-13900KF, 64 GB RAM. Der Desktop und +llama.cpp nutzen dieselbe GPU. Das 27B-Modell in Q4_K_M passt nicht vollständig +in 10 GiB: CPU/RAM-Offload bleibt erforderlich. Mehr VRAM für Gewichte kann +helfen; weder Voll-Offload noch eine bestimmte Beschleunigung werden versprochen. + +## Profile + +| Profil | Kontextlimit | Fit-Zielmarge | Einsatz | +| --- | ---: | ---: | --- | +| `baseline` | 65536 | 1536 MiB | bisherige Startparameter, Referenz und Rückweg | +| `balanced` | 49152 | 1536 MiB | erster Vergleich, nur Kontext reduzieren | +| `vram-plus` | 49152 | 1280 MiB | zweiter Vergleich, kleinere Zielmarge | +| `compact` | 32768 | 1536 MiB | Alternative für kürzere Sitzungen | + +Alle Profile: ein Slot, K/V-Cache q8_0, Flash Attention auto, GPU-Layer auto, +Fit an, 6 Generierungs-/16 Batch-Threads, Jinja, Reasoning auto/512/preserve, +Image-Minimum 1024 Tokens und Idle-Sleep nach 300 Sekunden. Modellalias bleibt +`qwen38-27b-local`. Es werden keine Sampling-Werte oder Hermes-Memories geändert. + +Die Definitionen stehen zentral in `libexec/hermes-stack-llama-run`. +Die Konfiguration und Optionsnamen werden anhand der installierten Binary +geprüft. Nicht unterstützte Optionen führen zum Abbruch, nicht zum stillen +Weglassen. Diese Prüfung kann GPU-Laufzeitfehler oder ungültige Optionswerte +nicht ausschließen. Auf anderer Hardware die Profile nicht ungeprüft übernehmen. + +**Fit-Zielmarge ist keine geschützte VRAM-Reservierung.** Sie beeinflusst die +Speicheranpassung beim Laden; später wachsende Browser-/Desktop-/Bildlast kann +trotzdem zu Speicherdruck führen. Verfügbare Layer und Puffer hängen auch von +Modellarchitektur, Kontext, Projektor und llama.cpp-Version ab. Weniger Kontext +bedeutet nicht proportional weniger Gesamtspeicher. 1024 MiB und weniger sind +hier bewusst nicht als fertiges Profil vorgesehen. + +## Messung statt Auslastungsziel + +1. Zuerst `baseline` mit deiner normalen Browser-/Hyprland-Nutzung messen. +2. Jobs beenden. `balanced` wählen und kontrolliert neu starten. +3. Dieselben Aufgaben erneut ausführen: kurze Textantwort, längerer Agent-Task + mit Tools und eine repräsentative Bildaufgabe. Qualität mitbewerten. +4. Kalten Start nach Idle-Sleep getrennt von warmem Prompt-Cache messen. +5. Erst bei genügend Spielraum `vram-plus` probieren. Bei Rucklern, OOM, + Grafikfehlern oder deutlich schlechterer Latenz zurück auf `baseline`. + +```bash +~/.local/libexec/hermes-stack-llama-profile list +~/.local/libexec/hermes-stack-llama-report + +# Nur speichern, unterbricht nichts: +~/.local/libexec/hermes-stack-llama-profile balanced + +# Ausschließlich nach Abschluss aller Backend-Jobs: +~/.local/libexec/hermes-stack-llama-restart --jobs-finished +journalctl --user -u llama-qwen.service -f +``` + +Laufende Inferenz überlebt diesen **llama.cpp-Neustart nicht**. Ein Hermes-Update +ist davon getrennt. Die neue Healthcheck-Version startet einen laufenden +llama-server nicht allein wegen eines HTTP-Timeouts neu. Ein inaktiver Dienst +wird nur mit `LLAMA_HEALTH_AUTORESTART=1` automatisch wieder gestartet. + +GPU während aller Vergleichsläufe beobachten, nicht nur nach Modellstart: + +```bash +nvidia-smi --query-gpu=timestamp,memory.used,memory.free,utilization.gpu \ + --format=csv --loop=1 +``` + +Pro Profil notieren: freie VRAM-Minima, tatsächlich ausgelagerte GPU-Layer, +Prompt-tok/s, Generierungs-tok/s, Zeit bis erster Antwort, Job-Gesamtdauer, +Desktop-Verhalten und Ergebnisqualität. Nicht die höchste VRAM-Anzeige gewinnt, +sondern die beste zuverlässig nutzbare Leistung. + +Bei kleinerem Kontext auch Hermes' Kontextbudget passend konfigurieren: erst +die in deiner Hermes-Version verfügbaren Modell-/Konfigurationsoptionen ansehen. +Der Alias bleibt gleich, daher erkennt Hermes eine geänderte Grenze nicht +zwingend automatisch. Lange alte Sitzungen nicht ungeprüft fortsetzen; eine +frische Sitzung für den Vergleich verwenden. Das Deployment überschreibt deine +`config.yaml` bewusst nicht. + +## Rückkehr + +```bash +~/.local/libexec/hermes-stack-llama-profile baseline +~/.local/libexec/hermes-stack-llama-restart --jobs-finished +``` + +Eine Profiländerung stellt keine anderen Konfigurationen zurück. Für alte +vollständige Startparameter siehe `WORKSTATION.md`. Browserbeschleunigung, +NVIDIA-Treiber, Hyprland und Kernelparameter werden nicht verändert. + +## Quellen + +Offizielle [llama.cpp-Argumentdefinitionen](https://github.com/ggml-org/llama.cpp/blob/master/common/arg.cpp) +für Fit-Marge, Kontext, Cache und Idle-Sleep; geprüft am 2026-09-05. Maßgeblich +für deinen Rechner bleibt `llama-server --help` seiner tatsächlich installierten +Version. Die Profile übernehmen die von dir gezeigten Modellparameter; sie +sind noch kein auf deiner GPU gemessener Optimalwert. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..44dea32 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,12 @@ +# Sicherheit + +- Keine ausgefüllten `.env`-Dateien, Signal-Daten oder Modellzugangsdaten committen. +- Lokale Konfigurationen werden mit Modus `0600` angelegt. +- Signal- und Telegram-Ziele werden ausschließlich aus `~/.hermes/.env` gelesen. +- Signal-Zugriff per `SIGNAL_ALLOWED_USERS` oder Hermes-DM-Pairing beschränken. +- `GATEWAY_ALLOW_ALL_USERS` nicht für einen Agenten mit Terminalzugriff verwenden. +- Das Update löscht beim Rollback nur einen geprüften Pfad innerhalb von `$HOME`, + dessen letzter Bestandteil `hermes-agent` ist. +- Backups enthalten Secrets und müssen wie Zugangsdaten geschützt werden. +- Gitea-Repository privat halten, auch wenn keine Secrets eingecheckt werden. +- Vor produktivem Einsatz `git diff --cached` und Secret-Scanning ausführen. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..d45b3b7 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,22 @@ +# Validierung vom 2026-09-05 + +Ausgeführt in einer isolierten Arbeitsumgebung, nicht auf der Workstation: + +- `make test`: erfolgreich. +- `bash -n`: Installer, Uninstaller, alle libexec-/Installationsskripte und + alle vier Beispielkonfigurationen erfolgreich. +- Python-Vertragstests: **25 Tests erfolgreich**. Fake-Binaries ersetzen + CUDA-Build, Git-Netzwerk, systemd, HTTP und Signal-Zustellung. +- `git diff --check`: erfolgreich. +- Einfacher Secret-Mustercheck des Smoke-Skripts: kein Treffer. Kein Ersatz + für einen vollständigen Secret-Scanner oder die Prüfung vor einem Push. +- ShellCheck war hier nicht installiert und wurde nicht ausgeführt; die + mitgelieferte Gitea-CI installiert und verwendet ihn. + +Nicht durchgeführt: Arch-Paketinstallation, echter CUDA-Build, Modell-Download, +GPU-/Hyprland-Benchmark, Hermes-Installation, End-to-End-Update/Rollback oder +echte Signal-/Telegram-Nachrichten. Die 25 Tests sind kein Nachweis dafür. + +Vor Produktivbetrieb: `WORKSTATION.md` bzw. `MIGRATION.md` abarbeiten, danach +`RTX3080.md` für kontrollierte Vergleiche. Kein Dienst auf der Workstation wurde +durch die Erstellung dieses Pakets verändert. diff --git a/docs/WORKSTATION.md b/docs/WORKSTATION.md new file mode 100644 index 0000000..4350af2 --- /dev/null +++ b/docs/WORKSTATION.md @@ -0,0 +1,97 @@ +# Bestehende Workstation behutsam übernehmen + +Nicht blind den Schnellstart für eine neue Maschine ausführen. Vorhandene +Hermes-/Signal-Konfiguration bleibt privat und wird nicht durch das Repo ersetzt. +Das aktuelle Deployment enthält deine gezeigten Anpassungen; weitere lokale, +hier nicht übermittelte Änderungen können nicht automatisch rekonstruiert werden. + +## 1. Ausgangszustand sichern + +Jobs zu Ende laufen lassen, sicherstellen dass gerade kein Hermes-Update läuft. +Vorhandene Startparameter, Versionen, Units und Drop-ins lokal dokumentieren: + +```bash +systemctl --user cat llama-qwen.service +systemctl --user cat hermes-gateway.service +/usr/bin/llama-server --version +systemctl --user list-timers --all --no-pager +``` + +Bestehende Maintenance-Timer während der Umstellung anhalten; zuvor aktive +Timer notieren und nur diese später wieder aktivieren. Laufende Service-Jobs +werden durch Stoppen ihrer Timer nicht beendet. + +```bash +systemctl --user stop hermes-stack-health.timer hermes-stack-hermes-update.timer \ + hermes-stack-signal-health.timer hermes-stack-signal-update-check.timer +``` + +Das folgende `install.sh` sichert ersetzte Dateien unter +`~/.local/state/hermes-stack-deployment.*`, behält vorhandene env-Dateien und +Gateway-Unit bei und startet ohne Optionen keine Timer und keinen Backend-Dienst. +Eigene systemd-Drop-ins bleiben bestehen; sie können den neuen ExecStart übersteuern! +Vergleiche nachher `systemctl --user cat llama-qwen.service`. + +```bash +./install.sh +``` + +Nicht `--install-hermes`, `--install-llama` oder `--install-packages` verwenden, +wenn zunächst nur das bestehende funktionierende Setup übernommen werden soll. + +## 2. llama.env bewusst migrieren + +Der Installer überschreibt `llama.env` nicht. Vergleiche sie mit +`config/llama.env.example`. Vor manueller Bearbeitung eine datierte Kopie anlegen: + +```bash +cp -a ~/.config/hermes-stack-maintenance/llama.env \ + ~/.config/hermes-stack-maintenance/llama.env.before-rtx-tuning +``` + +Den Backup-Namen nur einmal verwenden oder ein neues Datum anhängen. Folgende +Werte aus der Vorlage übernehmen, vorhandene individuelle Modellpfade prüfen: + +```bash +LLAMA_SERVER_BIN="/usr/bin/llama-server" +LLAMA_MODEL="" +LLAMA_HF_REPO="ggml-org/Qwen3.8-27B-GGUF:Q4_K_M" +LLAMA_ALIAS="qwen38-27b-local" +LLAMA_MMPROJ="" +LLAMA_HOST="127.0.0.1" +LLAMA_PORT="8080" +LLAMA_PROFILE="baseline" +LLAMA_ARGS=() +``` + +Eine alte Konfiguration mit `LLAMA_MODEL` und vollständigem `LLAMA_ARGS` läuft +weiter, solange `LLAMA_PROFILE` nicht gesetzt ist. Der Profilschalter verweigert +unmigrierte Konfigurationen. Lokale GGUF statt HF: Modellpfad setzen und HF leeren. + +```bash +~/.local/libexec/hermes-stack-llama-run --print +~/.local/libexec/hermes-stack-llama-run --check +``` + +Kein Modellstart, kein Neustart. Erst nach erfolgreicher Prüfung und Jobende: + +```bash +~/.local/libexec/hermes-stack-llama-restart --jobs-finished +curl -fsS http://127.0.0.1:8080/health +~/.local/libexec/hermes-stack-llama-report +``` + +Ein kurzzeitiges 503 während des Ladens ist noch kein Beweis für einen Defekt. +Logs kontrollieren und auf Bereitschaft warten. Danach eine echte Text-/Bild- +und Tool-Aufgabe in Hermes prüfen. Erst dann vorher aktive Timer wieder starten. +`LLAMA_HEALTH_AUTORESTART=0` ist auch bei älterer config.env ohne diesen Schlüssel +der neue Standard: HTTP-Probleme werden gemeldet, nicht durch Neustarts behandelt. + +## 3. Optimieren und zurücksetzen + +Nun gemäß [RTX3080.md](RTX3080.md) jeweils nur ein Profil vergleichen. Zurück: +Profil `baseline` wählen und kontrolliert neustarten. Für vollständige Rückkehr +alte llama.env und ggf. alte Unit/Wrapper aus der Deployment-Sicherung einzeln +wiederherstellen, `daemon-reload` und Neustart nach Jobende. Nicht pauschal ein +Archiv über dein gesamtes Home extrahieren. Die Datei `llama-profile.env` hat nur +bei aktivem Profilbetrieb Vorrang; ihre vorherigen Stände liegen daneben. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..68f0726 --- /dev/null +++ b/install.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +INSTALL_PACKAGES=0 +INSTALL_HERMES=0 +INSTALL_LLAMA=0 +LLAMA_REF="" +ENABLE_TIMERS=0 +SKIP_BROWSER=1 + +usage() { + sed -n '/^## Installationsoptionen/,/^## /p' "$ROOT_DIR/README.md" | + sed '$d' +} + +while (($# > 0)); do + case "$1" in + --install-packages) INSTALL_PACKAGES=1 ;; + --install-hermes) INSTALL_HERMES=1 ;; + --install-llama) INSTALL_LLAMA=1 ;; + --llama-ref) [[ $# -ge 2 ]] || exit 2; LLAMA_REF="$2"; shift ;; + --enable-timers) ENABLE_TIMERS=1 ;; + --skip-browser) SKIP_BROWSER=1 ;; + --with-browser) SKIP_BROWSER=0 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unbekannte Option: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +if ((INSTALL_LLAMA)) && [[ -z "$LLAMA_REF" ]]; then + printf -- '--install-llama benötigt --llama-ref TAG_ODER_COMMIT (oder bewusst master).\n' >&2 + exit 2 +fi + +if [[ ! -e /etc/arch-release ]]; then + printf 'Warnung: Dieses Deployment ist für Arch Linux ausgelegt.\n' >&2 +fi +export PATH="$HOME/.local/bin:$PATH" + +if ((INSTALL_PACKAGES)); then + # Vollständige Aktualisierung statt partieller Arch-Aktualisierung. + packages=( + bash git curl xz jq zstd tar ripgrep ffmpeg coreutils util-linux \ + jre-openjdk-headless + ) + if ((INSTALL_LLAMA)); then + packages+=(base-devel cmake ninja cuda openssl) + fi + sudo pacman -Syu --needed "${packages[@]}" +fi + +if ((INSTALL_LLAMA)); then + "$ROOT_DIR/scripts/install-llama-cpp.sh" --ref "$LLAMA_REF" +fi + +if ((INSTALL_HERMES)) && ! command -v hermes >/dev/null 2>&1; then + installer_args=() + ((SKIP_BROWSER)) && installer_args+=(--skip-browser) + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | + bash -s -- "${installer_args[@]}" + export PATH="$HOME/.local/bin:$PATH" +fi + +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +libexec_dir="$HOME/.local/libexec" + +mkdir -p "$config_dir" "$unit_dir" "$libexec_dir" "${XDG_STATE_HOME:-$HOME/.local/state}" + +# Bestehende Deployment-Dateien vor Ersetzen sichern. Keine Secrets ausgeben. +deploy_backup="$(mktemp -d "${XDG_STATE_HOME:-$HOME/.local/state}/hermes-stack-deployment.XXXXXXXX")" +mkdir -p "$deploy_backup/libexec" "$deploy_backup/units" +for file in "$ROOT_DIR"/libexec/*; do + target="$libexec_dir/$(basename "$file")" + [[ ! -e "$target" ]] || cp -a -- "$target" "$deploy_backup/libexec/" +done +for file in "$ROOT_DIR"/systemd/user/*; do + target="$unit_dir/$(basename "$file")" + [[ ! -e "$target" ]] || cp -a -- "$target" "$deploy_backup/units/" +done +printf 'Vorherige Deployment-Dateien: %s\n' "$deploy_backup" + +for file in "$ROOT_DIR"/libexec/*; do + install -m 0755 "$file" "$libexec_dir/$(basename "$file")" +done + +for file in "$ROOT_DIR"/systemd/user/*; do + install -m 0644 "$file" "$unit_dir/$(basename "$file")" +done + +install_config_if_missing() { + local example="$1" target="$2" + if [[ ! -e "$target" ]]; then + install -m 0600 "$example" "$target" + printf 'Konfiguration angelegt: %s\n' "$target" + else + printf 'Konfiguration beibehalten: %s\n' "$target" + fi +} + +install_config_if_missing \ + "$ROOT_DIR/config/config.env.example" "$config_dir/config.env" +install_config_if_missing \ + "$ROOT_DIR/config/llama.env.example" "$config_dir/llama.env" +install_config_if_missing \ + "$ROOT_DIR/config/signal-cli.env.example" "$config_dir/signal-cli.env" + +if [[ ! -e "$HOME/.hermes/.env" ]]; then + mkdir -p "$HOME/.hermes" + install -m 0600 "$ROOT_DIR/config/hermes.env.example" "$HOME/.hermes/.env" + printf 'Hermes-Umgebung angelegt: %s\n' "$HOME/.hermes/.env" +fi + +systemctl --user daemon-reload + +if command -v hermes >/dev/null 2>&1 && [[ ! -e "$unit_dir/hermes-gateway.service" ]]; then + if [[ -d "$HOME/.hermes/hermes-agent/.git" ]]; then + hermes gateway install || + printf 'Gateway-Unit konnte noch nicht erzeugt werden; nach `hermes setup` erneut ausführen.\n' >&2 + fi +fi + +if ((ENABLE_TIMERS)); then + systemctl --user enable --now \ + hermes-stack-hermes-update.timer \ + hermes-stack-health.timer \ + hermes-stack-signal-health.timer \ + hermes-stack-signal-update-check.timer +fi + +printf '\nDeployment installiert. Nächster Schritt:\n %s\n' \ + "$libexec_dir/hermes-stack-doctor" diff --git a/libexec/hermes-stack-doctor b/libexec/hermes-stack-doctor new file mode 100755 index 0000000..312d6f8 --- /dev/null +++ b/libexec/hermes-stack-doctor @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -Euo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/config.env" +failures=0 + +ok() { printf '✓ %s\n' "$*"; } +bad() { printf '✗ %s\n' "$*"; failures=$((failures + 1)); } + +[[ -r "$CONFIG_FILE" ]] || { + bad "$CONFIG_FILE fehlt" + exit 1 +} +# shellcheck disable=SC1090 +source "$CONFIG_FILE" + +for command in git curl jq tar zstd systemctl hermes; do + command -v "$command" >/dev/null 2>&1 && ok "$command vorhanden" || bad "$command fehlt" +done + +for script in "$HOME/.local/libexec"/hermes-stack-*; do + [[ -f "$script" ]] || continue + bash -n "$script" && ok "Syntax: ${script##*/}" || bad "Syntaxfehler: ${script##*/}" +done + +if [[ -d "$HERMES_REPO/.git" ]]; then + ok "Hermes-Git-Checkout vorhanden" + dirty="$(git -C "$HERMES_REPO" status --porcelain --untracked-files=all | + grep -vxF '?? .install_method' || true)" + [[ -z "$dirty" ]] && ok "Hermes-Checkout sauber" || bad "Hermes-Checkout enthält lokale Änderungen" +else + bad "Hermes-Git-Checkout fehlt" +fi + +systemctl --user is-active --quiet "$GATEWAY_SERVICE" && ok "Gateway aktiv" || bad "Gateway inaktiv" +hermes gateway status >/dev/null 2>&1 && ok "Gateway gesund" || bad "Gateway-Status fehlgeschlagen" + +if curl --fail --silent --max-time 5 "$LLAMA_URL/health" | + jq -e '.status == "ok"' >/dev/null 2>&1; then + ok "llama.cpp-Endpunkt gesund" +else + bad "llama.cpp-Endpunkt nicht gesund" +fi + +set +u +set -a +# shellcheck disable=SC1090 +source "$HERMES_ENV" 2>/dev/null || true +set +a +set -u +if [[ -n "${SIGNAL_HTTP_URL:-}" ]]; then + status="$(curl --silent --output /dev/null --max-time 5 --write-out '%{http_code}' \ + "${SIGNAL_HTTP_URL%/}/api/v1/check" || true)" + [[ "$status" =~ ^2[0-9][0-9]$ ]] && ok "signal-cli HTTP erreichbar" || bad "signal-cli HTTP nicht erreichbar" +fi + +printf '\nFehler: %d\n' "$failures" +((failures == 0)) diff --git a/libexec/hermes-stack-health b/libexec/hermes-stack-health new file mode 100755 index 0000000..7a80514 --- /dev/null +++ b/libexec/hermes-stack-health @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -Euo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/config.env" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hermes-stack-maintenance" +NOTIFIER="$HOME/.local/libexec/hermes-stack-notify" +mkdir -p "$STATE_DIR" +# shellcheck disable=SC1090 +source "$CONFIG_FILE" + +# Niemals den absichtlich gestoppten Gateway mitten im Update neu starten. +exec 9>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" +flock -n 9 || exit 0 + +state_file="$STATE_DIR/health-state" +problems=() + +if ! systemctl --user is-active --quiet "$GATEWAY_SERVICE"; then + problems+=("$GATEWAY_SERVICE war inaktiv; Neustart wurde versucht") + systemctl --user restart "$GATEWAY_SERVICE" || true +fi + +if ! hermes gateway status >/dev/null 2>&1; then + problems+=("Hermes-Gateway antwortete nicht gesund; Neustart wurde versucht") + systemctl --user restart "$GATEWAY_SERVICE" || true +fi + +if [[ "${LLAMA_HEALTH_AUTORESTART:-0}" == 1 ]] && + ! systemctl --user is-active --quiet "$LLAMA_SERVICE"; then + problems+=("$LLAMA_SERVICE war inaktiv; Neustart wurde versucht") + systemctl --user restart "$LLAMA_SERVICE" || true +fi + +if ! curl --fail --silent --max-time 10 "$LLAMA_URL/health" | + jq -e '.status == "ok"' >/dev/null 2>&1; then + problems+=("llama-server antwortete nicht gesund; kein HTTP-bedingter Neustart laufender Inferenz") +fi + +if ((${#problems[@]} == 0)); then + current="healthy" + previous="$(cat "$state_file" 2>/dev/null || true)" + if [[ "$previous" == unhealthy ]]; then + "$NOTIFIER" "Hermes-Stack: Dienste sind wieder gesund." || true + fi +else + current="unhealthy" + previous="$(cat "$state_file" 2>/dev/null || true)" + if [[ "$previous" != unhealthy ]]; then + message="$(IFS='; '; printf '%s' "${problems[*]}")" + "$NOTIFIER" "Hermes-Stack: Gesundheitsprüfung fehlgeschlagen. $message. Details: journalctl --user -u $LLAMA_SERVICE -u $GATEWAY_SERVICE" || true + fi +fi + +printf '%s\n' "$current" >"$state_file" +[[ "$current" == healthy ]] diff --git a/libexec/hermes-stack-hermes-update b/libexec/hermes-stack-hermes-update new file mode 100755 index 0000000..c53ee9d --- /dev/null +++ b/libexec/hermes-stack-hermes-update @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/config.env" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hermes-stack-maintenance" +NOTIFIER="$HOME/.local/libexec/hermes-stack-notify" +mkdir -p "$STATE_DIR" + +# shellcheck disable=SC1090 +source "$CONFIG_FILE" + +log() { + printf '%s\n' "$*" | systemd-cat -t hermes-stack-maintenance +} + +notify_user() { + local text="$1" + if [[ -x "$NOTIFIER" ]]; then + "$NOTIFIER" "$text" || true + else + log "$text" + fi +} + +fetch_origin_main_with_retry() { + local -a retry_delays=(0 5 20) + local -a git_options=() + local attempt delay + + for ((attempt = 1; attempt <= ${#retry_delays[@]}; attempt++)); do + delay="${retry_delays[attempt - 1]}" + if ((delay > 0)); then + log "Hermes-Git-Fetch: neuer Versuch $attempt/${#retry_delays[@]} in ${delay}s." + sleep "$delay" + fi + + git_options=() + if ((attempt == ${#retry_delays[@]})); then + git_options=(-c http.version=HTTP/1.1) + log "Hermes-Git-Fetch: letzter Versuch mit HTTP/1.1." + fi + + if GIT_TERMINAL_PROMPT=0 timeout --foreground 90s \ + git "${git_options[@]}" -C "$HERMES_REPO" fetch --no-tags \ + "$HERMES_REMOTE" \ + "+refs/heads/$HERMES_BRANCH:refs/remotes/$HERMES_REMOTE/$HERMES_BRANCH"; then + ((attempt == 1)) || + log "Hermes-Git-Fetch war im Versuch $attempt/${#retry_delays[@]} erfolgreich." + return 0 + fi + log "Hermes-Git-Fetch fehlgeschlagen (Versuch $attempt/${#retry_delays[@]})." + done + return 1 +} + +run_hermes_update_attempt() { + local attempt="$1" + local -a pipe_status + + printf '\n===== Hermes update: Versuch %s/2 (%s) =====\n' \ + "$attempt" "$(date --iso-8601=seconds)" >>"$update_log" + + set +e + hermes update 2>&1 | tee -a "$update_log" + pipe_status=("${PIPESTATUS[@]}") + set -e + + ((pipe_status[1] == 0)) || return "${pipe_status[1]}" + return "${pipe_status[0]}" +} + +is_retryable_update_fetch_failure() { + local current_commit dirty_after + current_commit="$(git -C "$HERMES_REPO" rev-parse HEAD)" || return 1 + [[ "$current_commit" == "$old_commit" ]] || return 1 + + dirty_after="$( + git -C "$HERMES_REPO" status --porcelain --untracked-files=all | + grep -vxF '?? .install_method' || true + )" + [[ -z "$dirty_after" ]] || return 1 + grep -Fq 'Fetching updates' "$update_log" || return 1 + grep -Fq 'Pulling updates' "$update_log" && return 1 + grep -Eqi \ + 'HTTP (429|500|502|503|504)|returned error: (429|500|502|503|504)|rate.?limit|could not read Username|Authentication failed|Could not resolve host|Failed to connect|Connection (timed out|reset)|TLS|early EOF|Failed to fetch updates|Network error' \ + "$update_log" +} + +for command in git tar zstd jq curl systemctl timeout flock hermes; do + command -v "$command" >/dev/null 2>&1 || { + notify_user "Hermes-Update abgebrochen: benötigtes Kommando fehlt: $command" + exit 1 + } +done + +LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" +exec 9>"$LOCK_FILE" +flock -n 9 || exit 0 + +if [[ ! -d "$HERMES_REPO/.git" ]]; then + notify_user "Hermes-Update abgebrochen: Git-Installation nicht gefunden." + exit 1 +fi + +case "$HERMES_REPO" in + "$HOME"/*/hermes-agent) ;; + *) + notify_user "Hermes-Update abgebrochen: unsicherer Repository-Pfad: $HERMES_REPO" + exit 1 + ;; +esac + +dirty="$( + git -C "$HERMES_REPO" status --porcelain --untracked-files=all | + grep -vxF '?? .install_method' || true +)" +if [[ -n "$dirty" ]]; then + notify_user "Hermes-Update abgebrochen: lokale Änderungen im Hermes-Repository: +$dirty" + exit 1 +fi + +old_commit="$(git -C "$HERMES_REPO" rev-parse HEAD)" + +if ! fetch_origin_main_with_retry; then + notify_user "Hermes-Updateprüfung fehlgeschlagen: $HERMES_REMOTE/$HERMES_BRANCH ist nicht erreichbar. Gateway und Installation bleiben unverändert." + exit 1 +fi + +remote_commit="$(git -C "$HERMES_REPO" rev-parse "refs/remotes/$HERMES_REMOTE/$HERMES_BRANCH")" +if [[ ! "$remote_commit" =~ ^[0-9a-f]{40}$ ]]; then + notify_user "Hermes-Updateprüfung fehlgeschlagen: Remote lieferte keinen gültigen Commit." + exit 1 +fi + +if [[ "$remote_commit" == "$old_commit" ]]; then + notify_user "Hermes ist bereits aktuell: $old_commit" + exit 0 +fi + +backup_dir="$STATE_DIR/backups" +mkdir -p "$backup_dir" +timestamp="$(date +%Y%m%d-%H%M%S)" +backup="$backup_dir/hermes-$timestamp.tar.zst" +update_log="$STATE_DIR/hermes-update-$timestamp.log" + +paths=("${HERMES_REPO#"$HOME/"}") +for path in \ + "$HOME/.hermes/config.yaml" "$HOME/.hermes/.env" "$HOME/.hermes/auth.json" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$GATEWAY_SERVICE"; do + [[ -e "$path" && "$path" == "$HOME/"* ]] && paths+=("${path#"$HOME/"}") +done + +tar --zstd -cf "$backup" -C "$HOME" "${paths[@]}" + +rollback() { + trap - ERR + set +e + systemctl --user stop "$GATEWAY_SERVICE" + if [[ "$HERMES_REPO" == "$HOME/"*/hermes-agent ]]; then + rm -rf -- "$HERMES_REPO" + tar --zstd -xf "$backup" -C "$HOME" + fi + systemctl --user daemon-reload + systemctl --user restart "$GATEWAY_SERVICE" + notify_user "Hermes-Update zurückgerollt auf $old_commit. Backup: $backup" + exit 1 +} +trap rollback ERR + +systemctl --user stop "$GATEWAY_SERVICE" + +if run_hermes_update_attempt 1; then + update_exit=0 +else + update_exit=$? + log "Hermes-Updater meldete Exitcode $update_exit." + if is_retryable_update_fetch_failure; then + log "Transienter Fetch-Fehler erkannt; Hermes-Update wird einmal wiederholt." + sleep 20 + if run_hermes_update_attempt 2; then + update_exit=0 + else + update_exit=$? + log "Hermes-Updater-Retry meldete Exitcode $update_exit." + fi + fi +fi + +if ! hermes config check >/dev/null 2>&1; then + hermes config migrate + hermes config check +fi + +systemctl --user daemon-reload +systemctl --user reset-failed "$GATEWAY_SERVICE" || true +systemctl --user restart "$GATEWAY_SERVICE" + +gateway_ready=0 +for ((attempt = 1; attempt <= ${GATEWAY_HEALTH_ATTEMPTS:-12}; attempt++)); do + if systemctl --user is-active --quiet "$GATEWAY_SERVICE" && + hermes gateway status >/dev/null 2>&1; then + gateway_ready=1 + break + fi + sleep "${GATEWAY_HEALTH_DELAY:-5}" +done + +if ((gateway_ready == 0)); then + notify_user "Hermes-Gateway wurde nach dem Update nicht gesund. Rollback wird ausgeführt." + false +fi + +if ! curl --fail --silent --max-time 10 "$LLAMA_URL/health" | + jq -e '.status == "ok"' >/dev/null; then + notify_user "Hermes und Gateway sind aktualisiert, aber der lokale Modell-Endpunkt ist nicht gesund: $LLAMA_URL. Kein Hermes-Rollback, da llama-server unabhängig ist." +fi + +trap - ERR + +mapfile -t old_backups < <( + find "$backup_dir" -maxdepth 1 -type f -name 'hermes-*.tar.zst' \ + -printf '%T@ %p\n' | sort -nr | tail -n "+$((${BACKUP_KEEP:-3} + 1))" | + cut -d' ' -f2- +) +((${#old_backups[@]} == 0)) || rm -f -- "${old_backups[@]}" + +new_commit="$(git -C "$HERMES_REPO" rev-parse HEAD)" +if [[ "$new_commit" == "$old_commit" ]]; then + notify_user "Hermes-Update wurde nicht installiert (Updater-Exitcode $update_exit); der bisherige Commit $new_commit und der frisch gestartete Gateway sind gesund." + exit 1 +elif ((update_exit != 0)); then + notify_user "Hermes erfolgreich aktualisiert: $old_commit -> $new_commit. Eine Updater-Restartwarnung wurde durch den frischen Gateway-Neustart behoben." +else + notify_user "Hermes erfolgreich aktualisiert: $old_commit -> $new_commit" +fi diff --git a/libexec/hermes-stack-llama-profile b/libexec/hermes-stack-llama-profile new file mode 100644 index 0000000..8ee6ff6 --- /dev/null +++ b/libexec/hermes-stack-llama-profile @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +runner="$(dirname -- "${BASH_SOURCE[0]}")/hermes-stack-llama-run" + +case "${1:-list}" in + list) + printf '%-12s %8s %10s\n' Profil Kontext Fit-MiB \ + baseline 65536 1536 balanced 49152 1536 vram-plus 49152 1280 compact 32768 1536 + exit 0 ;; + baseline|balanced|vram-plus|compact) selected="$1" ;; + *) printf 'Aufruf: %s [list|baseline|balanced|vram-plus|compact]\n' "$0" >&2; exit 2 ;; +esac +[[ $# -eq 1 ]] || exit 2 +# Profilwechsel nur für bewusst migrierte Konfigurationen erlauben. +# shellcheck disable=SC1090 +source "$config_dir/llama.env" +[[ -n "${LLAMA_PROFILE:-}" ]] || { + printf 'Zuerst llama.env nach docs/WORKSTATION.md migrieren. Keine Änderung.\n' >&2; exit 1; +} +"$runner" --profile "$selected" --check +exec 9>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" +flock -n 9 || { printf 'Maintenance läuft. Später erneut versuchen.\n' >&2; exit 1; } +target="$config_dir/llama-profile.env" +if [[ -e "$target" ]]; then + backup="$(mktemp "$config_dir/llama-profile.backup.XXXXXX")" + cp -p -- "$target" "$backup" + printf 'Vorherige Profilwahl: %s\n' "$backup" +fi +candidate="$(mktemp "$config_dir/llama-profile.env.XXXXXX")" +printf 'LLAMA_PROFILE=%q\n' "$selected" >"$candidate" +mv -T -- "$candidate" "$target" +printf 'Profil %s gespeichert. Laufende Jobs bleiben unverändert.\n' "$selected" +printf 'Erst nach Jobende: hermes-stack-llama-restart (siehe docs/WORKSTATION.md).\n' diff --git a/libexec/hermes-stack-llama-report b/libexec/hermes-stack-llama-report new file mode 100644 index 0000000..5ebe8cf --- /dev/null +++ b/libexec/hermes-stack-llama-report @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Nur lesen: kein Modellstart, keine Inferenz, kein Neustart. +set -Euo pipefail +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +if [[ -r "$config_dir/config.env" ]]; then + # shellcheck disable=SC1090 + source "$config_dir/config.env" +fi +printf '\nGPU (Gesamtspeicher, belegt, frei in MiB):\n' +nvidia-smi --query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,power.draw,temperature.gpu \ + --format=csv || true +printf '\nDienstzustand:\n' +systemctl --user show "${LLAMA_SERVICE:-llama-qwen.service}" \ + -p ActiveState -p SubState -p MainPID -p ExecMainStartTimestamp --no-pager || true +printf '\nLetzte Speicher-/Leistungsdaten (keine vollständigen Prompts):\n' +journalctl --user -u "${LLAMA_SERVICE:-llama-qwen.service}" -n 1000 --no-pager | + rg 'offload|buffer size|n_ctx|prompt eval time|eval time|sleeping|waking' | + tail -n 35 || true +printf '\nDas ist eine Momentaufnahme, kein Spitzenlast-Benchmark.\n' diff --git a/libexec/hermes-stack-llama-restart b/libexec/hermes-stack-llama-restart new file mode 100644 index 0000000..e75783e --- /dev/null +++ b/libexec/hermes-stack-llama-restart @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +if [[ "${1:-}" != --jobs-finished || $# -ne 1 ]]; then + printf 'Stoppt laufende Inferenz! Erst nach Jobende ausführen:\n %s --jobs-finished\n' "$0" >&2 + exit 2 +fi +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +# shellcheck disable=SC1090 +source "$config_dir/config.env" +exec 9>"${XDG_RUNTIME_DIR:-/tmp}/hermes-stack-hermes-update.lock" +flock -n 9 || { printf 'Maintenance läuft; kein Neustart.\n' >&2; exit 1; } +"$(dirname -- "${BASH_SOURCE[0]}")/hermes-stack-llama-run" --check +systemctl --user restart "$LLAMA_SERVICE" +printf 'Neustart ausgelöst. Modell kann noch laden; Logs und Inferenz prüfen.\n' diff --git a/libexec/hermes-stack-llama-run b/libexec/hermes-stack-llama-run new file mode 100755 index 0000000..23fe0fd --- /dev/null +++ b/libexec/hermes-stack-llama-run @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance" +CONFIG_FILE="${HERMES_LLAMA_CONFIG:-$config_dir/llama.env}" +LLAMA_ARGS=() +# shellcheck disable=SC1090 +source "$CONFIG_FILE" + +mode=run +profile_override="" +while (($#)); do + case "$1" in + --check) mode=check ;; + --print) mode=print ;; + --profile) [[ $# -ge 2 ]] || exit 2; profile_override="$2"; shift ;; + *) printf 'Aufruf: %s [--check|--print] [--profile NAME]\n' "$0" >&2; exit 2 ;; + esac + shift +done +if [[ -n "${LLAMA_PROFILE:-}" && -r "$config_dir/llama-profile.env" ]]; then + # shellcheck disable=SC1090 + source "$config_dir/llama-profile.env" +fi +[[ -z "$profile_override" ]] || LLAMA_PROFILE="$profile_override" + +if [[ -z "${LLAMA_SERVER_BIN:-}" ]]; then + LLAMA_SERVER_BIN="$HOME/.local/opt/llama.cpp/current/bin/llama-server" + if [[ ! -x "$LLAMA_SERVER_BIN" ]]; then + LLAMA_SERVER_BIN="$(command -v llama-server || true)" + fi +fi +[[ -x "$LLAMA_SERVER_BIN" ]] || { + printf 'llama-server nicht ausführbar: %s\n' "$LLAMA_SERVER_BIN" >&2 + exit 1 +} + +model_args=() +if [[ -n "${LLAMA_MODEL:-}" && -n "${LLAMA_HF_REPO:-}" ]]; then + printf 'Nur LLAMA_MODEL ODER LLAMA_HF_REPO setzen.\n' >&2 + exit 1 +elif [[ -n "${LLAMA_MODEL:-}" ]]; then + [[ -r "$LLAMA_MODEL" ]] || { printf 'Modell nicht lesbar.\n' >&2; exit 1; } + model_args=(--model "$LLAMA_MODEL") +elif [[ -n "${LLAMA_HF_REPO:-}" ]]; then + model_args=(--hf-repo "$LLAMA_HF_REPO") +else + printf 'LLAMA_MODEL oder LLAMA_HF_REPO fehlt.\n' >&2 + exit 1 +fi +if [[ -n "${LLAMA_MMPROJ:-}" ]]; then + [[ -r "$LLAMA_MMPROJ" ]] || { printf 'Projektor nicht lesbar.\n' >&2; exit 1; } + model_args+=(--mmproj "$LLAMA_MMPROJ") +fi +[[ -z "${LLAMA_ALIAS:-}" ]] || model_args+=(--alias "$LLAMA_ALIAS") + +profile_args=() +if [[ -n "${LLAMA_PROFILE:-}" ]]; then + case "$LLAMA_PROFILE" in + baseline) ctx=65536; margin=1536 ;; + balanced) ctx=49152; margin=1536 ;; + vram-plus) ctx=49152; margin=1280 ;; + compact) ctx=32768; margin=1536 ;; + *) printf 'Unbekanntes Profil: %s\n' "$LLAMA_PROFILE" >&2; exit 2 ;; + esac + profile_args=( + --ctx-size "$ctx" --parallel 1 --jinja + --reasoning auto --reasoning-budget 512 --reasoning-preserve + --flash-attn auto --threads 6 --threads-batch 16 + --n-gpu-layers auto --fit on --fit-target "$margin" + --cache-type-k q8_0 --cache-type-v q8_0 + --sleep-idle-seconds 300 --image-min-tokens 1024 + --cors-origins localhost --no-ui + ) + # Alte vollständige LLAMA_ARGS nicht still mit einem Profil vermischen. + if ((${#LLAMA_ARGS[@]})); then + printf 'Bei Profilbetrieb LLAMA_ARGS leeren; eigene vollständige Args nur ohne LLAMA_PROFILE.\n' >&2 + exit 2 + fi +fi + +args=("${model_args[@]}" --host "${LLAMA_HOST:-127.0.0.1}" --port "${LLAMA_PORT:-8080}" + "${profile_args[@]}" "${LLAMA_ARGS[@]}") +if [[ "$mode" == print ]]; then + printf '%q ' "$LLAMA_SERVER_BIN" "${args[@]}" + printf '\n' + exit 0 +fi + +# Kein Download/Modellstart: nur prüfen, ob die installierte Version die Namen kennt. +help_text="$(timeout 30 "$LLAMA_SERVER_BIN" --help 2>&1)" || { + printf 'llama-server --help fehlgeschlagen.\n' >&2; exit 1; +} +for arg in "${args[@]}"; do + [[ "$arg" == --* ]] || continue + option="${arg%%=*}" + if ! grep -Eq -- "(^|[[:space:],])${option}([[:space:],=]|$)" <<<"$help_text"; then + printf 'Die installierte llama.cpp-Version kennt %s nicht. Kein automatisches Weglassen.\n' "$option" >&2 + exit 1 + fi +done +if [[ "$mode" == check ]]; then + printf 'Konfiguration und Optionsnamen OK; GPU/Modell/Optionswerte noch nicht laufzeitgetestet.\n' + exit 0 +fi + +exec "$LLAMA_SERVER_BIN" "${args[@]}" diff --git a/libexec/hermes-stack-notify b/libexec/hermes-stack-notify new file mode 100755 index 0000000..ea2758c --- /dev/null +++ b/libexec/hermes-stack-notify @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -Euo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/config.env" + +log() { + printf '%s\n' "$*" | systemd-cat -t hermes-stack-maintenance 2>/dev/null || true +} + +text="${*:-}" +if [[ -z "$text" ]]; then + printf 'Verwendung: %s TEXT\n' "${0##*/}" >&2 + exit 2 +fi + +log "$text" + +if [[ ! -r "$CONFIG_FILE" ]]; then + log "Benachrichtigung übersprungen: $CONFIG_FILE ist nicht lesbar." + exit 0 +fi + +set +u +# shellcheck disable=SC1090 +source "$CONFIG_FILE" || { + set -u + log "Benachrichtigung übersprungen: Maintenance-Konfiguration ist ungültig." + exit 0 +} + +telegram_notify="${TELEGRAM_NOTIFY:-0}" +signal_notify="${SIGNAL_NOTIFY:-0}" + +if [[ -r "${HERMES_ENV:-}" ]] && + [[ "$telegram_notify" == 1 || "$signal_notify" == 1 ]]; then + set -a + # shellcheck disable=SC1090 + source "$HERMES_ENV" || true + set +a +fi +set -u + +if [[ "$telegram_notify" == 1 && + -n "${TELEGRAM_BOT_TOKEN:-}" && + -n "${TELEGRAM_HOME_CHANNEL:-}" ]]; then + if ! curl --fail --silent --show-error --max-time 15 \ + --request POST \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_HOME_CHANNEL}" \ + --data-urlencode "text=${text}" >/dev/null; then + log "Telegram-Benachrichtigung konnte nicht zugestellt werden." + fi +fi + +if [[ "$signal_notify" == 1 && + -n "${SIGNAL_HTTP_URL:-}" && + -n "${SIGNAL_ACCOUNT:-}" && + -n "${SIGNAL_HOME_CHANNEL:-}" ]]; then + if [[ "$SIGNAL_HOME_CHANNEL" == group:* ]]; then + signal_payload="$( + jq -nc --arg account "$SIGNAL_ACCOUNT" --arg message "$text" \ + --arg group_id "${SIGNAL_HOME_CHANNEL#group:}" \ + '{jsonrpc:"2.0",method:"send",params:{account:$account,message:$message,groupId:$group_id},id:"hermes-maintenance"}' + )" || signal_payload="" + else + signal_payload="$( + jq -nc --arg account "$SIGNAL_ACCOUNT" --arg message "$text" \ + --arg recipient "$SIGNAL_HOME_CHANNEL" \ + '{jsonrpc:"2.0",method:"send",params:{account:$account,message:$message,recipient:[$recipient]},id:"hermes-maintenance"}' + )" || signal_payload="" + fi + + if [[ -z "$signal_payload" ]] || + ! curl --fail --silent --show-error --max-time 30 \ + --header 'Content-Type: application/json' \ + --data-binary "$signal_payload" \ + "${SIGNAL_HTTP_URL%/}/api/v1/rpc" | + jq -e '((.error // null) == null) and (.result != null)' >/dev/null; then + log "Signal-Benachrichtigung konnte nicht zugestellt werden." + fi +fi + +exit 0 diff --git a/libexec/hermes-stack-signal-health b/libexec/hermes-stack-signal-health new file mode 100755 index 0000000..219b27b --- /dev/null +++ b/libexec/hermes-stack-signal-health @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -Euo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/config.env" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hermes-stack-maintenance" +NOTIFIER="$HOME/.local/libexec/hermes-stack-notify" +mkdir -p "$STATE_DIR" +# shellcheck disable=SC1090 +source "$CONFIG_FILE" + +set +u +set -a +# shellcheck disable=SC1090 +source "$HERMES_ENV" || true +set +a +set -u + +state_file="$STATE_DIR/signal-health-state" +signal_url="${SIGNAL_HTTP_URL:-}" +[[ -n "$signal_url" ]] || exit 0 + +check_signal() { + local status + status="$(curl --silent --output /dev/null --max-time 5 \ + --write-out '%{http_code}' "${signal_url%/}/api/v1/check" || true)" + [[ "$status" =~ ^2[0-9][0-9]$ ]] +} + +if check_signal; then + current=healthy +else + systemctl --user restart "$SIGNAL_SERVICE" || true + sleep 5 + check_signal && current=healthy || current=unhealthy +fi + +previous="$(cat "$state_file" 2>/dev/null || true)" +if [[ "$current" == unhealthy && "$previous" != unhealthy ]]; then + "$NOTIFIER" "Hermes-Stack: signal-cli ist nicht erreichbar; Neustart wurde versucht." || true +elif [[ "$current" == healthy && "$previous" == unhealthy ]]; then + "$NOTIFIER" "Hermes-Stack: signal-cli ist wieder erreichbar." || true +fi + +printf '%s\n' "$current" >"$state_file" +[[ "$current" == healthy ]] diff --git a/libexec/hermes-stack-signal-run b/libexec/hermes-stack-signal-run new file mode 100755 index 0000000..82b2922 --- /dev/null +++ b/libexec/hermes-stack-signal-run @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/signal-cli.env" +# shellcheck disable=SC1090 +source "$CONFIG_FILE" + +[[ -x "$SIGNAL_CLI_BIN" ]] || { + printf 'signal-cli nicht ausführbar: %s\n' "$SIGNAL_CLI_BIN" >&2 + exit 1 +} +[[ -n "$SIGNAL_ACCOUNT" ]] || { + printf 'SIGNAL_ACCOUNT ist nicht konfiguriert.\n' >&2 + exit 1 +} + +args=(--account "$SIGNAL_ACCOUNT") +if [[ -n "${SIGNAL_CONFIG_DIR:-}" ]]; then + mkdir -p "$SIGNAL_CONFIG_DIR" + args=(--config "$SIGNAL_CONFIG_DIR" "${args[@]}") +fi + +exec "$SIGNAL_CLI_BIN" "${args[@]}" daemon \ + --http "${SIGNAL_HTTP_BIND:-127.0.0.1:8090}" diff --git a/libexec/hermes-stack-signal-update b/libexec/hermes-stack-signal-update new file mode 100755 index 0000000..75f6ced --- /dev/null +++ b/libexec/hermes-stack-signal-update @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/config.env" +SIGNAL_CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/hermes-stack-maintenance/signal-cli.env" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hermes-stack-maintenance" +NOTIFIER="$HOME/.local/libexec/hermes-stack-notify" + +# shellcheck disable=SC1090 +source "$CONFIG_FILE" +# shellcheck disable=SC1090 +source "$SIGNAL_CONFIG_FILE" + +version="${1:-}" +if [[ -z "$version" ]]; then + latest_url="$(curl -LsS --max-time 30 -o /dev/null -w '%{url_effective}' \ + https://github.com/AsamK/signal-cli/releases/latest)" + version="${latest_url##*/v}" +fi + +[[ "$version" =~ ^[0-9]+(\.[0-9]+)+$ ]] || { + printf 'Ungültige signal-cli-Version: %s\n' "$version" >&2 + exit 1 +} + +old_version="unbekannt" +if [[ -x "$SIGNAL_CLI_BIN" ]]; then + old_version="$("$SIGNAL_CLI_BIN" --version 2>/dev/null | + grep -Eo '[0-9]+(\.[0-9]+)+' | head -n1 || true)" +fi +[[ -n "$old_version" ]] || old_version="unbekannt" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf -- "$tmp_dir"' EXIT +archive="$tmp_dir/signal-cli.tar.gz" +url="https://github.com/AsamK/signal-cli/releases/download/v${version}/signal-cli-${version}.tar.gz" + +curl --fail --location --show-error "$url" -o "$archive" +tar -xzf "$archive" -C "$tmp_dir" +binary="$(find "$tmp_dir" -type f -path '*/bin/signal-cli' -print -quit)" +[[ -n "$binary" ]] || { + printf 'signal-cli-Binary wurde im Archiv nicht gefunden.\n' >&2 + exit 1 +} +source_root="$(dirname "$(dirname "$binary")")" + +timestamp="$(date +%Y%m%d-%H%M%S)" +backup_dir="$STATE_DIR/backups/signal-cli/$timestamp" +mkdir -p "$backup_dir" + +old_target="" +if [[ -e "$SIGNAL_CLI_BIN" || -L "$SIGNAL_CLI_BIN" ]]; then + old_target="$(readlink -f "$SIGNAL_CLI_BIN" 2>/dev/null || true)" + if [[ -n "$old_target" ]]; then + old_root="$(dirname "$(dirname "$old_target")")" + [[ -d "$old_root" ]] && cp -a "$old_root" "$backup_dir/program" + fi +fi + +if [[ -d "${SIGNAL_CONFIG_DIR:-}" ]]; then + tar --zstd -cf "$backup_dir/signal-data.tar.zst" \ + -C "$(dirname "$SIGNAL_CONFIG_DIR")" "$(basename "$SIGNAL_CONFIG_DIR")" +fi + +was_active=0 +systemctl --user is-active --quiet "$SIGNAL_SERVICE" && was_active=1 +((was_active == 0)) || systemctl --user stop "$SIGNAL_SERVICE" + +target="$HOME/.local/opt/signal-cli-$version" +mkdir -p "$target" "$(dirname "$SIGNAL_CLI_BIN")" +cp -a "$source_root/." "$target/" +ln -sfn "$target/bin/signal-cli" "$SIGNAL_CLI_BIN" + +if ((was_active)); then + systemctl --user restart "$SIGNAL_SERVICE" + sleep 5 + + set +u + set -a + # shellcheck disable=SC1090 + source "$HERMES_ENV" || true + set +a + set -u + + signal_http_url="${SIGNAL_HTTP_URL:-}" + status="" + if [[ -n "$signal_http_url" ]]; then + status="$(curl --silent --output /dev/null --max-time 10 \ + --write-out '%{http_code}' "${signal_http_url%/}/api/v1/check" || true)" + fi + if [[ ! "$status" =~ ^2[0-9][0-9]$ ]]; then + systemctl --user stop "$SIGNAL_SERVICE" || true + if [[ -n "$old_target" ]]; then + ln -sfn "$old_target" "$SIGNAL_CLI_BIN" + systemctl --user restart "$SIGNAL_SERVICE" || true + fi + "$NOTIFIER" "signal-cli-Update auf $version fehlgeschlagen; Binary wurde auf $old_version zurückgestellt. Backup: $backup_dir" || true + exit 1 + fi +fi + +mapfile -t old_backups < <( + find "$STATE_DIR/backups/signal-cli" -mindepth 1 -maxdepth 1 -type d \ + -printf '%T@ %p\n' | sort -nr | tail -n +4 | cut -d' ' -f2- +) +((${#old_backups[@]} == 0)) || rm -rf -- "${old_backups[@]}" + +"$NOTIFIER" "signal-cli wurde erfolgreich aktualisiert: $old_version -> $version. Daten- und Binary-Backup: $backup_dir" || true diff --git a/libexec/hermes-stack-signal-update-check b/libexec/hermes-stack-signal-update-check new file mode 100755 index 0000000..d41b29b --- /dev/null +++ b/libexec/hermes-stack-signal-update-check @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Euo pipefail + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/hermes-stack-maintenance" +NOTIFIER="$HOME/.local/libexec/hermes-stack-notify" +mkdir -p "$STATE_DIR" + +command -v signal-cli >/dev/null 2>&1 || exit 0 +installed="$(signal-cli --version 2>/dev/null | grep -Eo '[0-9]+(\.[0-9]+)+' | head -n1)" +latest_url="$(curl -LsS --max-time 30 -o /dev/null -w '%{url_effective}' \ + https://github.com/AsamK/signal-cli/releases/latest || true)" +latest="${latest_url##*/v}" + +[[ "$installed" =~ ^[0-9]+(\.[0-9]+)+$ ]] || exit 1 +[[ "$latest" =~ ^[0-9]+(\.[0-9]+)+$ ]] || exit 1 + +if [[ "$(printf '%s\n%s\n' "$installed" "$latest" | sort -V | tail -n1)" == "$latest" && + "$installed" != "$latest" ]]; then + "$NOTIFIER" "signal-cli-Update verfügbar: $installed -> $latest. Manuell ausführen: ~/.local/libexec/hermes-stack-signal-update $latest" || true +fi diff --git a/scripts/install-llama-cpp.sh b/scripts/install-llama-cpp.sh new file mode 100644 index 0000000..2505b70 --- /dev/null +++ b/scripts/install-llama-cpp.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Baut in einem neuen Verzeichnis. Kein Ersetzen von /usr/bin oder laufenden Builds. +set -Eeuo pipefail +umask 077 +revision="" +jobs=4 +cuda_arch=86 +base_dir="$HOME/.local/opt/llama.cpp" +usage() { + printf 'Aufruf: %s --ref TAG_ODER_COMMIT [--jobs N] [--cuda-arch 86] [--prefix ABSOLUTER_PFAD]\n' "$0" + printf 'Explizit --ref master für aktuellen Stand; aufgelöster Commit wird gespeichert.\n' +} +while (($#)); do + case "$1" in + --ref) [[ $# -ge 2 ]] || exit 2; revision="$2"; shift ;; + --jobs) [[ $# -ge 2 ]] || exit 2; jobs="$2"; shift ;; + --cuda-arch) [[ $# -ge 2 ]] || exit 2; cuda_arch="$2"; shift ;; + --prefix) [[ $# -ge 2 ]] || exit 2; base_dir="$2"; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done +[[ "$revision" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || { usage >&2; exit 2; } +[[ "$jobs" =~ ^[1-9][0-9]*$ && "$cuda_arch" =~ ^[1-9][0-9]*$ ]] || exit 2 +[[ "$base_dir" == /* && "$base_dir" != / && "$base_dir" != "$HOME" ]] || exit 2 +for dependency in git cmake ninja c++ nvidia-smi; do + command -v "$dependency" >/dev/null || { + printf '%s fehlt. Siehe docs/LLAMA_INSTALL.md.\n' "$dependency" >&2; exit 1; + } +done +nvidia-smi >/dev/null +nvcc_path="${CUDACXX:-/opt/cuda/bin/nvcc}" +[[ -x "$nvcc_path" ]] || { printf 'CUDA nvcc fehlt: %s\n' "$nvcc_path" >&2; exit 1; } +mkdir -p "$base_dir" +exec 9>"$base_dir/build.lock" +flock -n 9 || { printf 'Ein CUDA-Build läuft bereits.\n' >&2; exit 1; } +build_dir="$(mktemp -d "$base_dir/build.XXXXXXXX")" +printf 'Neuer Build unter %s (bleibt auch bei Fehler zur Diagnose erhalten).\n' "$build_dir" +git init -q "$build_dir/source" +git -C "$build_dir/source" remote add origin https://github.com/ggml-org/llama.cpp.git +GIT_TERMINAL_PROMPT=0 timeout 300 git -C "$build_dir/source" fetch --depth 1 origin "$revision" +git -C "$build_dir/source" checkout --detach FETCH_HEAD +commit="$(git -C "$build_dir/source" rev-parse HEAD)" +cmake -S "$build_dir/source" -B "$build_dir/build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_CUDA_COMPILER="$nvcc_path" -DCMAKE_CUDA_ARCHITECTURES="$cuda_arch" +cmake --build "$build_dir/build" --parallel "$jobs" --target llama-server llama-cli llama-bench +cp -a -- "$build_dir/build/bin" "$build_dir/bin" +"$build_dir/bin/llama-server" --version +"$build_dir/bin/llama-cli" --version +printf 'upstream=https://github.com/ggml-org/llama.cpp.git\nref=%s\ncommit=%s\ncuda_arch=%s\nbuilt=%s\n' \ + "$revision" "$commit" "$cuda_arch" "$(date --iso-8601=seconds)" >"$build_dir/BUILD-INFO.txt" +if [[ ! -e "$base_dir/current" && ! -L "$base_dir/current" ]]; then + ln -s -- "$build_dir" "$base_dir/current" + printf 'Erster verwalteter Build ausgewählt; noch kein Dienst gestartet.\n' +else + printf 'Bestehender current-Build bleibt unverändert.\n' +fi +printf 'Neue Binary: %s/bin/llama-server\nCommit: %s\n' "$build_dir" "$commit" +printf 'Zur bewussten Auswahl LLAMA_SERVER_BIN in llama.env setzen. Keine automatische Aktualisierung.\n' diff --git a/scripts/install-signal-cli.sh b/scripts/install-signal-cli.sh new file mode 100755 index 0000000..026a022 --- /dev/null +++ b/scripts/install-signal-cli.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +exec "$ROOT_DIR/libexec/hermes-stack-signal-update" "$@" diff --git a/systemd/user/hermes-stack-health.service b/systemd/user/hermes-stack-health.service new file mode 100644 index 0000000..3b2a727 --- /dev/null +++ b/systemd/user/hermes-stack-health.service @@ -0,0 +1,6 @@ +[Unit] +Description=Check and recover Hermes stack + +[Service] +Type=oneshot +ExecStart=%h/.local/libexec/hermes-stack-health diff --git a/systemd/user/hermes-stack-health.timer b/systemd/user/hermes-stack-health.timer new file mode 100644 index 0000000..dbfd3f1 --- /dev/null +++ b/systemd/user/hermes-stack-health.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Periodic Hermes stack health check + +[Timer] +OnBootSec=2min +OnUnitActiveSec=10min +AccuracySec=30s +Unit=hermes-stack-health.service + +[Install] +WantedBy=timers.target diff --git a/systemd/user/hermes-stack-hermes-update.service b/systemd/user/hermes-stack-hermes-update.service new file mode 100644 index 0000000..f2a1ead --- /dev/null +++ b/systemd/user/hermes-stack-hermes-update.service @@ -0,0 +1,9 @@ +[Unit] +Description=Update Hermes Agent with backup and rollback +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=%h/.local/libexec/hermes-stack-hermes-update +TimeoutStartSec=30min diff --git a/systemd/user/hermes-stack-hermes-update.timer b/systemd/user/hermes-stack-hermes-update.timer new file mode 100644 index 0000000..3954092 --- /dev/null +++ b/systemd/user/hermes-stack-hermes-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Weekly Hermes Agent update + +[Timer] +OnCalendar=weekly +Persistent=true +RandomizedDelaySec=2h +Unit=hermes-stack-hermes-update.service + +[Install] +WantedBy=timers.target diff --git a/systemd/user/hermes-stack-signal-health.service b/systemd/user/hermes-stack-signal-health.service new file mode 100644 index 0000000..bfb4c59 --- /dev/null +++ b/systemd/user/hermes-stack-signal-health.service @@ -0,0 +1,6 @@ +[Unit] +Description=Check and recover signal-cli for Hermes + +[Service] +Type=oneshot +ExecStart=%h/.local/libexec/hermes-stack-signal-health diff --git a/systemd/user/hermes-stack-signal-health.timer b/systemd/user/hermes-stack-signal-health.timer new file mode 100644 index 0000000..33dd05a --- /dev/null +++ b/systemd/user/hermes-stack-signal-health.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Periodic signal-cli health check + +[Timer] +OnBootSec=3min +OnUnitActiveSec=10min +AccuracySec=30s +Unit=hermes-stack-signal-health.service + +[Install] +WantedBy=timers.target diff --git a/systemd/user/hermes-stack-signal-update-check.service b/systemd/user/hermes-stack-signal-update-check.service new file mode 100644 index 0000000..69973d1 --- /dev/null +++ b/systemd/user/hermes-stack-signal-update-check.service @@ -0,0 +1,8 @@ +[Unit] +Description=Check for a new signal-cli release +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=%h/.local/libexec/hermes-stack-signal-update-check diff --git a/systemd/user/hermes-stack-signal-update-check.timer b/systemd/user/hermes-stack-signal-update-check.timer new file mode 100644 index 0000000..7919b83 --- /dev/null +++ b/systemd/user/hermes-stack-signal-update-check.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily signal-cli update check + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=1h +Unit=hermes-stack-signal-update-check.service + +[Install] +WantedBy=timers.target diff --git a/systemd/user/llama-qwen.service b/systemd/user/llama-qwen.service new file mode 100644 index 0000000..ba6d873 --- /dev/null +++ b/systemd/user/llama-qwen.service @@ -0,0 +1,13 @@ +[Unit] +Description=Local llama.cpp Qwen model server +After=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/libexec/hermes-stack-llama-run +Restart=on-failure +RestartSec=10 +TimeoutStopSec=60 + +[Install] +WantedBy=default.target diff --git a/systemd/user/signal-cli.service b/systemd/user/signal-cli.service new file mode 100644 index 0000000..858aebb --- /dev/null +++ b/systemd/user/signal-cli.service @@ -0,0 +1,14 @@ +[Unit] +Description=signal-cli daemon for Hermes +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/libexec/hermes-stack-signal-run +Restart=always +RestartSec=10 +TimeoutStopSec=30 + +[Install] +WantedBy=default.target diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..2885cab --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +failures=0 + +for script in "$ROOT_DIR/install.sh" "$ROOT_DIR/uninstall.sh" \ + "$ROOT_DIR"/libexec/* "$ROOT_DIR"/scripts/*.sh "$ROOT_DIR"/config/*.example; do + if bash -n "$script"; then + printf 'OK bash -n %s\n' "${script#"$ROOT_DIR/"}" + else + failures=$((failures + 1)) + fi +done + +if command -v shellcheck >/dev/null 2>&1; then + shellcheck -x "$ROOT_DIR/install.sh" "$ROOT_DIR/uninstall.sh" \ + "$ROOT_DIR"/libexec/* "$ROOT_DIR"/scripts/*.sh || failures=$((failures + 1)) +else + printf 'HINWEIS: shellcheck fehlt; Lint wird nur in entsprechend ausgestatteter CI ausgeführt.\n' +fi + +python3 "$ROOT_DIR/tests/test_llama.py" || failures=$((failures + 1)) + +if rg -n --hidden --glob '!tests/smoke.sh' --glob '!tests/test_llama.py' --glob '!.git/**' \ + '(TELEGRAM_BOT_TOKEN|SIGNAL_ACCOUNT|API_KEY)=("|'"'"')?[A-Za-z0-9+]' "$ROOT_DIR"; then + printf 'Möglicher eingecheckter Secret-Wert gefunden.\n' >&2 + failures=$((failures + 1)) +fi + +for unit in "$ROOT_DIR"/systemd/user/*.{service,timer}; do + [[ -s "$unit" ]] || { + printf 'Leere Unit: %s\n' "$unit" >&2 + failures=$((failures + 1)) + } +done + +((failures == 0)) diff --git a/tests/test_llama.py b/tests/test_llama.py new file mode 100644 index 0000000..c05a90f --- /dev/null +++ b/tests/test_llama.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Isolierte Vertragstests. Kein HOME-Override, GPU, Netzwerk oder echter systemd-Aufruf.""" +import os +import json +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +RUNNER = ROOT / "libexec/hermes-stack-llama-run" +PROFILE = ROOT / "libexec/hermes-stack-llama-profile" +RESTART = ROOT / "libexec/hermes-stack-llama-restart" +OPTIONS = """--model --hf-repo --mmproj --alias --host --port --ctx-size --parallel +--jinja --reasoning --reasoning-budget --reasoning-preserve --flash-attn --threads +--threads-batch --n-gpu-layers --fit --fit-target --cache-type-k --cache-type-v +--sleep-idle-seconds --image-min-tokens --cors-origins --no-ui""" + + +class LlamaTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="hermes-test-", dir=ROOT.parent) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / "config/hermes-stack-maintenance" + self.config.mkdir(parents=True) + self.bin = self.root / "bin" + self.bin.mkdir() + self.record = self.root / "calls" + self.env = dict(os.environ, XDG_CONFIG_HOME=str(self.config.parent), + XDG_STATE_HOME=str(self.root / "state"), + XDG_RUNTIME_DIR=str(self.root), + PATH=str(self.bin) + ":" + os.environ["PATH"], + TEST_RECORD=str(self.record)) + self.server = self.bin / "llama-server" + self.write_server() + self.write_config() + self.program("systemctl", 'printf "%s\\n" "$*" >>"$TEST_RECORD"\nexit 0\n') + self.program("notify-test", "exit 0\n") + + def program(self, name, body): + path = self.bin / name + path.write_text("#!/usr/bin/env bash\nset -eu\n" + body) + path.chmod(0o755) + return path + + def write_server(self, options=OPTIONS): + self.program("llama-server", 'if [[ "${1:-}" == --help ]]; then\n' + f' printf "%s\\n" {shlex.quote(options)}\n exit 0\nfi\n' + 'printf "%s\\n" "$@" >"$TEST_RECORD"\n') + + def write_config(self, extra="", profile="baseline", model="", hf="test/model:Q4_K_M"): + content = (f'LLAMA_SERVER_BIN={shlex.quote(str(self.server))}\n' + f'LLAMA_MODEL={shlex.quote(model)}\nLLAMA_HF_REPO={shlex.quote(hf)}\n' + f'LLAMA_PROFILE={shlex.quote(profile)}\n' + 'LLAMA_ALIAS=qwen38-27b-local\nLLAMA_ARGS=()\n') + (self.config / "llama.env").write_text(content + extra) + (self.config / "config.env").write_text( + "LLAMA_SERVICE=llama-qwen.service\nGATEWAY_SERVICE=hermes-gateway.service\n" + "LLAMA_URL=http://127.0.0.1:8080\n" + f'NOTIFIER={shlex.quote(str(self.bin / "notify-test"))}\n') + + def run_tool(self, script, *args, success=True): + result = subprocess.run(["bash", str(script), *args], env=self.env, + text=True, capture_output=True, timeout=8) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def test_profile_values(self): + for name, ctx, margin in [("baseline", "65536", "1536"), + ("balanced", "49152", "1536"), + ("vram-plus", "49152", "1280"), + ("compact", "32768", "1536")]: + with self.subTest(name=name): + result = self.run_tool(RUNNER, "--profile", name, "--print") + args = shlex.split(result.stdout) + self.assertEqual(args[args.index("--ctx-size") + 1], ctx) + self.assertEqual(args[args.index("--fit-target") + 1], margin) + self.assertEqual(args.count("--ctx-size"), 1) + self.assertIn("qwen38-27b-local", args) + + def test_check_does_not_run_inference(self): + self.run_tool(RUNNER, "--check") + self.assertFalse(self.record.exists()) + + def test_unsupported_option_rejected(self): + self.write_server(OPTIONS.replace("--fit-target", "")) + result = self.run_tool(RUNNER, "--check", success=False) + self.assertIn("--fit-target", result.stderr) + self.assertFalse(self.record.exists()) + + def test_execution_uses_array(self): + self.run_tool(RUNNER) + args = self.record.read_text().splitlines() + self.assertIn("--hf-repo", args) + self.assertIn("test/model:Q4_K_M", args) + + def test_local_legacy_config_and_path_spaces(self): + model = self.root / "a model.gguf" + model.touch() + self.write_config(profile="", model=str(model), hf="", + extra="LLAMA_ARGS=(--ctx-size 8192 --parallel 1)\n") + self.run_tool(RUNNER) + args = self.record.read_text().splitlines() + self.assertIn(str(model), args) + self.assertNotIn("--fit", args) + + def test_model_source_conflict(self): + self.write_config(model="/some/model.gguf") + self.run_tool(RUNNER, "--check", success=False) + + def test_missing_model(self): + self.write_config(model="/does/not/exist.gguf", hf="") + self.run_tool(RUNNER, "--check", success=False) + + def test_missing_model_source(self): + self.write_config(hf="") + self.run_tool(RUNNER, "--check", success=False) + + def test_unknown_profile(self): + self.run_tool(RUNNER, "--profile", "../unknown", "--check", success=False) + + def test_profile_rejects_legacy_args(self): + self.write_config(extra="LLAMA_ARGS=(--ctx-size 64)\n") + self.run_tool(RUNNER, "--check", success=False) + + def test_profile_selection_preserves_config(self): + before = (self.config / "llama.env").read_bytes() + self.run_tool(PROFILE, "balanced") + self.assertEqual((self.config / "llama.env").read_bytes(), before) + self.assertFalse(self.record.exists()) + result = self.run_tool(RUNNER, "--print") + self.assertIn("49152", result.stdout) + self.run_tool(PROFILE, "baseline") + self.assertEqual(len(list(self.config.glob("llama-profile.backup.*"))), 1) + self.assertEqual((self.config / "llama-profile.env").stat().st_mode & 0o777, 0o600) + + def test_profile_does_not_migrate_implicitly(self): + self.write_config(profile="") + self.run_tool(PROFILE, "balanced", success=False) + self.assertFalse((self.config / "llama-profile.env").exists()) + + def test_restart_requires_confirmation(self): + self.run_tool(RESTART, success=False) + self.assertFalse(self.record.exists()) + + def test_restart_after_check(self): + self.run_tool(RESTART, "--jobs-finished") + self.assertEqual(self.record.read_text(), "--user restart llama-qwen.service\n") + + def test_failed_check_does_not_restart(self): + self.write_server("") + self.run_tool(RESTART, "--jobs-finished", success=False) + self.assertFalse(self.record.exists()) + + def test_lock_blocks_restart_and_profile_change(self): + import fcntl + with (self.root / "hermes-stack-hermes-update.lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.run_tool(RESTART, "--jobs-finished", success=False) + self.run_tool(PROFILE, "balanced", success=False) + self.assertFalse(self.record.exists()) + self.assertFalse((self.config / "llama-profile.env").exists()) + + def test_failed_http_does_not_restart_backend(self): + self.program("hermes", "exit 0\n") + self.program("curl", "exit 28\n") + self.run_tool(ROOT / "libexec/hermes-stack-health", success=False) + self.assertNotIn("restart llama", self.record.read_text()) + + def test_installer_requires_explicit_ref(self): + self.run_tool(ROOT / "scripts/install-llama-cpp.sh", success=False) + self.run_tool(ROOT / "install.sh", "--install-llama", success=False) + + def test_installer_help(self): + self.run_tool(ROOT / "scripts/install-llama-cpp.sh", "--help") + self.run_tool(ROOT / "install.sh", "--help") + + def prepare_build_mocks(self): + self.env["CUDACXX"] = str(self.program("nvcc", "exit 0\n")) + self.program("nvidia-smi", "exit 0\n") + self.program("ninja", "exit 0\n") + self.program("c++", "exit 0\n") + self.program("git", 'if [[ "$*" == *rev-parse* ]]; then\n' + 'printf "%s\\n" 0123456789012345678901234567890123456789\nfi\n') + self.program("cmake", 'if [[ "${1:-}" == --build ]]; then\n' + ' [[ "${TEST_BUILD_FAIL:-0}" != 1 ]] || exit 1\n' + ' mkdir -p "$2/bin"\n' + ' for name in llama-server llama-cli llama-bench; do\n' + ' cp "$CUDACXX" "$2/bin/$name"\n' + ' done\nfi\n') + + def test_build_publishes_all_three_tools(self): + self.prepare_build_mocks() + prefix = self.root / "managed" + self.run_tool(ROOT / "scripts/install-llama-cpp.sh", "--ref", "master", + "--prefix", str(prefix)) + for name in ("llama-server", "llama-cli", "llama-bench"): + self.assertTrue((prefix / "current/bin" / name).is_file()) + manifest = (prefix / "current/BUILD-INFO.txt").read_text() + self.assertIn("0123456789012345678901234567890123456789", manifest) + self.assertIn("cuda_arch=86", manifest) + + def test_build_preserves_existing_selection(self): + self.prepare_build_mocks() + prefix = self.root / "managed" + prefix.mkdir() + old = prefix / "old" + old.mkdir() + (prefix / "current").symlink_to(old) + self.run_tool(ROOT / "scripts/install-llama-cpp.sh", "--ref", "master", + "--prefix", str(prefix)) + self.assertEqual((prefix / "current").resolve(), old) + + def test_failed_build_does_not_activate(self): + self.prepare_build_mocks() + self.env["TEST_BUILD_FAIL"] = "1" + prefix = self.root / "managed" + self.run_tool(ROOT / "scripts/install-llama-cpp.sh", "--ref", "master", + "--prefix", str(prefix), success=False) + self.assertFalse((prefix / "current").exists()) + + def signal_fixture(self, target, response): + env_file = self.root / "hermes.env" + env_file.write_text('SIGNAL_HTTP_URL=http://127.0.0.1:8090\n' + 'SIGNAL_ACCOUNT=test-account\n' + f'SIGNAL_HOME_CHANNEL={shlex.quote(target)}\n') + with (self.config / "config.env").open("a") as config: + config.write(f'HERMES_ENV={shlex.quote(str(env_file))}\n' + 'SIGNAL_NOTIFY=1\nTELEGRAM_NOTIFY=0\n') + self.program("systemd-cat", "exit 0\n") + self.program("curl", 'while (($#)); do\n' + ' if [[ "$1" == --data-binary ]]; then\n' + ' printf "%s" "$2" >"$TEST_RECORD"\n fi\n shift\ndone\n' + f'printf "%s\\n" {shlex.quote(response)}\n') + + def test_signal_direct_payload(self): + self.signal_fixture("test-recipient", '{"result":{"timestamp":1}}') + message = 'Test "Anführungszeichen"\nzweite Zeile' + self.run_tool(ROOT / "libexec/hermes-stack-notify", message) + payload = json.loads(self.record.read_text()) + self.assertEqual(payload["params"]["recipient"], ["test-recipient"]) + self.assertEqual(payload["params"]["message"], message) + + def test_signal_group_payload(self): + self.signal_fixture("group:test-group", '{"result":{"timestamp":1}}') + self.run_tool(ROOT / "libexec/hermes-stack-notify", "Test") + payload = json.loads(self.record.read_text()) + self.assertEqual(payload["params"]["groupId"], "test-group") + self.assertNotIn("recipient", payload["params"]) + + def test_signal_rpc_failure_is_nonfatal(self): + self.signal_fixture("test-recipient", '{"error":{"code":-1}}') + self.run_tool(ROOT / "libexec/hermes-stack-notify", "Test") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..6147d9a --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Entfernt ausschließlich die von diesem Repository verwalteten Dateien. +# Hermes-Daten, Modelle, Signal-Sitzung, Konfigurationen und Backups bleiben erhalten. +units=( + hermes-stack-hermes-update.service hermes-stack-hermes-update.timer + hermes-stack-health.service hermes-stack-health.timer + hermes-stack-signal-health.service hermes-stack-signal-health.timer + hermes-stack-signal-update-check.service hermes-stack-signal-update-check.timer + llama-qwen.service signal-cli.service +) + +systemctl --user disable --now \ + hermes-stack-hermes-update.timer \ + hermes-stack-health.timer \ + hermes-stack-signal-health.timer \ + hermes-stack-signal-update-check.timer 2>/dev/null || true + +for unit in "${units[@]}"; do + rm -f -- "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$unit" +done + +for file in \ + hermes-stack-notify hermes-stack-hermes-update hermes-stack-health \ + hermes-stack-signal-health hermes-stack-signal-update-check hermes-stack-signal-update \ + hermes-stack-llama-run hermes-stack-llama-profile hermes-stack-llama-restart \ + hermes-stack-llama-report \ + hermes-stack-signal-run hermes-stack-doctor; do + rm -f -- "$HOME/.local/libexec/$file" +done + +systemctl --user daemon-reload +printf 'Deployment-Dateien entfernt; Benutzerdaten und Konfigurationen wurden beibehalten.\n'