Update auf Skills
This commit is contained in:
@@ -62,3 +62,71 @@ JARVIS_DEBUG_HTTP_STATE=false
|
||||
# Docker v7.6: whisper-cli is built into the image at /usr/local/bin/whisper-cli.
|
||||
# The compose.yaml sets absolute container paths automatically. The values above
|
||||
# are mainly useful for native/non-Docker runs.
|
||||
# Pluginfähige Skill Engine
|
||||
JARVIS_SKILLS_DIR=./skills
|
||||
JARVIS_SKILLS_ENABLED=true
|
||||
# Trusted lokale Prozess-Skills ausführen. Entry-Point standardmäßig nur aus dem Skill-Ordner.
|
||||
JARVIS_SKILL_PROCESS_ENABLED=true
|
||||
# Systemweite Commands/Interpreter nur aktivieren, wenn ein vertrauenswürdiger Skill sie wirklich benötigt.
|
||||
JARVIS_SKILL_ALLOW_SYSTEM_EXEC=false
|
||||
JARVIS_SKILL_MAX_TIMEOUT_MS=30000
|
||||
JARVIS_SKILL_MAX_OUTPUT_KB=1024
|
||||
|
||||
|
||||
# v9 Distributed Skill Mesh
|
||||
JARVIS_MESH_ENABLED=true
|
||||
# Shared bootstrap/enrollment secret. Set a long random value in Docker deployments.
|
||||
JARVIS_MESH_ENROLLMENT_TOKEN=change-this-enrollment-token
|
||||
JARVIS_MESH_LEASE_SECONDS=30
|
||||
JARVIS_MESH_INVOKE_TIMEOUT_MS=30000
|
||||
|
||||
# Master-side Docker controller. docker.sock grants powerful host Docker access;
|
||||
# controller operations are hard-limited to containers with this exact label/value.
|
||||
JARVIS_DOCKER_CONTROLLER_ENABLED=false
|
||||
JARVIS_DOCKER_SOCKET=/var/run/docker.sock
|
||||
JARVIS_DOCKER_SKILL_LABEL=com.jarvis.skill-service
|
||||
JARVIS_DOCKER_SKILL_LABEL_VALUE=true
|
||||
|
||||
# Remote worker defaults (set inside worker containers)
|
||||
JARVIS_MASTER_URL=http://jarvis:8080
|
||||
JARVIS_ENROLLMENT_TOKEN=change-this-enrollment-token
|
||||
JARVIS_WORKER_ADDR=:8090
|
||||
JARVIS_WORKER_PUBLIC_URL=http://skill-python:8090
|
||||
JARVIS_WORKER_NAME=python-main
|
||||
JARVIS_WORKER_RUNTIME=python
|
||||
JARVIS_WORKER_RUNTIME_VERSION=3.13
|
||||
JARVIS_WORKER_ALLOW_SYSTEM_EXEC=true
|
||||
JARVIS_WORKER_MAX_OUTPUT_KB=1024
|
||||
|
||||
|
||||
# v9.1 Home Control Skill Pack
|
||||
# Philips Hue local CLIP v2
|
||||
HUE_BRIDGE_URL=https://192.168.1.20
|
||||
HUE_APP_KEY=
|
||||
HUE_VERIFY_TLS=false
|
||||
|
||||
# UniFi local integration APIs. Point directly at the integration base URLs.
|
||||
UNIFI_NETWORK_URL=https://192.168.1.1/proxy/network/integration
|
||||
UNIFI_PROTECT_URL=https://192.168.1.1/proxy/protect/integration
|
||||
UNIFI_API_KEY=
|
||||
UNIFI_VERIFY_TLS=false
|
||||
|
||||
# Proxmox VE API token. Example token ID: jarvis@pve!home-control
|
||||
PROXMOX_BASE_URL=https://192.168.1.10:8006
|
||||
PROXMOX_TOKEN_ID=
|
||||
PROXMOX_TOKEN_SECRET=
|
||||
PROXMOX_VERIFY_TLS=false
|
||||
|
||||
# Dockge / Compose stack control. Mutations are denied unless explicitly listed.
|
||||
DOCKGE_STACKS_DIR=/opt/stacks
|
||||
DOCKGE_ALLOWED_STACKS=
|
||||
|
||||
# Network helpers
|
||||
WOL_BROADCAST=255.255.255.255
|
||||
WOL_PORT=9
|
||||
|
||||
# Optional ntfy notifications
|
||||
NTFY_BASE_URL=https://ntfy.sh
|
||||
NTFY_TOKEN=
|
||||
NTFY_DEFAULT_TOPIC=
|
||||
NTFY_VERIFY_TLS=true
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: latest
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with: # replace it with your local IP
|
||||
config-inline: |
|
||||
[registry."git.send.nrw"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.send.nrw # replace it with your local IP
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - Python Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.python
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-python:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-python:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - Node Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.node
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-node:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-node:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - Go Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.golang
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-golang:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-golang:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - Rust Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.rust
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-rust:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-rust:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - C Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.c
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-c:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-c:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - C++ Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.cpp
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-cpp:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-cpp:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push - C# Worker
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./workers/docker/Dockerfile.csharp
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-csharp:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-worker-csharp:${{ env.DOCKER_LATEST }}
|
||||
+3
-1
@@ -29,7 +29,9 @@ WORKDIR /app
|
||||
COPY --from=build /out/jarvis /app/jarvis
|
||||
COPY --from=whisper-build /out-whisper-cli /usr/local/bin/whisper-cli
|
||||
COPY scripts /app/scripts
|
||||
COPY skills /app/skills
|
||||
RUN chmod 0755 /usr/local/bin/whisper-cli /app/scripts/*.sh \
|
||||
&& mkdir -p /app/data /models /tools
|
||||
&& find /app/skills -maxdepth 2 -type f -name run -exec chmod 0755 {} + \
|
||||
&& mkdir -p /app/data /app/skills /models /tools
|
||||
EXPOSE 8080
|
||||
CMD ["/app/jarvis"]
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# JARVIS v9.1 – Home Control Skill Pack
|
||||
|
||||
Dieses Paket erweitert das v9 Skill Mesh um sieben praktische Home-/Infrastructure-Integrationen. Die Integrationen laufen standardmäßig jeweils in einem eigenen Worker-Container und registrieren ihre Actions dynamisch am JARVIS Master.
|
||||
|
||||
## Enthaltene Skills
|
||||
|
||||
| Skill | Wichtige Actions | Worker |
|
||||
|---|---|---|
|
||||
| Philips Hue | `list_lights`, `list_rooms`, `list_scenes`, `set_light`, `set_room`, `activate_scene` | Python |
|
||||
| UniFi Network | `info`, `list_sites`, `list_devices`, `list_clients`, `restart_device`, `power_cycle_port`, Gastzugang | Python |
|
||||
| UniFi Protect | `info`, `list_cameras`, `get_camera`, `list_sensors`, `list_lights`, PTZ, Alarm-Webhook | Python |
|
||||
| Proxmox VE | `version`, `list_nodes`, `list_guests`, `guest_status`, Start/Shutdown/Reboot/Snapshot | Python |
|
||||
| Dockge Stack Control | `list_stacks`, `stack_status`, Start/Stop/Restart/Update | eigener Python+Docker Worker |
|
||||
| Network Tools | TCP-/HTTP-Check, DNS, Wake-on-LAN | Python |
|
||||
| ntfy | `send` | Python |
|
||||
|
||||
Die Tool-Namen im Agenten werden automatisch aus Skill-ID und Action erzeugt, zum Beispiel:
|
||||
|
||||
```text
|
||||
skill_philips_hue_set_room
|
||||
skill_unifi_network_list_clients
|
||||
skill_unifi_protect_list_cameras
|
||||
skill_proxmox_ve_shutdown_guest
|
||||
skill_dockge_compose_restart_stack
|
||||
skill_home_network_tools_wake_on_lan
|
||||
skill_notify_ntfy_send
|
||||
```
|
||||
|
||||
## Secret-/ENV-Isolation
|
||||
|
||||
v9.1 ergänzt `runtime.env_from` im Skill-Manifest. Ein Worker gibt **nicht** seine gesamte Environment an Skill-Prozesse weiter. Nur explizit im Manifest genannte Variablen werden vererbt.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": ["main.py"],
|
||||
"env_from": ["HUE_BRIDGE_URL", "HUE_APP_KEY"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Damit bleiben Enrollment-Tokens und andere JARVIS-Control-Secrets vom Skill-Prozess getrennt.
|
||||
|
||||
## Start
|
||||
|
||||
1. `.env.example` nach `.env` kopieren und die Integrationen konfigurieren.
|
||||
2. Master starten:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build jarvis ollama
|
||||
```
|
||||
|
||||
3. Home-Control-Worker starten:
|
||||
|
||||
```bash
|
||||
docker compose --profile home-skills up -d --build
|
||||
```
|
||||
|
||||
Nicht konfigurierte Integrationen registrieren sich zwar, liefern bei einem Aufruf aber einen klaren `CONFIG_MISSING`/Integrationsfehler. Alternativ kannst du einzelne Services gezielt starten.
|
||||
|
||||
## Philips Hue
|
||||
|
||||
```env
|
||||
HUE_BRIDGE_URL=https://192.168.1.20
|
||||
HUE_APP_KEY=...
|
||||
HUE_VERIFY_TLS=false
|
||||
```
|
||||
|
||||
Der Skill verwendet die lokale Hue CLIP API v2. Für normale Licht-/Szenenänderungen ist keine zusätzliche JARVIS-Bestätigung erforderlich.
|
||||
|
||||
Beispiele:
|
||||
|
||||
```text
|
||||
Schalte im Wohnzimmer das Licht aus.
|
||||
Setze die Wohnzimmerbeleuchtung auf 35 Prozent.
|
||||
Aktiviere die Szene Entspannen.
|
||||
Welche Hue-Lampen sind noch an?
|
||||
```
|
||||
|
||||
## UniFi Network
|
||||
|
||||
```env
|
||||
UNIFI_NETWORK_URL=https://192.168.1.1/proxy/network/integration
|
||||
UNIFI_API_KEY=...
|
||||
UNIFI_VERIFY_TLS=false
|
||||
```
|
||||
|
||||
Die Integration nutzt die offizielle lokale UniFi-Network-Integration-API. Netzwerkänderungen wie Geräte-Neustart, PoE Power-Cycle und Gast-Autorisierung sind als mutierend markiert und erfordern eine Bestätigung.
|
||||
|
||||
Beispiele:
|
||||
|
||||
```text
|
||||
Welche Geräte sind im UniFi-Netzwerk offline?
|
||||
Ist mein Handy gerade im WLAN?
|
||||
Starte Access Point Büro neu.
|
||||
Führe auf Port 12 des Switches einen PoE Power-Cycle aus.
|
||||
```
|
||||
|
||||
## UniFi Protect
|
||||
|
||||
```env
|
||||
UNIFI_PROTECT_URL=https://192.168.1.1/proxy/protect/integration
|
||||
UNIFI_API_KEY=...
|
||||
UNIFI_VERIFY_TLS=false
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
|
||||
```text
|
||||
Welche Protect-Kameras sind offline?
|
||||
Zeige mir den Status der Kamera Einfahrt.
|
||||
Fahre die PTZ-Kamera auf Preset 2.
|
||||
Starte Patrouille 1 auf der Hofkamera.
|
||||
```
|
||||
|
||||
Ein Alarm-Manager-Webhook erfordert eine zusätzliche Bestätigung.
|
||||
|
||||
## Proxmox VE
|
||||
|
||||
Empfohlen wird ein eigener API-Token mit möglichst kleinen Rechten.
|
||||
|
||||
```env
|
||||
PROXMOX_BASE_URL=https://192.168.1.10:8006
|
||||
PROXMOX_TOKEN_ID=jarvis@pve!home-control
|
||||
PROXMOX_TOKEN_SECRET=...
|
||||
PROXMOX_VERIFY_TLS=false
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
|
||||
```text
|
||||
Welche VMs laufen auf Proxmox?
|
||||
Wie viel RAM nutzt VM 104?
|
||||
Starte den Container 203 auf pve01.
|
||||
Fahre VM 104 sauber herunter.
|
||||
Erstelle vor dem Update einen Snapshot von VM 104.
|
||||
```
|
||||
|
||||
Alle Power-/Snapshot-Aktionen verlangen eine Bestätigung.
|
||||
|
||||
## Dockge / Docker Compose
|
||||
|
||||
Der offizielle Dockge-Upstream besitzt derzeit keine stabile, dokumentierte Management-REST-API. Deshalb arbeitet dieser Skill bewusst auf derselben Compose-Stack-Struktur wie Dockge und verwendet Docker Compose direkt.
|
||||
|
||||
```env
|
||||
DOCKGE_STACKS_DIR=/opt/stacks
|
||||
# Nur diese Stacks dürfen durch JARVIS verändert werden. Leer = nur Lesen.
|
||||
DOCKGE_ALLOWED_STACKS=homepage,immich,adguard
|
||||
```
|
||||
|
||||
Der Worker mountet das Stack-Verzeichnis unter **demselben Pfad** in den Container, damit relative Compose-Bind-Mounts nicht versehentlich auf andere Host-Pfade zeigen.
|
||||
|
||||
Zusätzlich besitzt **nur dieser spezielle Worker** Zugriff auf `/var/run/docker.sock`. Dieser Socket bedeutet effektiv weitreichende Docker-Host-Rechte. Mutationen sind deshalb doppelt geschützt: `DOCKGE_ALLOWED_STACKS` plus JARVIS-Bestätigung.
|
||||
|
||||
Beispiele:
|
||||
|
||||
```text
|
||||
Welche Dockge-Stacks existieren?
|
||||
Ist Immich vollständig online?
|
||||
Starte den Stack homepage neu.
|
||||
Aktualisiere den Stack adguard.
|
||||
```
|
||||
|
||||
## Network Tools
|
||||
|
||||
```env
|
||||
WOL_BROADCAST=255.255.255.255
|
||||
WOL_PORT=9
|
||||
```
|
||||
|
||||
Beispiele:
|
||||
|
||||
```text
|
||||
Ist 192.168.1.50 Port 443 erreichbar?
|
||||
Prüfe https://proxmox.lan.
|
||||
Welche IP hat nas.lan?
|
||||
Wecke meinen Gaming-PC mit MAC 00:11:22:33:44:55.
|
||||
```
|
||||
|
||||
## ntfy
|
||||
|
||||
```env
|
||||
NTFY_BASE_URL=https://ntfy.example.lan
|
||||
NTFY_TOKEN=...
|
||||
NTFY_DEFAULT_TOPIC=jarvis
|
||||
NTFY_VERIFY_TLS=true
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
Wenn der Proxmox-Node offline ist, schick mir über ntfy eine Warnung.
|
||||
```
|
||||
|
||||
Das kann der Workflow-Agent aus `proxmox.ve` bzw. `home.network-tools` + `notify.ntfy` zusammensetzen.
|
||||
|
||||
## Beispiele für Skill-Kombinationen
|
||||
|
||||
```text
|
||||
"Wenn mein Handy nicht mehr im UniFi-Netz ist, schalte alle Hue-Lichter aus."
|
||||
|
||||
unifi.network/list_clients
|
||||
↓
|
||||
Agent bewertet Anwesenheit
|
||||
↓
|
||||
philips.hue/list_rooms
|
||||
↓
|
||||
philips.hue/set_room × N
|
||||
```
|
||||
|
||||
```text
|
||||
"Prüfe ob Immich erreichbar ist. Falls nicht, starte den Dockge-Stack neu und schick mir eine Nachricht."
|
||||
|
||||
home.network-tools/http_check
|
||||
↓ nicht erreichbar
|
||||
|
||||
dockge.compose/restart_stack
|
||||
↓
|
||||
notify.ntfy/send
|
||||
```
|
||||
|
||||
```text
|
||||
"Vor dem Neustart der VM einen Snapshot machen."
|
||||
|
||||
proxmox.ve/guest_status
|
||||
↓
|
||||
proxmox.ve/snapshot_guest
|
||||
↓
|
||||
proxmox.ve/reboot_guest
|
||||
```
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
- Secrets bleiben in Worker-ENVs und werden nur per `env_from` an den jeweiligen Skill weitergegeben.
|
||||
- Der Master bekommt keine Hue-/UniFi-/Proxmox-Zugangsdaten.
|
||||
- Mutierende Infrastruktur-Aktionen können `requires_confirmation=true` erzwingen.
|
||||
- Der Dockge-Worker ist wegen `docker.sock` ein bewusst separater Hochprivileg-Worker.
|
||||
- Für noch stärkere Docker-Isolation sollte später ein Docker-Socket-Proxy mit enger API-Allowlist zwischen Worker und Engine gesetzt werden.
|
||||
@@ -1,4 +1,237 @@
|
||||
# JARVIS Home Command v6 – Native Tool Agent
|
||||
# JARVIS Home Command v8 – Skill Engine
|
||||
|
||||
JARVIS besitzt jetzt eine **pluginfähige Skill Engine**. Die bisherigen festen Home-Tools sind weiterhin vorhanden, werden aber als **Core Skills** in eine gemeinsame Registry eingehängt. Zusätzliche Fähigkeiten können ohne Änderung am Go-Core unter `skills/<modul>/skill.json` installiert und zur Laufzeit neu geladen werden.
|
||||
|
||||
## Neue Architektur
|
||||
|
||||
```text
|
||||
Voice / Text
|
||||
│
|
||||
▼
|
||||
JARVIS Agent / Workflow Planner
|
||||
│
|
||||
▼
|
||||
Skill Registry
|
||||
│
|
||||
├── Core Skills (Go)
|
||||
│ ├── calendar.*
|
||||
│ ├── tasks.*
|
||||
│ ├── recipes.*
|
||||
│ └── ...
|
||||
│
|
||||
└── Plugin Skills (jarvis.skill.v1)
|
||||
├── skill.json
|
||||
├── Input JSON Schema
|
||||
├── Output JSON Schema
|
||||
└── beliebiges Executable / Script / Binary
|
||||
│
|
||||
▼
|
||||
Schema Validation
|
||||
│
|
||||
▼
|
||||
Skill Execution
|
||||
│
|
||||
▼
|
||||
standardisiertes JSON Result
|
||||
```
|
||||
|
||||
Der Agent kann Core- und Plugin-Skills in demselben Workflow kombinieren. Ein Plugin-Skill erscheint für Ollama technisch als natives Function Tool; für den Rest des Systems ist er aber ein versioniertes Skill-Modul mit klarer Modulgrenze.
|
||||
|
||||
## `jarvis.skill.v1`
|
||||
|
||||
Ein Modul kann mehrere Actions exportieren:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "weather.local",
|
||||
"name": "Local Weather",
|
||||
"version": "1.0.0",
|
||||
"description": "Liefert lokale Wetterdaten.",
|
||||
"enabled": true,
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "./weather",
|
||||
"timeout_ms": 5000
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": false
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "forecast",
|
||||
"description": "Liefert eine Wettervorhersage für einen Ort.",
|
||||
"triggers": ["wetter", "vorhersage"],
|
||||
"mutates": false,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"location": {"type": "string"}
|
||||
},
|
||||
"required": ["location"]
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"summary": {"type": "string"}
|
||||
},
|
||||
"required": ["summary"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Prozess-Input
|
||||
|
||||
JARVIS startet den Prozess mit dem Skill-Verzeichnis als Working Directory und sendet genau ein JSON-Envelope auf stdin:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"request_id": "skill_...",
|
||||
"skill_id": "weather.local",
|
||||
"action": "forecast",
|
||||
"input": {"location": "Berlin"},
|
||||
"context": {
|
||||
"trace_id": "http_...",
|
||||
"now": "2026-08-29T10:20:00+02:00",
|
||||
"timezone": "Europe/Berlin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prozess-Output
|
||||
|
||||
stdout muss genau eine JSON-Antwort enthalten. Debug-/Logausgaben gehören auf stderr.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"success": true,
|
||||
"data": {"summary": "..."},
|
||||
"message": "Wetterdaten geladen.",
|
||||
"warnings": [],
|
||||
"mutated": false
|
||||
}
|
||||
```
|
||||
|
||||
Fehler werden ebenfalls strukturiert zurückgegeben:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "UPSTREAM_UNAVAILABLE",
|
||||
"message": "Wetterdienst nicht erreichbar"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Input wird **vor dem Prozessstart** validiert; `data` wird anschließend gegen `output_schema` validiert. Ein Skill mit ungültigem JSON oder falschem Schema kann damit nicht unkontrolliert in den Agent-Loop einspeisen.
|
||||
|
||||
## Skill-Kombinationen
|
||||
|
||||
Der bestehende Workflow-Agent arbeitet jetzt auf der Skill Registry. Zum Beispiel kann eine zukünftige Anfrage wie
|
||||
|
||||
```text
|
||||
Prüfe das Wetter für Samstag und verschiebe den Grilltermin auf Sonntag,
|
||||
wenn Regen gemeldet ist.
|
||||
```
|
||||
|
||||
zu einem Ablauf werden wie:
|
||||
|
||||
```text
|
||||
skill_weather_local_forecast
|
||||
↓
|
||||
calendar_list
|
||||
↓
|
||||
calendar_update
|
||||
```
|
||||
|
||||
Die KI entscheidet über Auswahl und Reihenfolge; **jeder einzelne Skill-Call wird separat gegen sein JSON-Schema geprüft**.
|
||||
|
||||
## Mutationen und Bestätigungen
|
||||
|
||||
Plugin-Actions können deklarieren:
|
||||
|
||||
```json
|
||||
{
|
||||
"mutates": true,
|
||||
"requires_confirmation": true
|
||||
}
|
||||
```
|
||||
|
||||
Bei `requires_confirmation=true` führt JARVIS den Skill nicht sofort aus. Stattdessen wird der exakte Skill-Name inklusive Arguments als serverseitige Pending Action gespeichert. Ein anschließendes `ja` führt genau diesen vorgemerkten Call aus — ohne erneute Interpretation durch das Modell.
|
||||
|
||||
Auch für Plugin-Skills gilt weiterhin: **`success=false` kann niemals zu einer serverseitigen Erfolgsmeldung werden.**
|
||||
|
||||
## Sicherheit der Process Runtime
|
||||
|
||||
Process-Skills sind für **lokale, vertrauenswürdige Plugins** gedacht. Der Runner setzt derzeit:
|
||||
|
||||
- Skill-Verzeichnis als Working Directory
|
||||
- separates Timeout pro Aufruf
|
||||
- Output-Größenlimit
|
||||
- minimale Environment-Liste statt Vererbung der kompletten JARVIS-Umgebung
|
||||
- kein systemweiter Entry-Point ohne zusätzliche Freigabe
|
||||
- Input-/Output-Schema-Validierung
|
||||
- Debug-Trace für Request, Runtime und Result
|
||||
|
||||
`JARVIS_SKILL_ALLOW_SYSTEM_EXEC=false` verhindert standardmäßig, dass ein Manifest direkt `/bin/...`, `python3`, `node` usw. als Entry-Point auswählt. Plugin-eigene Executables unter dem Skill-Verzeichnis funktionieren weiterhin.
|
||||
|
||||
**Wichtig:** Ein nativer Prozess ist keine harte Sicherheits-Sandbox gegen absichtlich bösartigen Code. Der Docker-Container ist eine äußere Grenze, aber ein vertrauenswürdiges Plugin kann innerhalb seiner OS-Rechte handeln. `permissions.network` ist in v8 eine deklarative Capability für Transparenz und zukünftige Sandbox-Runtimes; sie blockiert Netzwerkzugriffe der Process-Runtime noch nicht. `permissions.system_exec` wird dagegen für den direkten Manifest-Entry-Point erzwungen. Die Registry ist bewusst so gebaut, dass später weitere Runtime-Typen wie WASM oder separate Container ergänzt werden können.
|
||||
|
||||
## Installation und Reload
|
||||
|
||||
Skills liegen standardmäßig unter:
|
||||
|
||||
```text
|
||||
./skills
|
||||
```
|
||||
|
||||
Docker mountet sie nach:
|
||||
|
||||
```text
|
||||
/app/skills
|
||||
```
|
||||
|
||||
Nach dem Hinzufügen oder Ändern eines Skills muss JARVIS nicht neu kompiliert werden. Entweder im UI **RELOAD** verwenden oder:
|
||||
|
||||
```text
|
||||
POST /api/skills/reload
|
||||
```
|
||||
|
||||
Status:
|
||||
|
||||
```text
|
||||
GET /api/skills
|
||||
```
|
||||
|
||||
Der Debug-Export enthält ebenfalls die geladene Skill Registry.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
```env
|
||||
JARVIS_SKILLS_DIR=./skills
|
||||
JARVIS_SKILLS_ENABLED=true
|
||||
JARVIS_SKILL_PROCESS_ENABLED=true
|
||||
JARVIS_SKILL_ALLOW_SYSTEM_EXEC=false
|
||||
JARVIS_SKILL_MAX_TIMEOUT_MS=30000
|
||||
JARVIS_SKILL_MAX_OUTPUT_KB=1024
|
||||
```
|
||||
|
||||
Unter `skills/_template/` liegt ein kopierbarer Modulframe. `skills/demo-text/` ist ein kleines ausführbares Beispiel. Ordner mit führendem `_` werden vom Loader ignoriert.
|
||||
|
||||
---
|
||||
|
||||
## Bestehender Home-Core
|
||||
|
||||
Die folgenden Abschnitte beschreiben die weiterhin vorhandenen Core-Funktionen. Technisch werden sie in v8 als Core Skills registriert; ihre bisherigen Function-Namen bleiben aus Kompatibilitätsgründen unverändert.
|
||||
|
||||
Lokales Home-Dashboard in Go mit Ollama, Voice, RAG, Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepten, Essensplanung und KI-Kanban.
|
||||
|
||||
@@ -539,3 +772,202 @@ Im HUD wird bei nicht bereitem STT jetzt unterschieden zwischen:
|
||||
Die API liefert zusätzlich die aufgelösten Binary-/Modellpfade und konkrete Diagnosefehler unter `system.voice`. Auch `docker compose logs jarvis` schreibt beim Start den Voice-Status mit den einzelnen Komponenten.
|
||||
|
||||
> Hinweis: Wenn du von einer älteren Version aktualisierst, reicht `docker compose up -d` möglicherweise nicht, weil Docker das alte Image weiterverwendet. Für den ersten Start von v7.6 daher einmal `docker compose build --no-cache jarvis` ausführen.
|
||||
# v9 – Distributed Skill Mesh
|
||||
|
||||
v9 verschiebt Plugin-Code aus dem JARVIS-Master in eigenständige Runtime-Worker. Der Agent sieht weiterhin **eine** Skill Registry, aber eine Action kann jetzt von drei Providern stammen:
|
||||
|
||||
```text
|
||||
core -> deterministische Go-Domain-Services im Master
|
||||
plugin -> lokaler Prozess-Skill (native Entwicklung; in Docker standardmäßig aus)
|
||||
remote -> HTTP Skill Worker in eigenem Container/Host
|
||||
```
|
||||
|
||||
## Protokolle
|
||||
|
||||
- `jarvis.skill.v1` – Skill-/Action-Definition inklusive Input-/Output-Schema
|
||||
- `jarvis.skill.worker.v1` – Enrollment, Registry, Heartbeat und Deregistration
|
||||
- `jarvis.skill.invoke.v1` – Remote-Ausführung und Resultat
|
||||
|
||||
## Worker-Lifecycle
|
||||
|
||||
```text
|
||||
Worker startet
|
||||
-> POST /api/mesh/enroll
|
||||
-> Master prüft Enrollment Token
|
||||
<- worker_id + zufälliges Session Access Token + Lease
|
||||
-> PUT /api/mesh/workers/{id}/skills
|
||||
-> periodisch POST /api/mesh/workers/{id}/heartbeat
|
||||
-> Master nimmt registrierte Actions in die Skill Registry auf
|
||||
-> Master ruft POST http://worker/v1/invoke
|
||||
-> Worker validiert Input, führt Skill aus, validiert Output
|
||||
-> bei sauberem Shutdown DELETE /api/mesh/workers/{id}
|
||||
```
|
||||
|
||||
Bleibt ein Heartbeat länger als die Lease aus, entfernt der Master den Worker und seine Actions automatisch aus der aktiven Registry. Mehrere Worker dürfen denselben Skill/Action-Namen als Replik bereitstellen; der Master verteilt Aufrufe round-robin und probiert bei einem Transportfehler den nächsten Provider.
|
||||
|
||||
## Runtime-Worker Images
|
||||
|
||||
Unter `workers/docker/` liegen fertige Dockerfiles für:
|
||||
|
||||
- `Dockerfile.python` – Python 3.13
|
||||
- `Dockerfile.node` – Node.js 22
|
||||
- `Dockerfile.golang` – Go 1.23 Toolchain
|
||||
- `Dockerfile.rust` – Rust Toolchain
|
||||
- `Dockerfile.c` – GCC / C
|
||||
- `Dockerfile.cpp` – G++ / CMake
|
||||
- `Dockerfile.csharp` – .NET SDK 8 / C#
|
||||
|
||||
Alle Images verwenden denselben kleinen Go-basierten `jarvis-skill-worker`. Die jeweilige Sprache/Toolchain ist nur die Laufzeit für den Skill-Code.
|
||||
|
||||
## Docker Compose starten
|
||||
|
||||
Zuerst einen eigenen Enrollment Token setzen, z. B. in `.env`:
|
||||
|
||||
```env
|
||||
JARVIS_MESH_ENROLLMENT_TOKEN=bitte-einen-langen-zufaelligen-wert-verwenden
|
||||
```
|
||||
|
||||
Master/Ollama:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build jarvis ollama
|
||||
```
|
||||
|
||||
Alle Runtime-Worker:
|
||||
|
||||
```bash
|
||||
docker compose --profile skill-workers up -d --build
|
||||
```
|
||||
|
||||
Oder nur Python:
|
||||
|
||||
```bash
|
||||
docker compose --profile skill-workers up -d --build skill-python
|
||||
```
|
||||
|
||||
Das mitgelieferte Python-Beispiel liegt unter `skills/python/example-text` und registriert `skill_example_python_text_uppercase`.
|
||||
|
||||
## Skill-Verteilung
|
||||
|
||||
Jeder Worker bekommt sein eigenes `/skills`-Volume:
|
||||
|
||||
```text
|
||||
skills/
|
||||
python/
|
||||
mein-python-skill/
|
||||
skill.json
|
||||
main.py
|
||||
node/
|
||||
go/
|
||||
rust/
|
||||
c/
|
||||
cpp/
|
||||
csharp/
|
||||
```
|
||||
|
||||
Der Worker scannt nur seine Runtime-Gruppe. `POST /api/mesh/workers/{id}/reload` fordert einen laufenden Worker auf, `/skills` neu einzulesen und seine Registry erneut beim Master zu veröffentlichen.
|
||||
|
||||
## Docker Controller über docker.sock
|
||||
|
||||
Der Master kann optional den Docker Engine Unix Socket verwenden:
|
||||
|
||||
```yaml
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
Konfiguration:
|
||||
|
||||
```env
|
||||
JARVIS_DOCKER_CONTROLLER_ENABLED=true
|
||||
JARVIS_DOCKER_SOCKET=/var/run/docker.sock
|
||||
JARVIS_DOCKER_SKILL_LABEL=com.jarvis.skill-service
|
||||
JARVIS_DOCKER_SKILL_LABEL_VALUE=true
|
||||
```
|
||||
|
||||
**Wichtig:** Zugriff auf `docker.sock` entspricht praktisch administrativem Docker-/Host-Zugriff. Deshalb implementiert der JARVIS-Controller bewusst nur `list`, `start`, `stop` und `restart`. Vor jeder Mutation listet er die Docker-Container neu und akzeptiert ausschließlich eine **exakte vollständige Container-ID**, die aktuell das konfigurierte Label mit dem exakten Wert besitzt. Er bietet keine allgemeinen `exec`, `create`, `remove`, Volume- oder Image-Operationen an.
|
||||
|
||||
Compose versieht die Skill-Worker mit:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: python-main
|
||||
```
|
||||
|
||||
API:
|
||||
|
||||
```text
|
||||
GET /api/docker/skill-services
|
||||
POST /api/docker/skill-services/{full-container-id}/start
|
||||
POST /api/docker/skill-services/{full-container-id}/stop
|
||||
POST /api/docker/skill-services/{full-container-id}/restart
|
||||
```
|
||||
|
||||
Im Screen **Wissen & System** werden sowohl enrolled Remote Worker als auch gelabelte Docker Skill Services angezeigt. Dort können Worker-Skills neu geladen und Container gestartet/gestoppt/neugestartet werden.
|
||||
|
||||
## Mesh API
|
||||
|
||||
```text
|
||||
POST /api/mesh/enroll
|
||||
GET /api/mesh/workers
|
||||
PUT /api/mesh/workers/{id}/skills
|
||||
POST /api/mesh/workers/{id}/heartbeat
|
||||
POST /api/mesh/workers/{id}/reload
|
||||
DELETE /api/mesh/workers/{id}
|
||||
```
|
||||
|
||||
Die Enrollment-Anfrage enthält den Bootstrap-Token. Der Master gibt danach pro Worker ein kryptografisch zufälliges Session Access Token aus. Dieses Token authentifiziert Worker -> Master Heartbeats/Registry und Master -> Worker Invoke/Reload. Enrollment-/Access-Token werden vom Debug-Trace als Secrets redigiert.
|
||||
|
||||
## Remote Invoke
|
||||
|
||||
Der Master sendet an den Worker:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.invoke.v1",
|
||||
"request_id": "remote_...",
|
||||
"skill_id": "example.python.text",
|
||||
"action": "uppercase",
|
||||
"input": {"text": "Hallo"},
|
||||
"context": {
|
||||
"trace_id": "http_...",
|
||||
"now": "2026-08-29T10:20:00+02:00",
|
||||
"timezone": "Europe/Berlin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Die gleiche `trace_id` läuft damit vom User-Request über Agent/Workflow und Master bis in den Remote-Worker.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
Remote Skill Worker sind eine deutlich stärkere Prozessgrenze als Code im Master-Container, aber Container sind nicht automatisch eine perfekte Sandbox. Für untrusted Skills weiterhin sinnvoll:
|
||||
|
||||
- keine unnötigen Host-Volumes
|
||||
- kein `docker.sock` in Worker-Containern
|
||||
- keine `--privileged` Worker
|
||||
- minimale Netzwerkfreigaben
|
||||
- Ressourcenlimits in Compose/Orchestrator
|
||||
- Secrets nur gezielt pro Worker injizieren
|
||||
|
||||
Der **Master** bekommt den Docker-Socket nur für den expliziten Service-Controller. Die Runtime-Worker bekommen ihn nicht.
|
||||
|
||||
### 1 Container = 1 Skill ist ebenfalls möglich
|
||||
|
||||
Der generische Worker kann sowohl mehrere Unterordner unter `/skills` als auch einen direkt auf `/skills/skill.json` gemounteten Einzel-Skill laden. Damit kann ein besonders sensibler Skill einen vollständig eigenen Container bekommen, während normale Skills gemeinsam einen Runtime-Worker nutzen.
|
||||
|
||||
Wenn der Master die Worker später über den Docker-Controller starten soll, müssen die Container bereits existieren. Dafür z. B. einmal:
|
||||
|
||||
```bash
|
||||
docker compose --profile skill-workers build
|
||||
docker compose --profile skill-workers create
|
||||
```
|
||||
|
||||
Anschließend erkennt `GET /api/docker/skill-services` auch die gestoppten, gelabelten Container und die UI kann sie starten. Der Controller erstellt oder entfernt absichtlich keine Container.
|
||||
|
||||
## v9.1 – Home Control Skill Pack
|
||||
|
||||
v9.1 ergänzt das Skill Mesh um dedizierte Remote-Integrationen für Philips Hue, UniFi Network, UniFi Protect, Proxmox VE, Dockge/Compose, Netzwerkdiagnose/Wake-on-LAN und ntfy. Außerdem unterstützt das Skill-Protokoll jetzt `runtime.env_from`, sodass Secrets aus Worker-ENVs nur explizit an den jeweiligen Skill-Prozess weitergegeben werden.
|
||||
|
||||
Details, ENV-Beispiele und Workflow-Ideen stehen in [`HOME-CONTROL-SKILLS.md`](HOME-CONTROL-SKILLS.md).
|
||||
|
||||
+34
-13
@@ -43,7 +43,7 @@ type agentPlan struct {
|
||||
}
|
||||
|
||||
func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error) {
|
||||
registry := a.tools
|
||||
registry := a.skills
|
||||
if registry == nil {
|
||||
return agentOutcome{}, fmt.Errorf("Tool-Registry nicht initialisiert")
|
||||
}
|
||||
@@ -141,12 +141,20 @@ func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
if a.skills.RequiresConfirmation(name) {
|
||||
label := a.skills.ActionLabel(name)
|
||||
a.setPendingToolAction(name, args, label)
|
||||
a.trace(ctx, "skills", "confirmation_pending", "output", map[string]any{"tool": name, "arguments": args, "label": label})
|
||||
out.Reply = fmt.Sprintf("Soll ich den Skill „%s“ mit diesen Parametern ausführen?", label)
|
||||
out.Mode = "skill-confirmation"
|
||||
return out, nil
|
||||
}
|
||||
a.trace(ctx, "agent", "tool_call", "input", map[string]any{"step": step + 1, "tool": name, "arguments": args})
|
||||
toolStarted := time.Now()
|
||||
res := registry.Execute(ctx, name, args)
|
||||
a.traceTimed(ctx, "agent", "tool_result", "output", res, toolStarted)
|
||||
out.Results = append(out.Results, res)
|
||||
if !res.Success && isMutationTool(name) {
|
||||
if !res.Success && a.skills.IsMutation(name) {
|
||||
writeFailed = true
|
||||
}
|
||||
if res.Mutated && res.Success {
|
||||
@@ -172,11 +180,11 @@ func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error
|
||||
}
|
||||
|
||||
func (a *app) agentSystemPrompt() string {
|
||||
return `Du bist JARVIS, der lokale Agent eines Home-Dashboards. Du hast ausschließlich über die bereitgestellten Tools Zugriff auf Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepte, Essensplan, Kanban und Dokumentwissen.
|
||||
return `Du bist JARVIS, der lokale Agent eines Home-Dashboards. Deine Fähigkeiten werden dynamisch als Skills bereitgestellt. Jeder Skill erscheint technisch als Function/Tool mit festem JSON-Schema. Core-Skills verwalten Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepte, Essensplan, Kanban und Dokumentwissen; Plugin-Skills können zusätzliche Fähigkeiten bereitstellen.
|
||||
|
||||
REGELN:
|
||||
1. Für JEDE Aussage über den aktuellen Home-State zuerst ein passendes Lese-Tool benutzen. Home-State niemals aus Gesprächserinnerung erfinden.
|
||||
2. Für JEDE gewünschte Änderung ein Schreib-Tool benutzen. Behaupte niemals eine Änderung ohne success=true aus einem Tool-Resultat.
|
||||
1. Für JEDE Aussage über den aktuellen Home-State zuerst einen passenden Lese-Skill benutzen. Home-State niemals aus Gesprächserinnerung erfinden.
|
||||
2. Für JEDE gewünschte Änderung einen mutierenden Skill benutzen. Behaupte niemals eine Änderung ohne success=true aus einem Tool-Resultat.
|
||||
3. Bei update/delete und unklarer ID zuerst das passende list/search Tool verwenden, danach die ID aus dessen Ergebnis.
|
||||
4. Relative Zeiten NICHT selbst in Kalenderdaten umrechnen. Übergib sie symbolisch im when-Objekt:
|
||||
- heute => {"kind":"relative","offset_days":0}
|
||||
@@ -192,12 +200,12 @@ REGELN:
|
||||
8. Für generierte Essenspläne gilt RECIPE-FIRST: Lies zuerst mit recipes_list das Rezeptbuch. Plane bevorzugt und standardmäßig vorhandene Rezepte und verwende deren recipe_id in mealplan_create. Bei zu wenigen Rezepten darfst du vorhandene Rezepte wiederholen. Freie erfundene Gerichtstitel nur, wenn das Rezeptbuch leer ist oder der Nutzer ausdrücklich ein nicht vorhandenes Gericht verlangt.
|
||||
9. Wenn ein konkretes bekanntes Rezept gemeint ist, nutze recipes_search/recipes_get und verwende dessen recipe ID. Zum Bearbeiten eines Rezepts nutze recipes_update; bei unbekannter ID zuerst recipes_search oder recipes_list.
|
||||
10. Für "was muss ich für den Essensplan einkaufen?", "Einkaufsliste aus Essensplan" o. ä. nutze groceries_sync_mealplan. Dieses Tool aggregiert ausschließlich gepflegte Rezeptzutaten; erfinde keine Zutaten. Danach kannst du groceries_list verwenden, wenn nötig.
|
||||
11. Wenn ein Tool einen Fehler oder mehrere Kandidaten liefert, korrigiere die Arguments oder frage den Nutzer knapp nach der fehlenden Information. Keine freie Vermutung.
|
||||
11. Wenn ein Skill einen Fehler oder mehrere Kandidaten liefert, korrigiere die Arguments oder frage den Nutzer knapp nach der fehlenden Information. Keine freie Vermutung.
|
||||
12. RAG/Dokumentwissen ausschließlich über rag_search. Quellen nur nennen, wenn sie im Tool-Ergebnis vorkommen.
|
||||
13. Eine Nutzeranfrage kann MEHRERE Aufgaben enthalten. Zerlege komplexe Ziele selbstständig in Unteraufgaben und führe mehrere Tool-Aufrufe aus, wenn das Ziel dies erfordert. Beispiel: "Essensplan für die ganze Woche, nur Abendessen" bedeutet mehrere mealplan_create-Aufrufe, typischerweise einen pro Tag.
|
||||
14. Wenn der Nutzer ausdrücklich "plane", "erstelle", "generiere", "mach mir" o. ä. sagt und Inhalte nicht einzeln vorgibt, ist die Auswahl sinnvoller Vorschläge an dich delegiert. Frage NICHT nach jedem einzelnen Inhalt. Erfinde jedoch keine harten persönlichen Fakten wie Allergien oder Personenzahl.
|
||||
15. "ganze Woche" ohne weitere Präzisierung bedeutet für Planungsaufgaben die nächsten 7 Kalendertage ab heute. Verwende dafür symbolische relative Zeiten offset_days 0..6. "nächste Woche" bedeutet Montag bis Sonntag der kommenden Kalenderwoche; dafür darfst du system_get_time verwenden und anschließend Wochentage symbolisch planen.
|
||||
16. Nutze so wenige Tool-Aufrufe wie nötig, aber so viele wie fachlich erforderlich. Nach erfolgreicher Ausführung antworte kurz auf Deutsch. Die tatsächliche Commit-Bestätigung kann vom Server deterministisch überschrieben werden.`
|
||||
16. Skills dürfen miteinander kombiniert werden. Nutze so wenige Skill-Aufrufe wie nötig, aber so viele wie fachlich erforderlich. Nach erfolgreicher Ausführung antworte kurz auf Deutsch. Die tatsächliche Commit-Bestätigung kann vom Server deterministisch überschrieben werden.`
|
||||
}
|
||||
|
||||
func (a *app) agentSessionHint() string {
|
||||
@@ -226,6 +234,9 @@ func entityID(res toolResult) string {
|
||||
}
|
||||
|
||||
func toolResultMessage(res toolResult) string {
|
||||
if strings.TrimSpace(res.Message) != "" {
|
||||
return strings.TrimSpace(res.Message)
|
||||
}
|
||||
if !res.Success {
|
||||
if res.Error != nil {
|
||||
return res.Error.Message
|
||||
@@ -257,6 +268,9 @@ func (a *app) requiresHomeTool(msg string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if a.skills != nil && a.skills.MayApply(msg) {
|
||||
return true
|
||||
}
|
||||
// A terse correction immediately after a mutation belongs to the tool session.
|
||||
a.recentMu.Lock()
|
||||
recent := a.recent != nil && time.Since(a.recent.At) <= 15*time.Minute
|
||||
@@ -265,11 +279,11 @@ func (a *app) requiresHomeTool(msg string) bool {
|
||||
}
|
||||
|
||||
func (a *app) selectToolPlanJSON(ctx context.Context, msg string, history []ollama.Turn) (agentPlan, []ollama.ToolCall, bool, error) {
|
||||
defs := a.tools.Definitions()
|
||||
defs := a.skills.Definitions()
|
||||
catalog, _ := json.Marshal(defs)
|
||||
st := a.db.Snapshot()
|
||||
planningContext := a.compactPlanningContext(st)
|
||||
system := `Du bist der JSON-Workflow-Planner von JARVIS. Zerlege die Nutzeranfrage in 1 bis 24 konkrete ausführbare Unteraufgaben. Jede ausführbare Unteraufgabe muss exakt ein registriertes Tool verwenden.
|
||||
system := `Du bist der JSON-Workflow-Planner von JARVIS. Zerlege die Nutzeranfrage in 1 bis 24 konkrete ausführbare Unteraufgaben. Jede ausführbare Unteraufgabe muss exakt eine registrierte Skill-Aktion verwenden. Die Skill-Aktionen erscheinen im Katalog technisch als Function/Tool-Namen.
|
||||
|
||||
Antworte ausschließlich als JSON:
|
||||
{"goal":"kurzes Ziel","steps":[{"id":"s1","parent":"optional","task":"kurze Beschreibung","tool":"exakter_tool_name","arguments":{...}}],"confidence":0.0,"clarification":"optional"}
|
||||
@@ -286,7 +300,7 @@ REGELN:
|
||||
- Update/Delete mit unbekannter ID zuerst als list/search-Schritt planen. Wenn ein Folgeschritt zwingend eine erst zur Laufzeit unbekannte ID benötigt, plane nur den Lese-Schritt; der native Agent kann danach fortsetzen.
|
||||
- Keine Tools außerhalb des Katalogs. Maximal 24 steps.
|
||||
- Nur bei wirklich fehlenden harten Angaben, die nicht sinnvoll delegiert wurden, clarification setzen und steps leer lassen.`
|
||||
prompt := fmt.Sprintf("TOOL-KATALOG:\n%s\n\nPLANUNGS-KONTEXT:\n%s\n\nSESSION:\n%s\n\nNUTZER:\n%s", string(catalog), planningContext, a.agentSessionHint(), msg)
|
||||
prompt := fmt.Sprintf("SKILL-AKTIONS-KATALOG:\n%s\n\nPLANUNGS-KONTEXT:\n%s\n\nSESSION:\n%s\n\nNUTZER:\n%s", string(catalog), planningContext, a.agentSessionHint(), msg)
|
||||
a.trace(ctx, "agent", "workflow_plan_request", "input", map[string]any{"message": msg, "catalog_tools": toolNames(defs), "history": history, "planning_context": planningContext, "prompt_estimated_tokens": estimateTokens(prompt)})
|
||||
started := time.Now()
|
||||
raw, err := a.ai.ChatWithHistory(ctx, system, history, prompt, true)
|
||||
@@ -321,7 +335,7 @@ REGELN:
|
||||
if step.Arguments == nil {
|
||||
step.Arguments = map[string]any{}
|
||||
}
|
||||
if step.Tool == "" || !a.tools.Has(step.Tool) {
|
||||
if step.Tool == "" || !a.skills.Has(step.Tool) {
|
||||
continue
|
||||
}
|
||||
validSteps = append(validSteps, step)
|
||||
@@ -611,6 +625,10 @@ func (a *app) toolCommitReply(results []toolResult) string {
|
||||
if !res.Success || !res.Mutated {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(res.Message) != "" {
|
||||
lines = append(lines, strings.TrimSpace(res.Message))
|
||||
continue
|
||||
}
|
||||
switch res.Tool {
|
||||
case "calendar_create", "calendar_update":
|
||||
if v, ok := decodeToolData[store.CalendarEvent](res.Data); ok {
|
||||
@@ -707,10 +725,13 @@ func (a *app) toolReadReply(results []toolResult) string {
|
||||
if last.Error != nil {
|
||||
return last.Error.Message
|
||||
}
|
||||
return "Tool-Anfrage fehlgeschlagen."
|
||||
return "Skill-Anfrage fehlgeschlagen."
|
||||
}
|
||||
if strings.TrimSpace(last.Message) != "" {
|
||||
return strings.TrimSpace(last.Message)
|
||||
}
|
||||
b, _ := json.Marshal(last.Data)
|
||||
return "Tool-Ergebnis: " + string(b)
|
||||
return "Skill-Ergebnis: " + string(b)
|
||||
}
|
||||
|
||||
func decodeToolData[T any](v any) (T, bool) {
|
||||
|
||||
@@ -180,11 +180,14 @@ func (a *app) handleDebugExport(w http.ResponseWriter, r *http.Request) {
|
||||
ms := a.ai.Status()
|
||||
runtimeState := a.debugRuntimeState()
|
||||
manifest := map[string]any{
|
||||
"schema": "jarvis.debug.export.v2", "generated_at": time.Now().In(a.timezone).Format(time.RFC3339), "timezone": a.timezone.String(),
|
||||
"schema": "jarvis.debug.export.v3", "generated_at": time.Now().In(a.timezone).Format(time.RFC3339), "timezone": a.timezone.String(),
|
||||
"events": len(events), "ollama": ms, "context": a.context, "debug": a.debug.Config(), "rag_chunks": a.rag.Count(),
|
||||
"state_summary": map[string]any{"events": len(st.Events), "tasks": len(st.Tasks), "reminders": len(st.Reminders), "groceries": len(st.Groceries), "recipes": len(st.Recipes), "meal_plans": len(st.MealPlans), "kanban": len(st.Kanban), "documents": len(st.Documents), "chat_turns": len(st.Chat)},
|
||||
"runtime": runtimeState,
|
||||
"agent": map[string]any{"mode": "native-tools", "max_steps": a.context.AgentMaxSteps, "tools": toolNames(a.tools.Definitions())},
|
||||
"agent": map[string]any{"mode": "skill-mesh-agent", "max_steps": a.context.AgentMaxSteps, "tools": toolNames(a.skills.Definitions())},
|
||||
"skills": a.skills.Status(),
|
||||
"mesh": a.mesh.Status(),
|
||||
"docker_skills": a.docker.Status(r.Context()),
|
||||
"home_state": debugStateSnapshot(st),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type dockerContainerSummary struct {
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
|
||||
type dockerSkillService struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type dockerControllerStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Available bool `json:"available"`
|
||||
Socket string `json:"socket"`
|
||||
LabelKey string `json:"label_key"`
|
||||
LabelValue string `json:"label_value"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Services []dockerSkillService `json:"services"`
|
||||
}
|
||||
|
||||
type dockerSkillController struct {
|
||||
app *app
|
||||
enabled bool
|
||||
socket string
|
||||
labelKey string
|
||||
labelValue string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newDockerSkillController(a *app) *dockerSkillController {
|
||||
socket := env("JARVIS_DOCKER_SOCKET", "/var/run/docker.sock")
|
||||
tr := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "unix", socket)
|
||||
}}
|
||||
return &dockerSkillController{
|
||||
app: a,
|
||||
enabled: envBool("JARVIS_DOCKER_CONTROLLER_ENABLED", false),
|
||||
socket: socket,
|
||||
labelKey: strings.TrimSpace(env("JARVIS_DOCKER_SKILL_LABEL", "com.jarvis.skill-service")),
|
||||
labelValue: strings.TrimSpace(env("JARVIS_DOCKER_SKILL_LABEL_VALUE", "true")),
|
||||
client: &http.Client{Transport: tr, Timeout: 8 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dockerSkillController) Status(ctx context.Context) dockerControllerStatus {
|
||||
st := dockerControllerStatus{Enabled: d.enabled, Socket: d.socket, LabelKey: d.labelKey, LabelValue: d.labelValue}
|
||||
if d == nil || !d.enabled {
|
||||
return st
|
||||
}
|
||||
list, err := d.list(ctx)
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return st
|
||||
}
|
||||
st.Available = true
|
||||
st.Services = list
|
||||
return st
|
||||
}
|
||||
|
||||
func (d *dockerSkillController) list(ctx context.Context) ([]dockerSkillService, error) {
|
||||
if d == nil || !d.enabled {
|
||||
return nil, errors.New("Docker-Controller deaktiviert")
|
||||
}
|
||||
filters := map[string][]string{"label": {d.labelKey + "=" + d.labelValue}}
|
||||
fb, _ := json.Marshal(filters)
|
||||
endpoint := "http://docker/containers/json?all=1&filters=" + url.QueryEscape(string(fb))
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker.sock nicht erreichbar: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Docker API HTTP %d: %s", resp.StatusCode, clipSkillString(string(raw), 800))
|
||||
}
|
||||
var cs []dockerContainerSummary
|
||||
if err := json.Unmarshal(raw, &cs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dockerSkillService, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
if c.Labels[d.labelKey] != d.labelValue {
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
if len(c.Names) > 0 {
|
||||
name = strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
out = append(out, dockerSkillService{ID: c.ID, Name: name, Image: c.Image, State: c.State, Status: c.Status, Runtime: c.Labels["com.jarvis.skill-runtime"], WorkerName: c.Labels["com.jarvis.worker-name"], Labels: map[string]string{d.labelKey: c.Labels[d.labelKey], "com.jarvis.skill-runtime": c.Labels["com.jarvis.skill-runtime"], "com.jarvis.worker-name": c.Labels["com.jarvis.worker-name"]}})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *dockerSkillController) control(ctx context.Context, idv, action string) error {
|
||||
if d == nil || !d.enabled {
|
||||
return errors.New("Docker-Controller deaktiviert")
|
||||
}
|
||||
services, err := d.list(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var selected *dockerSkillService
|
||||
for i := range services {
|
||||
if services[i].ID == idv {
|
||||
selected = &services[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return errors.New("Container ist nicht für JARVIS Skill Control freigegeben")
|
||||
}
|
||||
var path string
|
||||
switch action {
|
||||
case "start":
|
||||
path = "/containers/" + url.PathEscape(idv) + "/start"
|
||||
case "stop":
|
||||
path = "/containers/" + url.PathEscape(idv) + "/stop?t=10"
|
||||
case "restart":
|
||||
path = "/containers/" + url.PathEscape(idv) + "/restart?t=10"
|
||||
default:
|
||||
return errors.New("unbekannte Docker-Aktion")
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://docker"+path, bytes.NewReader(nil))
|
||||
started := time.Now()
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Docker API HTTP %d: %s", resp.StatusCode, clipSkillString(strings.TrimSpace(string(raw)), 600))
|
||||
}
|
||||
if d.app != nil {
|
||||
d.app.traceTimed(ctx, "docker", "skill_service_control", "output", map[string]any{"container_id": idv, "container": selected.Name, "action": action}, started)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dockerSkillController) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, d.Status(r.Context()))
|
||||
}
|
||||
func (d *dockerSkillController) handleControl(w http.ResponseWriter, r *http.Request) {
|
||||
action := r.PathValue("action")
|
||||
idv := r.PathValue("id")
|
||||
if err := d.control(r.Context(), idv, action); err != nil {
|
||||
writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "action": action, "id": idv})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDockerControllerOnlyControlsLabelledContainers(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "docker.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
controlled := false
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /containers/json", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]dockerContainerSummary{
|
||||
{
|
||||
ID: "allowed123",
|
||||
Names: []string{"/skill-python"},
|
||||
Image: "worker",
|
||||
State: "exited",
|
||||
Status: "Exited",
|
||||
Labels: map[string]string{
|
||||
"com.jarvis.skill-service": "true",
|
||||
"com.jarvis.skill-runtime": "python",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "other999",
|
||||
Names: []string{"/db"},
|
||||
Image: "db",
|
||||
State: "running",
|
||||
Labels: map[string]string{"other": "true"},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /containers/allowed123/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
controlled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(ln)
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("JARVIS_DOCKER_CONTROLLER_ENABLED", "true")
|
||||
t.Setenv("JARVIS_DOCKER_SOCKET", sock)
|
||||
t.Setenv("JARVIS_DOCKER_SKILL_LABEL", "com.jarvis.skill-service")
|
||||
t.Setenv("JARVIS_DOCKER_SKILL_LABEL_VALUE", "true")
|
||||
|
||||
d := newDockerSkillController(nil)
|
||||
st := d.Status(context.Background())
|
||||
if !st.Available || len(st.Services) != 1 || st.Services[0].ID != "allowed123" {
|
||||
t.Fatalf("bad status: %+v", st)
|
||||
}
|
||||
if err := d.control(context.Background(), "other999", "start"); err == nil {
|
||||
t.Fatalf("unlabelled container was controllable")
|
||||
}
|
||||
if err := d.control(context.Background(), "allowed123", "start"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !controlled {
|
||||
t.Fatalf("start not called")
|
||||
}
|
||||
}
|
||||
+45
-6
@@ -46,6 +46,9 @@ type app struct {
|
||||
recentMu sync.Mutex
|
||||
recent *recentMutation
|
||||
tools *toolRegistry
|
||||
skills *skillRegistry
|
||||
mesh *skillMesh
|
||||
docker *dockerSkillController
|
||||
debug *debugtrace.Logger
|
||||
}
|
||||
|
||||
@@ -181,6 +184,16 @@ func main() {
|
||||
},
|
||||
}
|
||||
a.tools = newToolRegistry(a)
|
||||
a.mesh = newSkillMesh(a)
|
||||
a.skills = newSkillRegistry(a, a.tools, env("JARVIS_SKILLS_DIR", "./skills"))
|
||||
a.docker = newDockerSkillController(a)
|
||||
a.mesh.Start()
|
||||
meshStatus := a.mesh.Status()
|
||||
log.Printf("Skill Mesh: enabled=%v enrollment=%v lease=%ds", meshStatus.Enabled, meshStatus.EnrollmentReady, meshStatus.LeaseSeconds)
|
||||
dockerStatus := a.docker.Status(context.Background())
|
||||
if dockerStatus.Enabled {
|
||||
log.Printf("Docker Skill Controller: available=%v socket=%s label=%s=%s error=%s", dockerStatus.Available, dockerStatus.Socket, dockerStatus.LabelKey, dockerStatus.LabelValue, dockerStatus.Error)
|
||||
}
|
||||
voiceStatus := a.voice.Status(wakeWord)
|
||||
log.Printf("Voice erkannt: stt=%v tts=%v ffmpeg=%v whisperBin=%v whisperModel=%v piperBin=%v piperModel=%v", voiceStatus.STTReady, voiceStatus.TTSReady, voiceStatus.FFmpegReady, voiceStatus.WhisperBinReady, voiceStatus.WhisperModelReady, voiceStatus.PiperBinReady, voiceStatus.PiperModelReady)
|
||||
if !voiceStatus.STTReady {
|
||||
@@ -219,6 +232,16 @@ func main() {
|
||||
mux.HandleFunc("POST /api/voice/speak", a.handleSpeak)
|
||||
mux.HandleFunc("GET /api/health", a.handleHealth)
|
||||
mux.HandleFunc("GET /api/agent/tools", a.handleAgentTools)
|
||||
mux.HandleFunc("GET /api/skills", a.handleSkills)
|
||||
mux.HandleFunc("POST /api/skills/reload", a.handleSkillsReload)
|
||||
mux.HandleFunc("POST /api/mesh/enroll", a.mesh.handleEnroll)
|
||||
mux.HandleFunc("GET /api/mesh/workers", a.mesh.handleWorkers)
|
||||
mux.HandleFunc("PUT /api/mesh/workers/{id}/skills", a.mesh.handleRegister)
|
||||
mux.HandleFunc("POST /api/mesh/workers/{id}/heartbeat", a.mesh.handleHeartbeat)
|
||||
mux.HandleFunc("POST /api/mesh/workers/{id}/reload", a.mesh.handleReloadWorker)
|
||||
mux.HandleFunc("DELETE /api/mesh/workers/{id}", a.mesh.handleDeregister)
|
||||
mux.HandleFunc("GET /api/docker/skill-services", a.docker.handleList)
|
||||
mux.HandleFunc("POST /api/docker/skill-services/{id}/{action}", a.docker.handleControl)
|
||||
mux.HandleFunc("GET /api/debug/export", a.handleDebugExport)
|
||||
mux.HandleFunc("GET /api/debug/export.jsonl", a.handleDebugExportJSONL)
|
||||
mux.HandleFunc("DELETE /api/debug", a.handleDebugClear)
|
||||
@@ -257,23 +280,39 @@ func (a *app) handleState(w http.ResponseWriter, r *http.Request) {
|
||||
st := a.db.Snapshot()
|
||||
ms := a.ai.Status()
|
||||
writeJSON(w, 200, systemState{State: st, System: map[string]any{
|
||||
"ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "native-tools", "tools": toolNames(a.tools.Definitions()), "maxSteps": a.context.AgentMaxSteps},
|
||||
"ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "skill-mesh-agent", "tools": toolNames(a.skills.Definitions()), "maxSteps": a.context.AgentMaxSteps}, "skills": a.skills.Status(), "mesh": a.mesh.Status(), "dockerSkills": a.docker.Status(r.Context()),
|
||||
}})
|
||||
}
|
||||
|
||||
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ms := a.ai.Status()
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "native-tools", "tools": toolNames(a.tools.Definitions()), "maxSteps": a.context.AgentMaxSteps}})
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "skill-mesh-agent", "tools": toolNames(a.skills.Definitions()), "maxSteps": a.context.AgentMaxSteps}, "skills": a.skills.Status(), "mesh": a.mesh.Status(), "dockerSkills": a.docker.Status(r.Context())})
|
||||
}
|
||||
|
||||
func (a *app) handleAgentTools(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"mode": "native-tools",
|
||||
"mode": "skill-mesh-agent",
|
||||
"maxSteps": a.context.AgentMaxSteps,
|
||||
"tools": a.tools.Definitions(),
|
||||
"tools": a.skills.Definitions(),
|
||||
"skills": a.skills.Status(),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *app) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, a.skills.Status())
|
||||
}
|
||||
|
||||
func (a *app) handleSkillsReload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
err := a.skills.Reload(ctx)
|
||||
status := a.skills.Status()
|
||||
if err != nil {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "warning": err.Error(), "skills": status})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "skills": status})
|
||||
}
|
||||
|
||||
func (a *app) handleTaskUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
var v store.Task
|
||||
if !decodeJSON(w, r, &v) {
|
||||
@@ -513,7 +552,7 @@ func (a *app) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Nachricht fehlt", 400)
|
||||
return
|
||||
}
|
||||
a.trace(baseCtx, "command", "user_input", "input", map[string]any{"message": req.Message, "timezone": a.timezone.String(), "architecture": "native-tool-agent"})
|
||||
a.trace(baseCtx, "command", "user_input", "input", map[string]any{"message": req.Message, "timezone": a.timezone.String(), "architecture": "skill-agent"})
|
||||
a.trace(baseCtx, "state", "before_command", "input", debugStateSnapshot(a.db.Snapshot()))
|
||||
|
||||
// One household command is processed atomically at the orchestration level.
|
||||
@@ -704,7 +743,7 @@ func (a *app) runScout(parent context.Context, reason string) (int, error) {
|
||||
}
|
||||
}
|
||||
a.trace(ctx, "scout", "tool_call", "input", map[string]any{"tool": "tasks_create", "arguments": args})
|
||||
res := a.tools.Execute(ctx, "tasks_create", args)
|
||||
res := a.skills.Execute(ctx, "tasks_create", args)
|
||||
a.trace(ctx, "scout", "tool_result", "output", res)
|
||||
if res.Success && res.Mutated {
|
||||
created++
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
skillWorkerProtocolV1 = "jarvis.skill.worker.v1"
|
||||
skillInvokeProtocolV1 = "jarvis.skill.invoke.v1"
|
||||
)
|
||||
|
||||
type meshWorkerHello struct {
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
RuntimeVersion string `json:"runtime_version,omitempty"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type meshEnrollRequest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
EnrollmentToken string `json:"enrollment_token"`
|
||||
Worker meshWorkerHello `json:"worker"`
|
||||
}
|
||||
|
||||
type meshEnrollResponse struct {
|
||||
Protocol string `json:"protocol"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
LeaseSeconds int `json:"lease_seconds"`
|
||||
MasterTime string `json:"master_time"`
|
||||
}
|
||||
|
||||
type meshSkillRegistration struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Actions []skillActionManifest `json:"actions"`
|
||||
}
|
||||
|
||||
type meshRegistryRequest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Skills []meshSkillRegistration `json:"skills"`
|
||||
}
|
||||
|
||||
type meshHeartbeatRequest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Status map[string]any `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type meshWorker struct {
|
||||
ID string
|
||||
Name string
|
||||
Runtime string
|
||||
RuntimeVersion string
|
||||
Endpoint string
|
||||
Labels map[string]string
|
||||
AccessToken string
|
||||
Skills []meshSkillRegistration
|
||||
LastSeen time.Time
|
||||
ExpiresAt time.Time
|
||||
RegisteredAt time.Time
|
||||
}
|
||||
|
||||
type meshWorkerStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
RuntimeVersion string `json:"runtime_version,omitempty"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
State string `json:"state"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
ActionCount int `json:"action_count"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
RegisteredAt string `json:"registered_at"`
|
||||
}
|
||||
|
||||
type meshStatus struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LeaseSeconds int `json:"lease_seconds"`
|
||||
EnrollmentReady bool `json:"enrollment_ready"`
|
||||
Workers []meshWorkerStatus `json:"workers"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
OnlineCount int `json:"online_count"`
|
||||
RemoteActions int `json:"remote_actions"`
|
||||
}
|
||||
|
||||
type meshRemoteAction struct {
|
||||
ToolName string
|
||||
SkillID string
|
||||
SkillName string
|
||||
Version string
|
||||
Description string
|
||||
Action skillActionManifest
|
||||
ProviderIDs []string
|
||||
}
|
||||
|
||||
type skillMesh struct {
|
||||
app *app
|
||||
enabled bool
|
||||
enrollmentToken string
|
||||
lease time.Duration
|
||||
invokeTimeout time.Duration
|
||||
client *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
workers map[string]*meshWorker
|
||||
rr map[string]int
|
||||
}
|
||||
|
||||
func newSkillMesh(a *app) *skillMesh {
|
||||
leaseSeconds := envInt("JARVIS_MESH_LEASE_SECONDS", 30)
|
||||
if leaseSeconds < 10 {
|
||||
leaseSeconds = 10
|
||||
}
|
||||
if leaseSeconds > 600 {
|
||||
leaseSeconds = 600
|
||||
}
|
||||
invokeMS := envInt("JARVIS_MESH_INVOKE_TIMEOUT_MS", 30000)
|
||||
if invokeMS < 500 {
|
||||
invokeMS = 500
|
||||
}
|
||||
if invokeMS > 300000 {
|
||||
invokeMS = 300000
|
||||
}
|
||||
m := &skillMesh{
|
||||
app: a,
|
||||
enabled: envBool("JARVIS_MESH_ENABLED", true),
|
||||
enrollmentToken: strings.TrimSpace(env("JARVIS_MESH_ENROLLMENT_TOKEN", "")),
|
||||
lease: time.Duration(leaseSeconds) * time.Second,
|
||||
invokeTimeout: time.Duration(invokeMS) * time.Millisecond,
|
||||
client: &http.Client{Timeout: time.Duration(invokeMS) * time.Millisecond},
|
||||
workers: map[string]*meshWorker{},
|
||||
rr: map[string]int{},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *skillMesh) Start() {
|
||||
if m == nil || !m.enabled {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
tick := time.NewTicker(maxDuration(5*time.Second, m.lease/3))
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
m.reapExpired()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func maxDuration(a, b time.Duration) time.Duration {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (m *skillMesh) reapExpired() {
|
||||
now := time.Now()
|
||||
removed := []string{}
|
||||
m.mu.Lock()
|
||||
for id, w := range m.workers {
|
||||
if !w.ExpiresAt.IsZero() && now.After(w.ExpiresAt) {
|
||||
removed = append(removed, id)
|
||||
delete(m.workers, id)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if len(removed) > 0 {
|
||||
if m.app != nil {
|
||||
m.app.trace(context.Background(), "mesh", "workers_expired", "output", map[string]any{"worker_ids": removed})
|
||||
if m.app.skills != nil {
|
||||
_ = m.app.skills.Reload(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *skillMesh) Status() meshStatus {
|
||||
if m == nil {
|
||||
return meshStatus{Protocol: skillWorkerProtocolV1}
|
||||
}
|
||||
now := time.Now()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := meshStatus{Protocol: skillWorkerProtocolV1, Enabled: m.enabled, LeaseSeconds: int(m.lease.Seconds()), EnrollmentReady: m.enrollmentToken != ""}
|
||||
actionSet := map[string]bool{}
|
||||
for _, w := range m.workers {
|
||||
actions := 0
|
||||
for _, s := range w.Skills {
|
||||
actions += len(s.Actions)
|
||||
for _, a := range s.Actions {
|
||||
tn := strings.TrimSpace(a.ToolName)
|
||||
if tn == "" {
|
||||
tn = pluginToolName(s.ID, a.Name)
|
||||
}
|
||||
actionSet[tn] = true
|
||||
}
|
||||
}
|
||||
state := "online"
|
||||
if now.After(w.ExpiresAt) {
|
||||
state = "offline"
|
||||
} else {
|
||||
out.OnlineCount++
|
||||
}
|
||||
out.Workers = append(out.Workers, meshWorkerStatus{ID: w.ID, Name: w.Name, Runtime: w.Runtime, RuntimeVersion: w.RuntimeVersion, Endpoint: w.Endpoint, Labels: cloneStringMap(w.Labels), State: state, SkillCount: len(w.Skills), ActionCount: actions, LastSeen: w.LastSeen.Format(time.RFC3339), ExpiresAt: w.ExpiresAt.Format(time.RFC3339), RegisteredAt: w.RegisteredAt.Format(time.RFC3339)})
|
||||
}
|
||||
sort.Slice(out.Workers, func(i, j int) bool { return out.Workers[i].Name < out.Workers[j].Name })
|
||||
out.WorkerCount = len(out.Workers)
|
||||
out.RemoteActions = len(actionSet)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *skillMesh) RemoteActions() []meshRemoteAction {
|
||||
if m == nil || !m.enabled {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
grouped := map[string]*meshRemoteAction{}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
ids := make([]string, 0, len(m.workers))
|
||||
for id := range m.workers {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
w := m.workers[id]
|
||||
if now.After(w.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
for _, s := range w.Skills {
|
||||
for _, a := range s.Actions {
|
||||
tn := strings.TrimSpace(a.ToolName)
|
||||
if tn == "" {
|
||||
tn = pluginToolName(s.ID, a.Name)
|
||||
}
|
||||
x := grouped[tn]
|
||||
if x == nil {
|
||||
cp := a
|
||||
cp.ToolName = tn
|
||||
x = &meshRemoteAction{ToolName: tn, SkillID: s.ID, SkillName: s.Name, Version: s.Version, Description: firstNonEmpty(a.Description, s.Description, "Remote Skill "+s.Name), Action: cp}
|
||||
grouped[tn] = x
|
||||
}
|
||||
// Only replicas of the same logical skill/action may share a tool name.
|
||||
if x.SkillID == s.ID && x.Action.Name == a.Name {
|
||||
x.ProviderIDs = append(x.ProviderIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]meshRemoteAction, 0, len(grouped))
|
||||
for _, x := range grouped {
|
||||
sort.Strings(x.ProviderIDs)
|
||||
out = append(out, *x)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ToolName < out[j].ToolName })
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(v ...string) string {
|
||||
for _, s := range v {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *skillMesh) Invoke(ctx context.Context, toolName string, args map[string]any) toolResult {
|
||||
requestID := id("remote")
|
||||
remote, ok := m.remoteAction(toolName)
|
||||
if !ok {
|
||||
return failTool(toolName, requestID, "REMOTE_SKILL_UNAVAILABLE", "Kein aktiver Remote-Worker stellt diese Skill-Aktion bereit")
|
||||
}
|
||||
if err := validateJSONAgainstSchema(args, remote.Action.InputSchema, "input"); err != nil {
|
||||
return failTool(toolName, requestID, "INVALID_SKILL_INPUT", err.Error())
|
||||
}
|
||||
providers := m.providerSnapshot(toolName, remote.ProviderIDs)
|
||||
if len(providers) == 0 {
|
||||
return failTool(toolName, requestID, "REMOTE_SKILL_OFFLINE", "Alle Worker für diese Skill-Aktion sind offline")
|
||||
}
|
||||
startIdx := m.nextProviderIndex(toolName, len(providers))
|
||||
var lastErr error
|
||||
for i := 0; i < len(providers); i++ {
|
||||
w := providers[(startIdx+i)%len(providers)]
|
||||
res, transportErr := m.invokeWorker(ctx, w, remote, requestID, args)
|
||||
if transportErr == nil {
|
||||
return res
|
||||
}
|
||||
lastErr = transportErr
|
||||
if m.app != nil {
|
||||
m.app.traceErr(ctx, "mesh", "invoke_transport", map[string]any{"worker_id": w.ID, "tool": toolName}, transportErr, time.Now())
|
||||
}
|
||||
}
|
||||
return failTool(toolName, requestID, "REMOTE_SKILL_TRANSPORT", lastErr.Error())
|
||||
}
|
||||
|
||||
func (m *skillMesh) remoteAction(tool string) (meshRemoteAction, bool) {
|
||||
for _, a := range m.RemoteActions() {
|
||||
if a.ToolName == tool {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return meshRemoteAction{}, false
|
||||
}
|
||||
|
||||
func (m *skillMesh) providerSnapshot(tool string, ids []string) []*meshWorker {
|
||||
now := time.Now()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := []*meshWorker{}
|
||||
for _, id := range ids {
|
||||
if w := m.workers[id]; w != nil && now.Before(w.ExpiresAt) {
|
||||
cp := *w
|
||||
cp.Labels = cloneStringMap(w.Labels)
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *skillMesh) nextProviderIndex(tool string, n int) int {
|
||||
if n <= 1 {
|
||||
return 0
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
x := m.rr[tool] % n
|
||||
m.rr[tool] = (x + 1) % n
|
||||
return x
|
||||
}
|
||||
|
||||
func (m *skillMesh) invokeWorker(ctx context.Context, w *meshWorker, action meshRemoteAction, requestID string, args map[string]any) (toolResult, error) {
|
||||
callCtx, cancel := context.WithTimeout(ctx, m.invokeTimeout)
|
||||
defer cancel()
|
||||
envReq := skillRequestEnvelope{Protocol: skillInvokeProtocolV1, RequestID: requestID, SkillID: action.SkillID, Action: action.Action.Name, Input: args, Context: skillInvocationContext{TraceID: traceIDFromContext(ctx), Now: time.Now().In(m.app.timezone).Format(time.RFC3339), Timezone: m.app.timezone.String()}}
|
||||
body, _ := json.Marshal(envReq)
|
||||
endpoint := strings.TrimRight(w.Endpoint, "/") + "/v1/invoke"
|
||||
req, err := http.NewRequestWithContext(callCtx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return toolResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+w.AccessToken)
|
||||
req.Header.Set("X-Jarvis-Trace-ID", traceIDFromContext(ctx))
|
||||
started := time.Now()
|
||||
if m.app != nil {
|
||||
m.app.trace(ctx, "mesh", "invoke_request", "input", map[string]any{"worker_id": w.ID, "endpoint": endpoint, "tool": action.ToolName, "skill_id": action.SkillID, "action": action.Action.Name, "arguments": args})
|
||||
}
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return toolResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return toolResult{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return toolResult{}, fmt.Errorf("worker %s HTTP %d: %s", w.Name, resp.StatusCode, clipSkillString(strings.TrimSpace(string(raw)), 1000))
|
||||
}
|
||||
var wire skillResponseEnvelope
|
||||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||
return toolResult{}, fmt.Errorf("worker %s lieferte ungültiges JSON: %w", w.Name, err)
|
||||
}
|
||||
if wire.Protocol != "" && wire.Protocol != skillInvokeProtocolV1 && wire.Protocol != skillProtocolV1 {
|
||||
return toolResult{}, fmt.Errorf("worker %s Protokoll %q inkompatibel", w.Name, wire.Protocol)
|
||||
}
|
||||
if wire.Success && action.Action.OutputSchema != nil {
|
||||
if err := validateJSONAgainstSchema(wire.Data, action.Action.OutputSchema, "data"); err != nil {
|
||||
return failTool(action.ToolName, requestID, "INVALID_SKILL_OUTPUT", err.Error()), nil
|
||||
}
|
||||
}
|
||||
res := toolResult{Success: wire.Success, Tool: action.ToolName, RequestID: requestID, Data: wire.Data, Error: wire.Error, Warnings: wire.Warnings, Entity: wire.Entity, Message: strings.TrimSpace(wire.Message)}
|
||||
if !res.Success && res.Error == nil {
|
||||
res.Error = &toolError{Code: "REMOTE_SKILL_FAILED", Message: "Remote-Skill meldete success=false"}
|
||||
}
|
||||
mutated := action.Action.Mutates && wire.Success
|
||||
if wire.Mutated != nil {
|
||||
mutated = action.Action.Mutates && wire.Success && *wire.Mutated
|
||||
}
|
||||
res.Mutated = mutated
|
||||
if m.app != nil {
|
||||
m.app.traceTimed(ctx, "mesh", "invoke_response", "output", map[string]any{"worker_id": w.ID, "tool": action.ToolName, "result": res}, started)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *skillMesh) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.enabled {
|
||||
http.Error(w, "Skill Mesh deaktiviert", 503)
|
||||
return
|
||||
}
|
||||
var in meshEnrollRequest
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.Protocol != "" && in.Protocol != skillWorkerProtocolV1 {
|
||||
http.Error(w, "Protocol mismatch", 400)
|
||||
return
|
||||
}
|
||||
if !constantTimeEqual(strings.TrimSpace(in.EnrollmentToken), m.enrollmentToken) || m.enrollmentToken == "" {
|
||||
http.Error(w, "Enrollment abgelehnt", 401)
|
||||
return
|
||||
}
|
||||
endpoint, err := validateWorkerEndpoint(in.Worker.Endpoint)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Worker.Name) == "" || strings.TrimSpace(in.Worker.Runtime) == "" {
|
||||
http.Error(w, "worker.name/runtime fehlt", 400)
|
||||
return
|
||||
}
|
||||
tok, err := secureToken(32)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
wid := "worker_" + strings.ToLower(strings.ReplaceAll(id("mesh"), "_", ""))
|
||||
mw := &meshWorker{ID: wid, Name: strings.TrimSpace(in.Worker.Name), Runtime: strings.TrimSpace(in.Worker.Runtime), RuntimeVersion: strings.TrimSpace(in.Worker.RuntimeVersion), Endpoint: endpoint, Labels: cloneStringMap(in.Worker.Labels), AccessToken: tok, LastSeen: now, ExpiresAt: now.Add(m.lease), RegisteredAt: now}
|
||||
m.mu.Lock()
|
||||
// Re-enrollment of the same logical worker replaces an older lease instead
|
||||
// of leaving duplicate registrations around until expiry.
|
||||
for existingID, existing := range m.workers {
|
||||
if existing.Name == mw.Name && existing.Endpoint == mw.Endpoint {
|
||||
delete(m.workers, existingID)
|
||||
}
|
||||
}
|
||||
m.workers[wid] = mw
|
||||
m.mu.Unlock()
|
||||
if m.app != nil {
|
||||
m.app.trace(r.Context(), "mesh", "worker_enrolled", "output", map[string]any{"worker_id": wid, "name": mw.Name, "runtime": mw.Runtime, "endpoint": mw.Endpoint})
|
||||
}
|
||||
writeJSON(w, 200, meshEnrollResponse{Protocol: skillWorkerProtocolV1, WorkerID: wid, AccessToken: tok, LeaseSeconds: int(m.lease.Seconds()), MasterTime: now.In(m.app.timezone).Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func validateWorkerEndpoint(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errors.New("worker.endpoint fehlt")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", errors.New("worker.endpoint muss http/https verwenden")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", errors.New("worker.endpoint Host fehlt")
|
||||
}
|
||||
return strings.TrimRight(raw, "/"), nil
|
||||
}
|
||||
|
||||
func secureToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func (m *skillMesh) authWorker(r *http.Request) (*meshWorker, bool) {
|
||||
idv := r.PathValue("id")
|
||||
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
return nil, false
|
||||
}
|
||||
tok := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
m.mu.RLock()
|
||||
w := m.workers[idv]
|
||||
if w == nil {
|
||||
m.mu.RUnlock()
|
||||
return nil, false
|
||||
}
|
||||
cp := *w
|
||||
m.mu.RUnlock()
|
||||
if !constantTimeEqual(tok, cp.AccessToken) {
|
||||
return nil, false
|
||||
}
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
func (m *skillMesh) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
current, ok := m.authWorker(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
var in meshRegistryRequest
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.Protocol != "" && in.Protocol != skillWorkerProtocolV1 {
|
||||
http.Error(w, "Protocol mismatch", 400)
|
||||
return
|
||||
}
|
||||
clean := make([]meshSkillRegistration, 0, len(in.Skills))
|
||||
issues := []string{}
|
||||
for _, s := range in.Skills {
|
||||
cs, err := validateMeshSkill(s)
|
||||
if err != nil {
|
||||
issues = append(issues, fmt.Sprintf("%s: %v", s.ID, err))
|
||||
continue
|
||||
}
|
||||
clean = append(clean, cs)
|
||||
}
|
||||
now := time.Now()
|
||||
m.mu.Lock()
|
||||
live := m.workers[current.ID]
|
||||
if live != nil {
|
||||
live.Skills = clean
|
||||
live.LastSeen = now
|
||||
live.ExpiresAt = now.Add(m.lease)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if m.app != nil {
|
||||
m.app.trace(r.Context(), "mesh", "skills_registered", "output", map[string]any{"worker_id": current.ID, "skills": len(clean), "issues": issues})
|
||||
if m.app.skills != nil {
|
||||
_ = m.app.skills.Reload(r.Context())
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": len(issues) == 0, "registered": len(clean), "issues": issues, "lease_seconds": int(m.lease.Seconds())})
|
||||
}
|
||||
|
||||
func validateMeshSkill(s meshSkillRegistration) (meshSkillRegistration, error) {
|
||||
s.Protocol = strings.TrimSpace(s.Protocol)
|
||||
if s.Protocol == "" {
|
||||
s.Protocol = skillProtocolV1
|
||||
}
|
||||
if s.Protocol != skillProtocolV1 {
|
||||
return s, fmt.Errorf("protocol %q wird nicht unterstützt", s.Protocol)
|
||||
}
|
||||
s.ID = strings.ToLower(strings.TrimSpace(s.ID))
|
||||
if !skillIDPattern.MatchString(s.ID) {
|
||||
return s, errors.New("ungültige skill id")
|
||||
}
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return s, errors.New("name fehlt")
|
||||
}
|
||||
if strings.TrimSpace(s.Version) == "" {
|
||||
s.Version = "0.0.0"
|
||||
}
|
||||
if len(s.Actions) == 0 {
|
||||
return s, errors.New("actions leer")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := range s.Actions {
|
||||
a := &s.Actions[i]
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
if !skillActionPattern.MatchString(a.Name) {
|
||||
return s, fmt.Errorf("action[%d].name ungültig", i)
|
||||
}
|
||||
if seen[a.Name] {
|
||||
return s, fmt.Errorf("action %q doppelt", a.Name)
|
||||
}
|
||||
seen[a.Name] = true
|
||||
if a.InputSchema == nil {
|
||||
a.InputSchema = obj(nil, nil)
|
||||
}
|
||||
if err := validateSchemaDefinition(a.InputSchema); err != nil {
|
||||
return s, fmt.Errorf("action %s input_schema: %w", a.Name, err)
|
||||
}
|
||||
if a.OutputSchema != nil {
|
||||
if err := validateSchemaDefinition(a.OutputSchema); err != nil {
|
||||
return s, fmt.Errorf("action %s output_schema: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *skillMesh) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
current, ok := m.authWorker(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
var in meshHeartbeatRequest
|
||||
if r.ContentLength > 0 {
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
m.mu.Lock()
|
||||
live := m.workers[current.ID]
|
||||
if live != nil {
|
||||
live.LastSeen = now
|
||||
live.ExpiresAt = now.Add(m.lease)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "lease_seconds": int(m.lease.Seconds()), "master_time": now.In(m.app.timezone).Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func (m *skillMesh) handleDeregister(w http.ResponseWriter, r *http.Request) {
|
||||
current, ok := m.authWorker(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.workers, current.ID)
|
||||
m.mu.Unlock()
|
||||
if m.app != nil && m.app.skills != nil {
|
||||
_ = m.app.skills.Reload(r.Context())
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (m *skillMesh) handleReloadWorker(w http.ResponseWriter, r *http.Request) {
|
||||
idv := r.PathValue("id")
|
||||
m.mu.RLock()
|
||||
current := m.workers[idv]
|
||||
if current != nil {
|
||||
cp := *current
|
||||
current = &cp
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if current == nil || time.Now().After(current.ExpiresAt) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"ok": false, "error": "Worker nicht gefunden oder offline"})
|
||||
return
|
||||
}
|
||||
endpoint := strings.TrimRight(current.Endpoint, "/") + "/v1/reload"
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpoint, bytes.NewReader([]byte(`{}`)))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+current.AccessToken)
|
||||
started := time.Now()
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "status": resp.StatusCode, "error": clipSkillString(strings.TrimSpace(string(raw)), 800)})
|
||||
return
|
||||
}
|
||||
if m.app != nil {
|
||||
m.app.traceTimed(r.Context(), "mesh", "worker_reload", "output", map[string]any{"worker_id": current.ID, "name": current.Name}, started)
|
||||
}
|
||||
var out any = map[string]any{"ok": true}
|
||||
if len(bytes.TrimSpace(raw)) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (m *skillMesh) handleWorkers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, m.Status())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSkillMeshEnrollRegisterInvoke(t *testing.T) {
|
||||
t.Setenv("JARVIS_MESH_ENABLED", "true")
|
||||
t.Setenv("JARVIS_MESH_ENROLLMENT_TOKEN", "secret-enroll")
|
||||
t.Setenv("JARVIS_SKILLS_ENABLED", "false")
|
||||
|
||||
var expectedToken string
|
||||
workerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/invoke" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); got == "" || got != expectedToken {
|
||||
http.Error(w, "bad token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var in skillRequestEnvelope
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
if in.SkillID != "demo.remote" || in.Action != "echo" {
|
||||
http.Error(w, "bad action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, skillResponseEnvelope{
|
||||
Protocol: skillInvokeProtocolV1,
|
||||
Success: true,
|
||||
Data: map[string]any{"text": in.Input["text"]},
|
||||
Message: "remote ok",
|
||||
})
|
||||
}))
|
||||
defer workerServer.Close()
|
||||
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
a := &app{timezone: loc}
|
||||
a.mesh = newSkillMesh(a)
|
||||
a.skills = newSkillRegistry(a, nil, t.TempDir())
|
||||
|
||||
enrollBody := `{"protocol":"jarvis.skill.worker.v1","enrollment_token":"secret-enroll","worker":{"name":"python-test","runtime":"python","endpoint":"` + workerServer.URL + `"}}`
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mesh/enroll", strings.NewReader(enrollBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
a.mesh.handleEnroll(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enroll status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var enrolled meshEnrollResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &enrolled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedToken = enrolled.AccessToken
|
||||
if enrolled.WorkerID == "" || expectedToken == "" {
|
||||
t.Fatalf("bad enrollment: %+v", enrolled)
|
||||
}
|
||||
|
||||
inputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"text"},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
outputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"text"},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
reg := meshRegistryRequest{
|
||||
Protocol: skillWorkerProtocolV1,
|
||||
Skills: []meshSkillRegistration{{
|
||||
Protocol: skillProtocolV1,
|
||||
ID: "demo.remote",
|
||||
Name: "Remote Demo",
|
||||
Version: "1.0.0",
|
||||
Actions: []skillActionManifest{{
|
||||
Name: "echo",
|
||||
Description: "echo",
|
||||
InputSchema: inputSchema,
|
||||
OutputSchema: outputSchema,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
b, _ := json.Marshal(reg)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/mesh/workers/"+enrolled.WorkerID+"/skills", strings.NewReader(string(b)))
|
||||
req.SetPathValue("id", enrolled.WorkerID)
|
||||
req.Header.Set("Authorization", "Bearer "+expectedToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
a.mesh.handleRegister(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("register=%d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
toolName := pluginToolName("demo.remote", "echo")
|
||||
if !a.skills.Has(toolName) {
|
||||
t.Fatalf("remote tool %s not registered; status=%+v", toolName, a.skills.Status())
|
||||
}
|
||||
res := a.skills.Execute(context.Background(), toolName, map[string]any{"text": "hello"})
|
||||
if !res.Success {
|
||||
t.Fatalf("invoke failed: %+v", res)
|
||||
}
|
||||
data, _ := res.Data.(map[string]any)
|
||||
if data["text"] != "hello" {
|
||||
t.Fatalf("bad data: %#v", res.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillMeshExpiredWorkerIsRemoved(t *testing.T) {
|
||||
t.Setenv("JARVIS_MESH_ENABLED", "true")
|
||||
t.Setenv("JARVIS_MESH_ENROLLMENT_TOKEN", "x")
|
||||
t.Setenv("JARVIS_SKILLS_ENABLED", "false")
|
||||
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
a := &app{timezone: loc}
|
||||
a.mesh = newSkillMesh(a)
|
||||
a.skills = newSkillRegistry(a, nil, t.TempDir())
|
||||
|
||||
a.mesh.mu.Lock()
|
||||
a.mesh.workers["worker_dead"] = &meshWorker{
|
||||
ID: "worker_dead",
|
||||
Name: "dead",
|
||||
Runtime: "python",
|
||||
Endpoint: "http://127.0.0.1:1",
|
||||
AccessToken: "x",
|
||||
ExpiresAt: time.Now().Add(-time.Second),
|
||||
Skills: []meshSkillRegistration{{
|
||||
Protocol: skillProtocolV1,
|
||||
ID: "dead.skill",
|
||||
Name: "Dead",
|
||||
Version: "1",
|
||||
Actions: []skillActionManifest{{
|
||||
Name: "run",
|
||||
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
a.mesh.mu.Unlock()
|
||||
|
||||
a.mesh.reapExpired()
|
||||
if a.mesh.Status().WorkerCount != 0 {
|
||||
t.Fatalf("expired worker remains")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillMeshReenrollReplacesSameWorker(t *testing.T) {
|
||||
t.Setenv("JARVIS_MESH_ENABLED", "true")
|
||||
t.Setenv("JARVIS_MESH_ENROLLMENT_TOKEN", "secret")
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
a := &app{timezone: loc}
|
||||
a.mesh = newSkillMesh(a)
|
||||
body := `{"protocol":"jarvis.skill.worker.v1","enrollment_token":"secret","worker":{"name":"python-main","runtime":"python","endpoint":"http://worker:8090"}}`
|
||||
for i := 0; i < 2; i++ {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/mesh/enroll", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
a.mesh.handleEnroll(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enroll %d: %s", i, rr.Body.String())
|
||||
}
|
||||
}
|
||||
if got := a.mesh.Status().WorkerCount; got != 1 {
|
||||
t.Fatalf("worker_count=%d want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (a *app) resolvePendingToolAction(ctx context.Context, raw string) (agentOu
|
||||
}
|
||||
|
||||
a.clearPendingToolAction()
|
||||
res := a.tools.Execute(ctx, cp.Tool, cp.Arguments)
|
||||
res := a.skills.Execute(ctx, cp.Tool, cp.Arguments)
|
||||
a.trace(ctx, "agent", "pending_tool_execute", "output", map[string]any{"tool": cp.Tool, "arguments": cp.Arguments, "label": cp.Label, "result": res})
|
||||
out := agentOutcome{Mode: "tool-confirmed", Results: []toolResult{res}, Automations: []automationResult{}}
|
||||
if !res.Success {
|
||||
@@ -164,7 +164,7 @@ func isMutationTool(name string) bool {
|
||||
func (a *app) toolFailureReply(results []toolResult) string {
|
||||
for i := len(results) - 1; i >= 0; i-- {
|
||||
res := results[i]
|
||||
if res.Success || !isMutationTool(res.Tool) {
|
||||
if res.Success || !a.skills.IsMutation(res.Tool) {
|
||||
continue
|
||||
}
|
||||
msg := "Die gewünschte Änderung konnte nicht ausgeführt werden."
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"homehub/internal/debugtrace"
|
||||
"homehub/internal/ollama"
|
||||
)
|
||||
|
||||
const skillProtocolV1 = "jarvis.skill.v1"
|
||||
|
||||
var skillIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||
var skillActionPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
|
||||
|
||||
type skillRuntime struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
TimeoutMS int `json:"timeout_ms,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
EnvFrom []string `json:"env_from,omitempty"`
|
||||
}
|
||||
|
||||
type skillPermissions struct {
|
||||
Network bool `json:"network,omitempty"`
|
||||
SystemExec bool `json:"system_exec,omitempty"`
|
||||
}
|
||||
|
||||
type skillActionManifest struct {
|
||||
Name string `json:"name"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"input_schema"`
|
||||
OutputSchema map[string]any `json:"output_schema,omitempty"`
|
||||
Mutates bool `json:"mutates,omitempty"`
|
||||
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
|
||||
Triggers []string `json:"triggers,omitempty"`
|
||||
}
|
||||
|
||||
type skillManifest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Runtime skillRuntime `json:"runtime"`
|
||||
Permissions skillPermissions `json:"permissions,omitempty"`
|
||||
Actions []skillActionManifest `json:"actions"`
|
||||
}
|
||||
|
||||
type skillActionRef struct {
|
||||
SkillID string
|
||||
SkillName string
|
||||
Version string
|
||||
Source string
|
||||
Dir string
|
||||
Runtime skillRuntime
|
||||
Permissions skillPermissions
|
||||
Action skillActionManifest
|
||||
ToolName string
|
||||
Core bool
|
||||
Remote bool
|
||||
}
|
||||
|
||||
type skillActionStatus struct {
|
||||
Name string `json:"name"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Mutates bool `json:"mutates"`
|
||||
RequiresConfirmation bool `json:"requires_confirmation"`
|
||||
}
|
||||
|
||||
type skillModuleStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Runtime string `json:"runtime"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Permissions skillPermissions `json:"permissions,omitempty"`
|
||||
Actions []skillActionStatus `json:"actions"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type skillRegistryStatus struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Directory string `json:"directory"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ProcessEnabled bool `json:"process_enabled"`
|
||||
SystemExec bool `json:"system_exec_enabled"`
|
||||
Modules []skillModuleStatus `json:"modules"`
|
||||
ActionCount int `json:"action_count"`
|
||||
PluginCount int `json:"plugin_count"`
|
||||
RemoteCount int `json:"remote_count"`
|
||||
CoreCount int `json:"core_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
LoadedAt string `json:"loaded_at"`
|
||||
}
|
||||
|
||||
type skillRegistry struct {
|
||||
app *app
|
||||
core *toolRegistry
|
||||
dir string
|
||||
enabled bool
|
||||
processEnabled bool
|
||||
allowSystemExec bool
|
||||
maxTimeout time.Duration
|
||||
maxOutputBytes int
|
||||
|
||||
mu sync.RWMutex
|
||||
defs []ollama.Tool
|
||||
actions map[string]skillActionRef
|
||||
modules []skillModuleStatus
|
||||
errors []string
|
||||
loadedAt time.Time
|
||||
}
|
||||
|
||||
type skillInvocationContext struct {
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Now string `json:"now"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type skillRequestEnvelope struct {
|
||||
Protocol string `json:"protocol"`
|
||||
RequestID string `json:"request_id"`
|
||||
SkillID string `json:"skill_id"`
|
||||
Action string `json:"action"`
|
||||
Input map[string]any `json:"input"`
|
||||
Context skillInvocationContext `json:"context"`
|
||||
}
|
||||
|
||||
type skillResponseEnvelope struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error *toolError `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Mutated *bool `json:"mutated,omitempty"`
|
||||
Entity *toolEntity `json:"entity,omitempty"`
|
||||
}
|
||||
|
||||
func newSkillRegistry(a *app, core *toolRegistry, dir string) *skillRegistry {
|
||||
maxTimeoutMS := envInt("JARVIS_SKILL_MAX_TIMEOUT_MS", 30000)
|
||||
if maxTimeoutMS < 250 {
|
||||
maxTimeoutMS = 250
|
||||
}
|
||||
if maxTimeoutMS > 300000 {
|
||||
maxTimeoutMS = 300000
|
||||
}
|
||||
maxOutputKB := envInt("JARVIS_SKILL_MAX_OUTPUT_KB", 1024)
|
||||
if maxOutputKB < 16 {
|
||||
maxOutputKB = 16
|
||||
}
|
||||
if maxOutputKB > 16384 {
|
||||
maxOutputKB = 16384
|
||||
}
|
||||
r := &skillRegistry{
|
||||
app: a,
|
||||
core: core,
|
||||
dir: dir,
|
||||
enabled: envBool("JARVIS_SKILLS_ENABLED", true),
|
||||
processEnabled: envBool("JARVIS_SKILL_PROCESS_ENABLED", true),
|
||||
allowSystemExec: envBool("JARVIS_SKILL_ALLOW_SYSTEM_EXEC", false),
|
||||
maxTimeout: time.Duration(maxTimeoutMS) * time.Millisecond,
|
||||
maxOutputBytes: maxOutputKB * 1024,
|
||||
actions: map[string]skillActionRef{},
|
||||
}
|
||||
_ = r.Reload(context.Background())
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *skillRegistry) Definitions() []ollama.Tool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return append([]ollama.Tool(nil), r.defs...)
|
||||
}
|
||||
|
||||
func (r *skillRegistry) Has(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
_, ok := r.actions[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *skillRegistry) MayApply(message string) bool {
|
||||
x := foldCommand(message)
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, ref := range r.actions {
|
||||
if ref.Core {
|
||||
continue
|
||||
}
|
||||
for _, trigger := range ref.Action.Triggers {
|
||||
trigger = strings.TrimSpace(trigger)
|
||||
if trigger != "" && strings.Contains(x, foldCommand(trigger)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *skillRegistry) IsMutation(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
ref, ok := r.actions[name]
|
||||
return ok && ref.Action.Mutates
|
||||
}
|
||||
|
||||
func (r *skillRegistry) RequiresConfirmation(name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
ref, ok := r.actions[name]
|
||||
return ok && ref.Action.RequiresConfirmation
|
||||
}
|
||||
|
||||
func (r *skillRegistry) ActionLabel(name string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
ref, ok := r.actions[name]
|
||||
if !ok {
|
||||
return name
|
||||
}
|
||||
if ref.SkillName != "" {
|
||||
return ref.SkillName + " · " + ref.Action.Name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *skillRegistry) Status() skillRegistryStatus {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
mods := append([]skillModuleStatus(nil), r.modules...)
|
||||
errs := append([]string(nil), r.errors...)
|
||||
sort.Slice(mods, func(i, j int) bool {
|
||||
if mods[i].Source == mods[j].Source {
|
||||
return mods[i].Name < mods[j].Name
|
||||
}
|
||||
return mods[i].Source < mods[j].Source
|
||||
})
|
||||
coreCount, pluginCount, remoteCount := 0, 0, 0
|
||||
for _, m := range mods {
|
||||
switch m.Source {
|
||||
case "core":
|
||||
coreCount++
|
||||
case "remote":
|
||||
remoteCount++
|
||||
default:
|
||||
pluginCount++
|
||||
}
|
||||
}
|
||||
return skillRegistryStatus{
|
||||
Protocol: skillProtocolV1, Directory: r.dir, Enabled: r.enabled,
|
||||
ProcessEnabled: r.processEnabled, SystemExec: r.allowSystemExec,
|
||||
Modules: mods, ActionCount: len(r.actions), PluginCount: pluginCount, RemoteCount: remoteCount,
|
||||
CoreCount: coreCount, Errors: errs, LoadedAt: r.loadedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *skillRegistry) Reload(ctx context.Context) error {
|
||||
defs := []ollama.Tool{}
|
||||
actions := map[string]skillActionRef{}
|
||||
modulesByID := map[string]*skillModuleStatus{}
|
||||
issues := []string{}
|
||||
|
||||
// Existing deterministic Home tools become Core Skills. Their public function
|
||||
// names intentionally stay stable so old conversations and model behavior do
|
||||
// not break while the execution architecture changes underneath them.
|
||||
if r.core != nil {
|
||||
for _, d := range r.core.Definitions() {
|
||||
toolName := d.Function.Name
|
||||
domain, actionName := coreSkillParts(toolName)
|
||||
sid := "core." + domain
|
||||
ref := skillActionRef{
|
||||
SkillID: sid, SkillName: "Core " + strings.Title(domain), Version: "1",
|
||||
Source: "core", Core: true, ToolName: toolName,
|
||||
Action: skillActionManifest{Name: actionName, ToolName: toolName, Description: d.Function.Description, InputSchema: d.Function.Parameters, Mutates: isMutationTool(toolName)},
|
||||
}
|
||||
actions[toolName] = ref
|
||||
defs = append(defs, d)
|
||||
mod := modulesByID[sid]
|
||||
if mod == nil {
|
||||
mod = &skillModuleStatus{ID: sid, Name: ref.SkillName, Version: "1", Source: "core", Runtime: "builtin", Enabled: true}
|
||||
modulesByID[sid] = mod
|
||||
}
|
||||
mod.Actions = append(mod.Actions, skillActionStatus{Name: actionName, ToolName: toolName, Description: d.Function.Description, Mutates: ref.Action.Mutates})
|
||||
}
|
||||
}
|
||||
|
||||
// Remote Skill Mesh providers are merged into the exact same registry as
|
||||
// built-in and local process skills. The LLM therefore sees one capability
|
||||
// catalog regardless of where code is executed.
|
||||
if r.app != nil && r.app.mesh != nil {
|
||||
for _, remote := range r.app.mesh.RemoteActions() {
|
||||
if _, exists := actions[remote.ToolName]; exists {
|
||||
issues = append(issues, remote.SkillID+": Remote Tool-Name kollidiert: "+remote.ToolName)
|
||||
continue
|
||||
}
|
||||
act := remote.Action
|
||||
act.ToolName = remote.ToolName
|
||||
ref := skillActionRef{SkillID: remote.SkillID, SkillName: remote.SkillName, Version: remote.Version, Source: "remote", Action: act, ToolName: remote.ToolName, Remote: true}
|
||||
actions[remote.ToolName] = ref
|
||||
desc := firstNonEmpty(remote.Description, act.Description, "Remote Skill "+remote.SkillName)
|
||||
defs = append(defs, toolDef(remote.ToolName, "[Remote Skill: "+remote.SkillName+"] "+desc, act.InputSchema))
|
||||
sid := "remote." + remote.SkillID
|
||||
mod := modulesByID[sid]
|
||||
if mod == nil {
|
||||
mod = &skillModuleStatus{ID: remote.SkillID, Name: remote.SkillName, Version: remote.Version, Description: "Remote Skill Mesh", Source: "remote", Runtime: "http", Enabled: true}
|
||||
modulesByID[sid] = mod
|
||||
}
|
||||
mod.Actions = append(mod.Actions, skillActionStatus{Name: act.Name, ToolName: remote.ToolName, Description: desc, Mutates: act.Mutates, RequiresConfirmation: act.RequiresConfirmation})
|
||||
}
|
||||
}
|
||||
|
||||
if r.enabled {
|
||||
if err := os.MkdirAll(r.dir, 0o755); err != nil {
|
||||
issues = append(issues, "Skill-Verzeichnis: "+err.Error())
|
||||
} else {
|
||||
entries, err := os.ReadDir(r.dir)
|
||||
if err != nil {
|
||||
issues = append(issues, "Skill-Verzeichnis lesen: "+err.Error())
|
||||
} else {
|
||||
for _, ent := range entries {
|
||||
if !ent.IsDir() || strings.HasPrefix(ent.Name(), ".") || strings.HasPrefix(ent.Name(), "_") {
|
||||
continue
|
||||
}
|
||||
skillDir := filepath.Join(r.dir, ent.Name())
|
||||
manifestPath := filepath.Join(skillDir, "skill.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil {
|
||||
continue
|
||||
}
|
||||
manifest, err := loadSkillManifest(manifestPath)
|
||||
if err != nil {
|
||||
issues = append(issues, ent.Name()+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
enabled := manifest.Enabled == nil || *manifest.Enabled
|
||||
mod := skillModuleStatus{
|
||||
ID: manifest.ID, Name: manifest.Name, Version: manifest.Version, Description: manifest.Description,
|
||||
Source: "plugin", Runtime: manifest.Runtime.Type, Enabled: enabled, Permissions: manifest.Permissions,
|
||||
}
|
||||
if !enabled {
|
||||
modulesByID[manifest.ID] = &mod
|
||||
continue
|
||||
}
|
||||
if manifest.Runtime.Type == "process" && !r.processEnabled {
|
||||
mod.Error = "Process-Skills sind global deaktiviert"
|
||||
modulesByID[manifest.ID] = &mod
|
||||
issues = append(issues, manifest.ID+": "+mod.Error)
|
||||
continue
|
||||
}
|
||||
for _, act := range manifest.Actions {
|
||||
toolName := strings.TrimSpace(act.ToolName)
|
||||
if toolName == "" {
|
||||
toolName = pluginToolName(manifest.ID, act.Name)
|
||||
}
|
||||
if _, exists := actions[toolName]; exists {
|
||||
issues = append(issues, manifest.ID+": Tool-Name kollidiert: "+toolName)
|
||||
continue
|
||||
}
|
||||
act.ToolName = toolName
|
||||
ref := skillActionRef{
|
||||
SkillID: manifest.ID, SkillName: manifest.Name, Version: manifest.Version,
|
||||
Source: "plugin", Dir: skillDir, Runtime: manifest.Runtime, Permissions: manifest.Permissions,
|
||||
Action: act, ToolName: toolName,
|
||||
}
|
||||
actions[toolName] = ref
|
||||
desc := strings.TrimSpace(act.Description)
|
||||
if desc == "" {
|
||||
desc = manifest.Description
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "Plugin-Skill " + manifest.Name
|
||||
}
|
||||
defs = append(defs, toolDef(toolName, "[Skill: "+manifest.Name+"] "+desc, act.InputSchema))
|
||||
mod.Actions = append(mod.Actions, skillActionStatus{Name: act.Name, ToolName: toolName, Description: desc, Mutates: act.Mutates, RequiresConfirmation: act.RequiresConfirmation})
|
||||
}
|
||||
modulesByID[manifest.ID] = &mod
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modules := make([]skillModuleStatus, 0, len(modulesByID))
|
||||
for _, m := range modulesByID {
|
||||
sort.Slice(m.Actions, func(i, j int) bool { return m.Actions[i].ToolName < m.Actions[j].ToolName })
|
||||
modules = append(modules, *m)
|
||||
}
|
||||
sort.Slice(defs, func(i, j int) bool { return defs[i].Function.Name < defs[j].Function.Name })
|
||||
|
||||
r.mu.Lock()
|
||||
r.defs = defs
|
||||
r.actions = actions
|
||||
r.modules = modules
|
||||
r.errors = issues
|
||||
r.loadedAt = time.Now()
|
||||
r.mu.Unlock()
|
||||
|
||||
if r.app != nil {
|
||||
r.app.trace(ctx, "skills", "reload", "output", map[string]any{"directory": r.dir, "actions": len(actions), "modules": len(modules), "errors": issues})
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return fmt.Errorf("%d Skill-Problem(e) beim Laden", len(issues))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func coreSkillParts(tool string) (string, string) {
|
||||
if i := strings.Index(tool, "_"); i > 0 {
|
||||
return tool[:i], tool[i+1:]
|
||||
}
|
||||
return "system", tool
|
||||
}
|
||||
|
||||
func pluginToolName(skillID, action string) string {
|
||||
clean := func(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
var b strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, ch := range s {
|
||||
ok := ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9'
|
||||
if ok {
|
||||
b.WriteRune(ch)
|
||||
lastUnderscore = false
|
||||
} else if !lastUnderscore {
|
||||
b.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
out := "skill_" + clean(skillID) + "_" + clean(action)
|
||||
if len(out) > 96 {
|
||||
out = out[:96]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadSkillManifest(path string) (skillManifest, error) {
|
||||
var m skillManifest
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return m, fmt.Errorf("skill.json ungültig: %w", err)
|
||||
}
|
||||
m.Protocol = strings.TrimSpace(m.Protocol)
|
||||
if m.Protocol == "" {
|
||||
m.Protocol = skillProtocolV1
|
||||
}
|
||||
if m.Protocol != skillProtocolV1 {
|
||||
return m, fmt.Errorf("nicht unterstütztes Protokoll %q", m.Protocol)
|
||||
}
|
||||
m.ID = strings.ToLower(strings.TrimSpace(m.ID))
|
||||
if !skillIDPattern.MatchString(m.ID) {
|
||||
return m, fmt.Errorf("id muss %s entsprechen", skillIDPattern.String())
|
||||
}
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return m, errors.New("name fehlt")
|
||||
}
|
||||
if strings.TrimSpace(m.Version) == "" {
|
||||
m.Version = "0.0.0"
|
||||
}
|
||||
m.Runtime.Type = strings.ToLower(strings.TrimSpace(m.Runtime.Type))
|
||||
if m.Runtime.Type == "" {
|
||||
m.Runtime.Type = "process"
|
||||
}
|
||||
if m.Runtime.Type != "process" {
|
||||
return m, fmt.Errorf("runtime.type %q wird noch nicht unterstützt (aktuell: process)", m.Runtime.Type)
|
||||
}
|
||||
if strings.TrimSpace(m.Runtime.Command) == "" {
|
||||
return m, errors.New("runtime.command fehlt")
|
||||
}
|
||||
if len(m.Actions) == 0 {
|
||||
return m, errors.New("actions ist leer")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := range m.Actions {
|
||||
a := &m.Actions[i]
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
if !skillActionPattern.MatchString(a.Name) {
|
||||
return m, fmt.Errorf("action[%d].name ungültig", i)
|
||||
}
|
||||
if seen[a.Name] {
|
||||
return m, fmt.Errorf("action %q doppelt", a.Name)
|
||||
}
|
||||
seen[a.Name] = true
|
||||
if a.InputSchema == nil {
|
||||
a.InputSchema = obj(nil, nil)
|
||||
}
|
||||
if err := validateSchemaDefinition(a.InputSchema); err != nil {
|
||||
return m, fmt.Errorf("action %s input_schema: %w", a.Name, err)
|
||||
}
|
||||
if a.OutputSchema != nil {
|
||||
if err := validateSchemaDefinition(a.OutputSchema); err != nil {
|
||||
return m, fmt.Errorf("action %s output_schema: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r *skillRegistry) Execute(ctx context.Context, name string, args map[string]any) toolResult {
|
||||
requestID := id("skill")
|
||||
r.mu.RLock()
|
||||
ref, ok := r.actions[name]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return failTool(name, requestID, "UNKNOWN_SKILL", "Skill-Aktion ist nicht registriert: "+name)
|
||||
}
|
||||
if ref.Core {
|
||||
res := r.core.Execute(ctx, name, args)
|
||||
// Core tool results keep their existing request IDs for compatibility.
|
||||
return res
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
if err := validateJSONAgainstSchema(args, ref.Action.InputSchema, "input"); err != nil {
|
||||
return failTool(name, requestID, "INVALID_SKILL_INPUT", err.Error())
|
||||
}
|
||||
if ref.Remote {
|
||||
if r.app == nil || r.app.mesh == nil {
|
||||
return failTool(name, requestID, "REMOTE_SKILL_UNAVAILABLE", "Skill Mesh ist nicht verfügbar")
|
||||
}
|
||||
return r.app.mesh.Invoke(ctx, name, args)
|
||||
}
|
||||
return r.executeProcess(ctx, ref, requestID, args)
|
||||
}
|
||||
|
||||
func (r *skillRegistry) executeProcess(ctx context.Context, ref skillActionRef, requestID string, args map[string]any) toolResult {
|
||||
if !r.processEnabled {
|
||||
return failTool(ref.ToolName, requestID, "SKILL_RUNTIME_DISABLED", "Process-Skills sind deaktiviert")
|
||||
}
|
||||
timeout := 5 * time.Second
|
||||
if ref.Runtime.TimeoutMS > 0 {
|
||||
timeout = time.Duration(ref.Runtime.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
if timeout > r.maxTimeout {
|
||||
timeout = r.maxTimeout
|
||||
}
|
||||
if timeout < 250*time.Millisecond {
|
||||
timeout = 250 * time.Millisecond
|
||||
}
|
||||
command, err := r.resolveSkillCommand(ref)
|
||||
if err != nil {
|
||||
return failTool(ref.ToolName, requestID, "SKILL_EXEC_DENIED", err.Error())
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
envReq := skillRequestEnvelope{
|
||||
Protocol: skillProtocolV1, RequestID: requestID, SkillID: ref.SkillID, Action: ref.Action.Name, Input: args,
|
||||
Context: skillInvocationContext{TraceID: traceIDFromContext(ctx), Now: time.Now().In(r.app.timezone).Format(time.RFC3339), Timezone: r.app.timezone.String()},
|
||||
}
|
||||
in, _ := json.Marshal(envReq)
|
||||
cmd := exec.CommandContext(callCtx, command, ref.Runtime.Args...)
|
||||
cmd.Dir = ref.Dir
|
||||
cmd.Stdin = bytes.NewReader(in)
|
||||
cmd.Env = r.skillEnvironment(ref, requestID, ctx)
|
||||
stdout := &limitedBuffer{max: r.maxOutputBytes}
|
||||
stderr := &limitedBuffer{max: minInt(r.maxOutputBytes/4, 256*1024)}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
started := time.Now()
|
||||
r.app.trace(ctx, "skills", "invoke_request", "input", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "tool": ref.ToolName, "runtime": ref.Runtime.Type, "command": command, "arguments": args, "timeout_ms": timeout.Milliseconds()})
|
||||
runErr := cmd.Run()
|
||||
duration := time.Since(started)
|
||||
stderrText := strings.TrimSpace(stderr.String())
|
||||
if callCtx.Err() == context.DeadlineExceeded {
|
||||
r.app.traceErr(ctx, "skills", "invoke_response", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "stderr": stderrText}, callCtx.Err(), started)
|
||||
return failTool(ref.ToolName, requestID, "SKILL_TIMEOUT", fmt.Sprintf("Skill %s überschritt das Zeitlimit von %s", ref.SkillID, timeout))
|
||||
}
|
||||
if stdout.overflow {
|
||||
return failTool(ref.ToolName, requestID, "SKILL_OUTPUT_LIMIT", "Skill-Ausgabe überschreitet das konfigurierte Größenlimit")
|
||||
}
|
||||
if runErr != nil {
|
||||
msg := runErr.Error()
|
||||
if stderrText != "" {
|
||||
msg += ": " + clipSkillString(stderrText, 1200)
|
||||
}
|
||||
r.app.traceErr(ctx, "skills", "invoke_response", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "stderr": stderrText}, runErr, started)
|
||||
return failTool(ref.ToolName, requestID, "SKILL_PROCESS_FAILED", msg)
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(stdout.String())
|
||||
if raw == "" {
|
||||
return failTool(ref.ToolName, requestID, "SKILL_EMPTY_OUTPUT", "Skill hat keine JSON-Antwort geliefert")
|
||||
}
|
||||
var wire skillResponseEnvelope
|
||||
dec := json.NewDecoder(strings.NewReader(raw))
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
return failTool(ref.ToolName, requestID, "SKILL_INVALID_OUTPUT", "Skill-Ausgabe ist kein gültiges JSON: "+err.Error())
|
||||
}
|
||||
if wire.Protocol != "" && wire.Protocol != skillProtocolV1 {
|
||||
return failTool(ref.ToolName, requestID, "SKILL_PROTOCOL_MISMATCH", "Skill antwortete mit inkompatiblem Protokoll: "+wire.Protocol)
|
||||
}
|
||||
if wire.Success && ref.Action.OutputSchema != nil {
|
||||
if err := validateJSONAgainstSchema(wire.Data, ref.Action.OutputSchema, "data"); err != nil {
|
||||
return failTool(ref.ToolName, requestID, "INVALID_SKILL_OUTPUT", err.Error())
|
||||
}
|
||||
}
|
||||
res := toolResult{Success: wire.Success, Tool: ref.ToolName, RequestID: requestID, Data: wire.Data, Error: wire.Error, Warnings: wire.Warnings, Entity: wire.Entity, Message: strings.TrimSpace(wire.Message)}
|
||||
if !wire.Success && res.Error == nil {
|
||||
res.Error = &toolError{Code: "SKILL_FAILED", Message: "Skill meldete success=false"}
|
||||
}
|
||||
mutated := ref.Action.Mutates && wire.Success
|
||||
if wire.Mutated != nil {
|
||||
mutated = ref.Action.Mutates && wire.Success && *wire.Mutated
|
||||
if *wire.Mutated && !ref.Action.Mutates {
|
||||
res.Warnings = append(res.Warnings, "Skill meldete mutated=true, die Action ist im Manifest jedoch read-only")
|
||||
}
|
||||
}
|
||||
res.Mutated = mutated
|
||||
r.app.traceTimed(ctx, "skills", "invoke_response", "output", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "tool": ref.ToolName, "result": res, "stderr": clipSkillString(stderrText, 4000)}, started)
|
||||
_ = duration
|
||||
return res
|
||||
}
|
||||
|
||||
func (r *skillRegistry) resolveSkillCommand(ref skillActionRef) (string, error) {
|
||||
raw := strings.TrimSpace(ref.Runtime.Command)
|
||||
if raw == "" {
|
||||
return "", errors.New("runtime.command fehlt")
|
||||
}
|
||||
if filepath.IsAbs(raw) {
|
||||
if !ref.Permissions.SystemExec || !r.allowSystemExec {
|
||||
return "", fmt.Errorf("absolute/systemweite Executables sind für diesen Skill nicht erlaubt")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
if strings.ContainsRune(raw, filepath.Separator) || strings.HasPrefix(raw, ".") {
|
||||
p := filepath.Clean(filepath.Join(ref.Dir, raw))
|
||||
if !pathWithin(ref.Dir, p) {
|
||||
return "", errors.New("runtime.command verlässt das Skill-Verzeichnis")
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(p)
|
||||
if err == nil && !pathWithin(ref.Dir, resolved) {
|
||||
return "", errors.New("runtime.command Symlink verlässt das Skill-Verzeichnis")
|
||||
}
|
||||
if err := ensureExecutable(p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
local := filepath.Join(ref.Dir, raw)
|
||||
if st, err := os.Stat(local); err == nil && !st.IsDir() {
|
||||
if err := ensureExecutable(local); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return local, nil
|
||||
}
|
||||
if !ref.Permissions.SystemExec || !r.allowSystemExec {
|
||||
return "", fmt.Errorf("Executable %q ist nicht im Skill-Verzeichnis; system_exec ist nicht freigegeben", raw)
|
||||
}
|
||||
p, err := exec.LookPath(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func ensureExecutable(path string) error {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errors.New("runtime.command ist ein Verzeichnis")
|
||||
}
|
||||
if st.Mode().Perm()&0o111 == 0 {
|
||||
return fmt.Errorf("runtime.command ist nicht ausführbar: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathWithin(base, target string) bool {
|
||||
b, err1 := filepath.Abs(base)
|
||||
t, err2 := filepath.Abs(target)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(b, t)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func (r *skillRegistry) skillEnvironment(ref skillActionRef, requestID string, ctx context.Context) []string {
|
||||
keep := map[string]string{
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"TZ": r.app.timezone.String(),
|
||||
"JARVIS_SKILL_PROTOCOL": skillProtocolV1,
|
||||
"JARVIS_SKILL_ID": ref.SkillID,
|
||||
"JARVIS_SKILL_ACTION": ref.Action.Name,
|
||||
"JARVIS_SKILL_REQUEST_ID": requestID,
|
||||
"JARVIS_SKILL_TRACE_ID": traceIDFromContext(ctx),
|
||||
}
|
||||
for k, v := range ref.Runtime.Env {
|
||||
key := strings.TrimSpace(k)
|
||||
// Never let a manifest override JARVIS control variables or smuggle
|
||||
// process-wide secrets from the parent. Values are literal manifest data.
|
||||
if key == "" || strings.HasPrefix(strings.ToUpper(key), "JARVIS_SKILL_") {
|
||||
continue
|
||||
}
|
||||
keep[key] = v
|
||||
}
|
||||
// Optional parity with remote workers: only explicitly named variables are
|
||||
// inherited from the parent process. This keeps secrets out of skill.json.
|
||||
for _, rawKey := range ref.Runtime.EnvFrom {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" || strings.HasPrefix(strings.ToUpper(key), "JARVIS_") {
|
||||
continue
|
||||
}
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
keep[key] = v
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(keep))
|
||||
for k := range keep {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+keep[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type limitedBuffer struct {
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
overflow bool
|
||||
}
|
||||
|
||||
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
||||
if b.max <= 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
remain := b.max - b.buf.Len()
|
||||
if remain <= 0 {
|
||||
b.overflow = true
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > remain {
|
||||
_, _ = b.buf.Write(p[:remain])
|
||||
b.overflow = true
|
||||
return len(p), nil
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
func (b *limitedBuffer) String() string { return b.buf.String() }
|
||||
|
||||
func traceIDFromContext(ctx context.Context) string { return debugtrace.TraceID(ctx) }
|
||||
|
||||
func clipSkillString(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
func validateSchemaDefinition(schema map[string]any) error {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
typ, _ := schema["type"].(string)
|
||||
if typ == "" {
|
||||
return errors.New("type fehlt")
|
||||
}
|
||||
switch typ {
|
||||
case "object", "array", "string", "number", "integer", "boolean", "null":
|
||||
default:
|
||||
return fmt.Errorf("type %q wird nicht unterstützt", typ)
|
||||
}
|
||||
if typ == "object" {
|
||||
if props, ok := schema["properties"].(map[string]any); ok {
|
||||
for name, raw := range props {
|
||||
child, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("property %s ist kein Schema-Objekt", name)
|
||||
}
|
||||
if err := validateSchemaDefinition(child); err != nil {
|
||||
return fmt.Errorf("property %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if typ == "array" {
|
||||
if raw, ok := schema["items"]; ok {
|
||||
child, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("items ist kein Schema-Objekt")
|
||||
}
|
||||
if err := validateSchemaDefinition(child); err != nil {
|
||||
return fmt.Errorf("items: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONAgainstSchema(value any, schema map[string]any, path string) error {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
typ, _ := schema["type"].(string)
|
||||
if typ == "" {
|
||||
return fmt.Errorf("%s: Schema ohne type", path)
|
||||
}
|
||||
switch typ {
|
||||
case "object":
|
||||
m, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
// Values decoded from typed data may not be map[string]any; normalize.
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: object erwartet", path)
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return fmt.Errorf("%s: object erwartet", path)
|
||||
}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
required := stringSet(schema["required"])
|
||||
for req := range required {
|
||||
if _, exists := m[req]; !exists {
|
||||
return fmt.Errorf("%s.%s fehlt", path, req)
|
||||
}
|
||||
}
|
||||
if ap, ok := schema["additionalProperties"].(bool); ok && !ap {
|
||||
for k := range m {
|
||||
if _, exists := props[k]; !exists {
|
||||
return fmt.Errorf("%s.%s ist nicht erlaubt", path, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range m {
|
||||
if raw, exists := props[k]; exists {
|
||||
if child, ok := raw.(map[string]any); ok {
|
||||
if err := validateJSONAgainstSchema(v, child, path+"."+k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "array":
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: array erwartet", path)
|
||||
}
|
||||
if err := json.Unmarshal(b, &arr); err != nil {
|
||||
return fmt.Errorf("%s: array erwartet", path)
|
||||
}
|
||||
}
|
||||
if raw, ok := schema["items"].(map[string]any); ok {
|
||||
for i, v := range arr {
|
||||
if err := validateJSONAgainstSchema(v, raw, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case "string":
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: string erwartet", path)
|
||||
}
|
||||
if vals, ok := schema["enum"].([]any); ok && len(vals) > 0 {
|
||||
found := false
|
||||
for _, v := range vals {
|
||||
if fmt.Sprint(v) == s {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("%s: Wert %q ist nicht erlaubt", path, s)
|
||||
}
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("%s: boolean erwartet", path)
|
||||
}
|
||||
case "number", "integer":
|
||||
n, ok := jsonNumber(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: Zahl erwartet", path)
|
||||
}
|
||||
if typ == "integer" && n != float64(int64(n)) {
|
||||
return fmt.Errorf("%s: Ganzzahl erwartet", path)
|
||||
}
|
||||
if min, ok := numberFromAny(schema["minimum"]); ok && n < min {
|
||||
return fmt.Errorf("%s: muss >= %v sein", path, min)
|
||||
}
|
||||
if max, ok := numberFromAny(schema["maximum"]); ok && n > max {
|
||||
return fmt.Errorf("%s: muss <= %v sein", path, max)
|
||||
}
|
||||
case "null":
|
||||
if value != nil {
|
||||
return fmt.Errorf("%s: null erwartet", path)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s: nicht unterstützter Schema-Typ %q", path, typ)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringSet(v any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
if s, ok := item.(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, s := range x {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func jsonNumber(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case float32:
|
||||
return float64(n), true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int8:
|
||||
return float64(n), true
|
||||
case int16:
|
||||
return float64(n), true
|
||||
case int32:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
case uint:
|
||||
return float64(n), true
|
||||
case uint8:
|
||||
return float64(n), true
|
||||
case uint16:
|
||||
return float64(n), true
|
||||
case uint32:
|
||||
return float64(n), true
|
||||
case uint64:
|
||||
return float64(n), true
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
return f, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
func numberFromAny(v any) (float64, bool) {
|
||||
if v == nil {
|
||||
return 0, false
|
||||
}
|
||||
if f, ok := jsonNumber(v); ok {
|
||||
return f, true
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return f, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Ensure compile-time awareness that limitedBuffer is an io.Writer.
|
||||
var _ io.Writer = (*limitedBuffer)(nil)
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeSkillFixture(t *testing.T, root, name, manifest, runner string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := filepath.Join(dir, "run")
|
||||
if err := os.WriteFile(run, []byte(runner), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestSkillRegistryLoadsAndExecutesProcessSkill(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkillFixture(t, root, "echo", `{
|
||||
"protocol":"jarvis.skill.v1",
|
||||
"id":"test.echo",
|
||||
"name":"Echo Test",
|
||||
"version":"1.0.0",
|
||||
"runtime":{"type":"process","command":"./run","timeout_ms":2000},
|
||||
"actions":[{
|
||||
"name":"echo","description":"Echo input","triggers":["echo skill"],"mutates":false,
|
||||
"input_schema":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"]},
|
||||
"output_schema":{"type":"object","additionalProperties":false,"properties":{"echo":{"type":"string"}},"required":["echo"]}
|
||||
}]
|
||||
}`, `#!/bin/sh
|
||||
cat >/dev/null
|
||||
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"echo":"ok"},"message":"Echo OK"}'
|
||||
`)
|
||||
a := &app{timezone: berlin(t)}
|
||||
reg := newSkillRegistry(a, nil, root)
|
||||
tool := "skill_test_echo_echo"
|
||||
if !reg.Has(tool) {
|
||||
t.Fatalf("skill %s not loaded: %+v", tool, reg.Status())
|
||||
}
|
||||
if !reg.MayApply("bitte den echo skill verwenden") {
|
||||
t.Fatal("trigger not matched")
|
||||
}
|
||||
res := reg.Execute(context.Background(), tool, map[string]any{"text": "Hallo"})
|
||||
if !res.Success || res.Message != "Echo OK" {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
data, ok := res.Data.(map[string]any)
|
||||
if !ok || data["echo"] != "ok" {
|
||||
t.Fatalf("data=%#v", res.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInputSchemaRejectsBeforeProcess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
marker := filepath.Join(root, "ran")
|
||||
manifest := `{
|
||||
"protocol":"jarvis.skill.v1","id":"test.validate","name":"Validate","version":"1",
|
||||
"runtime":{"type":"process","command":"./run"},
|
||||
"actions":[{"name":"run","description":"x","input_schema":{"type":"object","additionalProperties":false,"properties":{"count":{"type":"integer","minimum":1}},"required":["count"]}}]
|
||||
}`
|
||||
runner := "#!/bin/sh\ntouch " + marker + "\nprintf '%s\\n' '{\"protocol\":\"jarvis.skill.v1\",\"success\":true}'\n"
|
||||
writeSkillFixture(t, root, "validate", manifest, runner)
|
||||
a := &app{timezone: berlin(t)}
|
||||
reg := newSkillRegistry(a, nil, root)
|
||||
res := reg.Execute(context.Background(), "skill_test_validate_run", map[string]any{"count": 0})
|
||||
if res.Success || res.Error == nil || res.Error.Code != "INVALID_SKILL_INPUT" {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("process should not run, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillOutputSchemaRejectsInvalidData(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkillFixture(t, root, "badout", `{
|
||||
"protocol":"jarvis.skill.v1","id":"test.badout","name":"Bad Output","version":"1",
|
||||
"runtime":{"type":"process","command":"./run"},
|
||||
"actions":[{"name":"run","description":"x","input_schema":{"type":"object","properties":{},"additionalProperties":false},"output_schema":{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"],"additionalProperties":false}}]
|
||||
}`, `#!/bin/sh
|
||||
cat >/dev/null
|
||||
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"value":"wrong"}}'
|
||||
`)
|
||||
a := &app{timezone: berlin(t)}
|
||||
reg := newSkillRegistry(a, nil, root)
|
||||
res := reg.Execute(context.Background(), "skill_test_badout_run", map[string]any{})
|
||||
if res.Success || res.Error == nil || res.Error.Code != "INVALID_SKILL_OUTPUT" {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSystemExecutableDeniedByDefault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkillFixture(t, root, "system", `{
|
||||
"protocol":"jarvis.skill.v1","id":"test.system","name":"System","version":"1",
|
||||
"runtime":{"type":"process","command":"/bin/sh","args":["-c","printf x"]},
|
||||
"permissions":{"system_exec":true},
|
||||
"actions":[{"name":"run","description":"x","input_schema":{"type":"object","properties":{},"additionalProperties":false}}]
|
||||
}`, "#!/bin/sh\nexit 0\n")
|
||||
t.Setenv("JARVIS_SKILL_ALLOW_SYSTEM_EXEC", "false")
|
||||
a := &app{timezone: berlin(t)}
|
||||
reg := newSkillRegistry(a, nil, root)
|
||||
res := reg.Execute(context.Background(), "skill_test_system_run", map[string]any{})
|
||||
if res.Success || res.Error == nil || !strings.Contains(res.Error.Code, "SKILL_EXEC_DENIED") {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolAgentCanSelectPluginSkill(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkillFixture(t, root, "echo", `{
|
||||
"protocol":"jarvis.skill.v1","id":"test.echo","name":"Echo Test","version":"1",
|
||||
"runtime":{"type":"process","command":"./run"},
|
||||
"actions":[{"name":"echo","description":"Echo","triggers":["plugin echo"],"input_schema":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"]},"output_schema":{"type":"object","additionalProperties":false,"properties":{"echo":{"type":"string"}},"required":["echo"]}}]
|
||||
}`, `#!/bin/sh
|
||||
cat >/dev/null
|
||||
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"echo":"plugin-ok"},"message":"Plugin ausgeführt."}'
|
||||
`)
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if _, ok := body["tools"]; ok {
|
||||
if calls.Add(1) == 1 {
|
||||
tools, _ := body["tools"].([]any)
|
||||
found := false
|
||||
for _, raw := range tools {
|
||||
m, _ := raw.(map[string]any)
|
||||
fn, _ := m["function"].(map[string]any)
|
||||
if fn["name"] == "skill_test_echo_echo" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("plugin skill missing from Ollama tool catalog")
|
||||
}
|
||||
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "skill_test_echo_echo", "arguments": map[string]any{"text": "Hallo"}}}}})
|
||||
return
|
||||
}
|
||||
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Plugin-Ergebnis gelesen."})
|
||||
return
|
||||
}
|
||||
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "{}"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
a := toolTestApp(t, srv.URL)
|
||||
a.skills = newSkillRegistry(a, a.tools, root)
|
||||
out, err := a.runToolAgent(context.Background(), "nutze plugin echo für Hallo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Results) != 1 || !out.Results[0].Success || out.Results[0].Tool != "skill_test_echo_echo" {
|
||||
t.Fatalf("out=%+v", out)
|
||||
}
|
||||
if out.Results[0].Message != "Plugin ausgeführt." {
|
||||
t.Fatalf("result=%+v", out.Results[0])
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func toolTestApp(t *testing.T, serverURL string) *app {
|
||||
ai.ConfigureGeneration(cfg.chatOptions(), cfg.plannerOptions())
|
||||
a := &app{db: db, ai: ai, rag: idx, timezone: berlin(t), context: cfg}
|
||||
a.tools = newToolRegistry(a)
|
||||
a.skills = newSkillRegistry(a, a.tools, filepath.Join(t.TempDir(), "skills"))
|
||||
return a
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type toolResult struct {
|
||||
Tool string `json:"tool"`
|
||||
RequestID string `json:"request_id"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error *toolError `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Mutated bool `json:"mutated,omitempty"`
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const skillProtocol = "jarvis.skill.v1"
|
||||
const workerProtocol = "jarvis.skill.worker.v1"
|
||||
const invokeProtocol = "jarvis.skill.invoke.v1"
|
||||
|
||||
type runtimeSpec struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
TimeoutMS int `json:"timeout_ms,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
EnvFrom []string `json:"env_from,omitempty"`
|
||||
}
|
||||
type permissions struct {
|
||||
Network bool `json:"network,omitempty"`
|
||||
SystemExec bool `json:"system_exec,omitempty"`
|
||||
}
|
||||
type actionManifest struct {
|
||||
Name string `json:"name"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"input_schema"`
|
||||
OutputSchema map[string]any `json:"output_schema,omitempty"`
|
||||
Mutates bool `json:"mutates,omitempty"`
|
||||
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
|
||||
Triggers []string `json:"triggers,omitempty"`
|
||||
}
|
||||
type manifest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Runtime runtimeSpec `json:"runtime"`
|
||||
Permissions permissions `json:"permissions,omitempty"`
|
||||
Actions []actionManifest `json:"actions"`
|
||||
}
|
||||
type skillRef struct {
|
||||
Manifest manifest
|
||||
Action actionManifest
|
||||
Dir string
|
||||
}
|
||||
type publicSkill struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Actions []actionManifest `json:"actions"`
|
||||
}
|
||||
type invokeContext struct {
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Now string `json:"now"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
type invokeRequest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
RequestID string `json:"request_id"`
|
||||
SkillID string `json:"skill_id"`
|
||||
Action string `json:"action"`
|
||||
Input map[string]any `json:"input"`
|
||||
Context invokeContext `json:"context"`
|
||||
}
|
||||
type wireError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type invokeResponse struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error *wireError `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Mutated *bool `json:"mutated,omitempty"`
|
||||
Entity any `json:"entity,omitempty"`
|
||||
}
|
||||
|
||||
type worker struct {
|
||||
mu sync.RWMutex
|
||||
skills map[string]manifest
|
||||
actions map[string]skillRef
|
||||
accessToken string
|
||||
workerID string
|
||||
lease time.Duration
|
||||
master string
|
||||
enrollment string
|
||||
name string
|
||||
runtime string
|
||||
runtimeVersion string
|
||||
publicURL string
|
||||
skillsDir string
|
||||
workDir string
|
||||
allowSystemExec bool
|
||||
maxOutput int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
w := &worker{master: trimURL(env("JARVIS_MASTER_URL", "http://jarvis:8080")), enrollment: strings.TrimSpace(os.Getenv("JARVIS_ENROLLMENT_TOKEN")), name: env("JARVIS_WORKER_NAME", "skill-worker"), runtime: env("JARVIS_WORKER_RUNTIME", "generic"), runtimeVersion: env("JARVIS_WORKER_RUNTIME_VERSION", ""), publicURL: trimURL(env("JARVIS_WORKER_PUBLIC_URL", "http://localhost:8090")), skillsDir: env("JARVIS_SKILLS_DIR", "/skills"), allowSystemExec: envBool("JARVIS_WORKER_ALLOW_SYSTEM_EXEC", true), maxOutput: envInt("JARVIS_WORKER_MAX_OUTPUT_KB", 1024) * 1024, client: &http.Client{Timeout: 15 * time.Second}, skills: map[string]manifest{}, actions: map[string]skillRef{}, lease: 30 * time.Second}
|
||||
if err := w.reload(); err != nil {
|
||||
log.Printf("Skill load warning: %v", err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", w.health)
|
||||
mux.HandleFunc("GET /v1/skills", w.listSkills)
|
||||
mux.HandleFunc("POST /v1/invoke", w.invoke)
|
||||
mux.HandleFunc("POST /v1/reload", w.reloadHTTP)
|
||||
srv := &http.Server{Addr: env("JARVIS_WORKER_ADDR", ":8090"), Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 180 * time.Second}
|
||||
go func() {
|
||||
log.Printf("JARVIS Skill Worker %s runtime=%s listening %s", w.name, w.runtime, srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go w.controlLoop(ctx)
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-ch
|
||||
cancel()
|
||||
w.deregister()
|
||||
sd, sdCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer sdCancel()
|
||||
_ = srv.Shutdown(sd)
|
||||
}
|
||||
|
||||
func (w *worker) controlLoop(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := w.enrollAndRegister(ctx); err != nil {
|
||||
log.Printf("Enrollment: %v", err)
|
||||
if !sleepCtx(ctx, 5*time.Second) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
for {
|
||||
w.mu.RLock()
|
||||
lease := w.lease
|
||||
w.mu.RUnlock()
|
||||
wait := lease / 3
|
||||
if wait < 5*time.Second {
|
||||
wait = 5 * time.Second
|
||||
}
|
||||
if !sleepCtx(ctx, wait) {
|
||||
return
|
||||
}
|
||||
if err := w.heartbeat(ctx); err != nil {
|
||||
log.Printf("Heartbeat: %v; erneutes Enrollment", err)
|
||||
w.mu.Lock()
|
||||
w.accessToken = ""
|
||||
w.workerID = ""
|
||||
w.mu.Unlock()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := w.enrollAndRegister(ctx); err == nil {
|
||||
break
|
||||
}
|
||||
if !sleepCtx(ctx, 5*time.Second) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(d):
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (w *worker) enrollAndRegister(ctx context.Context) error {
|
||||
if w.enrollment == "" {
|
||||
return errors.New("JARVIS_ENROLLMENT_TOKEN fehlt")
|
||||
}
|
||||
in := map[string]any{"protocol": workerProtocol, "enrollment_token": w.enrollment, "worker": map[string]any{"name": w.name, "runtime": w.runtime, "runtime_version": w.runtimeVersion, "endpoint": w.publicURL, "labels": map[string]string{"runtime": w.runtime}}}
|
||||
var out struct {
|
||||
Protocol string `json:"protocol"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
LeaseSeconds int `json:"lease_seconds"`
|
||||
}
|
||||
if err := w.doJSON(ctx, http.MethodPost, w.master+"/api/mesh/enroll", "", in, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.WorkerID == "" || out.AccessToken == "" {
|
||||
return errors.New("Master lieferte keine Worker-ID/Token")
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.workerID = out.WorkerID
|
||||
w.accessToken = out.AccessToken
|
||||
if out.LeaseSeconds > 0 {
|
||||
w.lease = time.Duration(out.LeaseSeconds) * time.Second
|
||||
}
|
||||
w.mu.Unlock()
|
||||
return w.register(ctx)
|
||||
}
|
||||
func (w *worker) register(ctx context.Context) error {
|
||||
w.mu.RLock()
|
||||
idv, tok := w.workerID, w.accessToken
|
||||
skills := w.publicSkillsLocked()
|
||||
w.mu.RUnlock()
|
||||
in := map[string]any{"protocol": workerProtocol, "skills": skills}
|
||||
var out map[string]any
|
||||
if err := w.doJSON(ctx, http.MethodPut, w.master+"/api/mesh/workers/"+url.PathEscape(idv)+"/skills", tok, in, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, exists := out["ok"].(bool); exists && !ok {
|
||||
b, _ := json.Marshal(out["issues"])
|
||||
return fmt.Errorf("Master hat Skill-Registrierung abgelehnt: %s", string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *worker) heartbeat(ctx context.Context) error {
|
||||
w.mu.RLock()
|
||||
idv, tok := w.workerID, w.accessToken
|
||||
skillCount := len(w.skills)
|
||||
w.mu.RUnlock()
|
||||
if idv == "" || tok == "" {
|
||||
return errors.New("not enrolled")
|
||||
}
|
||||
var out map[string]any
|
||||
return w.doJSON(ctx, http.MethodPost, w.master+"/api/mesh/workers/"+url.PathEscape(idv)+"/heartbeat", tok, map[string]any{"protocol": workerProtocol, "status": map[string]any{"skills": skillCount}}, &out)
|
||||
}
|
||||
func (w *worker) deregister() {
|
||||
w.mu.RLock()
|
||||
idv, tok := w.workerID, w.accessToken
|
||||
w.mu.RUnlock()
|
||||
if idv == "" || tok == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = w.doJSON(ctx, http.MethodDelete, w.master+"/api/mesh/workers/"+url.PathEscape(idv), tok, nil, nil)
|
||||
}
|
||||
func (w *worker) doJSON(ctx context.Context, method, endpoint, token string, in, out any) error {
|
||||
var rd io.Reader
|
||||
if in != nil {
|
||||
b, _ := json.Marshal(in)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, clip(string(raw), 800))
|
||||
}
|
||||
if out != nil && len(raw) > 0 {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *worker) reload() error {
|
||||
entries, err := os.ReadDir(w.skillsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
_ = os.MkdirAll(w.skillsDir, 0o755)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
type candidate struct{ name, dir string }
|
||||
candidates := []candidate{}
|
||||
if st, err := os.Stat(filepath.Join(w.skillsDir, "skill.json")); err == nil && !st.IsDir() {
|
||||
candidates = append(candidates, candidate{name: "root", dir: w.skillsDir})
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") || strings.HasPrefix(e.Name(), "_") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(w.skillsDir, e.Name())
|
||||
if st, err := os.Stat(filepath.Join(dir, "skill.json")); err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{name: e.Name(), dir: dir})
|
||||
}
|
||||
|
||||
skills := map[string]manifest{}
|
||||
actions := map[string]skillRef{}
|
||||
issues := []string{}
|
||||
for _, c := range candidates {
|
||||
dir := c.dir
|
||||
execDir := dir
|
||||
if strings.TrimSpace(w.workDir) != "" {
|
||||
target := filepath.Join(w.workDir, c.name)
|
||||
_ = os.RemoveAll(target)
|
||||
if err := copySkillDir(dir, target); err != nil {
|
||||
issues = append(issues, c.name+": workdir copy: "+err.Error())
|
||||
continue
|
||||
}
|
||||
execDir = target
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "skill.json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m manifest
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
issues = append(issues, c.name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if m.Protocol == "" {
|
||||
m.Protocol = skillProtocol
|
||||
}
|
||||
if m.Protocol != skillProtocol || m.ID == "" || m.Name == "" || len(m.Actions) == 0 {
|
||||
issues = append(issues, c.name+": manifest ungültig")
|
||||
continue
|
||||
}
|
||||
if m.Enabled != nil && !*m.Enabled {
|
||||
continue
|
||||
}
|
||||
if m.Runtime.Type == "" {
|
||||
m.Runtime.Type = "process"
|
||||
}
|
||||
if m.Runtime.Type != "process" || strings.TrimSpace(m.Runtime.Command) == "" {
|
||||
issues = append(issues, m.ID+": runtime.command fehlt")
|
||||
continue
|
||||
}
|
||||
good := true
|
||||
for i := range m.Actions {
|
||||
a := &m.Actions[i]
|
||||
if a.Name == "" {
|
||||
good = false
|
||||
break
|
||||
}
|
||||
if a.InputSchema == nil {
|
||||
a.InputSchema = map[string]any{"type": "object", "properties": map[string]any{}, "additionalProperties": false}
|
||||
}
|
||||
key := m.ID + "\x00" + a.Name
|
||||
actions[key] = skillRef{Manifest: m, Action: *a, Dir: execDir}
|
||||
}
|
||||
if good {
|
||||
skills[m.ID] = m
|
||||
}
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.skills = skills
|
||||
w.actions = actions
|
||||
w.mu.Unlock()
|
||||
if len(issues) > 0 {
|
||||
return errors.New(strings.Join(issues, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *worker) publicSkillsLocked() []publicSkill {
|
||||
out := make([]publicSkill, 0, len(w.skills))
|
||||
for _, m := range w.skills {
|
||||
acts := make([]actionManifest, len(m.Actions))
|
||||
copy(acts, m.Actions)
|
||||
for i := range acts {
|
||||
acts[i].ToolName = strings.TrimSpace(acts[i].ToolName)
|
||||
}
|
||||
out = append(out, publicSkill{Protocol: skillProtocol, ID: m.ID, Name: m.Name, Version: m.Version, Description: m.Description, Actions: acts})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *worker) health(rw http.ResponseWriter, r *http.Request) {
|
||||
w.mu.RLock()
|
||||
out := map[string]any{"ok": true, "protocol": workerProtocol, "worker_id": w.workerID, "name": w.name, "runtime": w.runtime, "runtime_version": w.runtimeVersion, "enrolled": w.accessToken != "", "skills": len(w.skills), "actions": len(w.actions)}
|
||||
w.mu.RUnlock()
|
||||
writeJSON(rw, 200, out)
|
||||
}
|
||||
func (w *worker) listSkills(rw http.ResponseWriter, r *http.Request) {
|
||||
w.mu.RLock()
|
||||
out := w.publicSkillsLocked()
|
||||
w.mu.RUnlock()
|
||||
writeJSON(rw, 200, map[string]any{"protocol": workerProtocol, "skills": out})
|
||||
}
|
||||
func (w *worker) reloadHTTP(rw http.ResponseWriter, r *http.Request) {
|
||||
if !w.authorized(r) {
|
||||
http.Error(rw, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
err := w.reload()
|
||||
if err == nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
_ = w.register(ctx)
|
||||
cancel()
|
||||
}
|
||||
writeJSON(rw, 200, map[string]any{"ok": err == nil, "error": errString(err)})
|
||||
}
|
||||
func (w *worker) authorized(r *http.Request) bool {
|
||||
w.mu.RLock()
|
||||
tok := w.accessToken
|
||||
w.mu.RUnlock()
|
||||
return tok != "" && strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) == tok && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
|
||||
func (w *worker) invoke(rw http.ResponseWriter, r *http.Request) {
|
||||
if !w.authorized(r) {
|
||||
http.Error(rw, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
var in invokeRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&in); err != nil {
|
||||
http.Error(rw, "invalid json", 400)
|
||||
return
|
||||
}
|
||||
if in.Protocol != "" && in.Protocol != invokeProtocol && in.Protocol != skillProtocol {
|
||||
writeJSON(rw, 400, fail(in.RequestID, "PROTOCOL_MISMATCH", "unsupported protocol"))
|
||||
return
|
||||
}
|
||||
key := in.SkillID + "\x00" + in.Action
|
||||
w.mu.RLock()
|
||||
ref, ok := w.actions[key]
|
||||
w.mu.RUnlock()
|
||||
if !ok {
|
||||
writeJSON(rw, 404, fail(in.RequestID, "SKILL_NOT_FOUND", "Skill action not found"))
|
||||
return
|
||||
}
|
||||
if in.Input == nil {
|
||||
in.Input = map[string]any{}
|
||||
}
|
||||
if err := validate(in.Input, ref.Action.InputSchema, "input"); err != nil {
|
||||
writeJSON(rw, 400, fail(in.RequestID, "INVALID_SKILL_INPUT", err.Error()))
|
||||
return
|
||||
}
|
||||
res := w.execute(r.Context(), ref, in)
|
||||
writeJSON(rw, 200, res)
|
||||
}
|
||||
func (w *worker) execute(ctx context.Context, ref skillRef, in invokeRequest) invokeResponse {
|
||||
timeout := 5 * time.Second
|
||||
if ref.Manifest.Runtime.TimeoutMS > 0 {
|
||||
timeout = time.Duration(ref.Manifest.Runtime.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
if timeout > 5*time.Minute {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
call, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmdPath, err := w.resolveCommand(ref)
|
||||
if err != nil {
|
||||
return fail(in.RequestID, "SKILL_EXEC_DENIED", err.Error())
|
||||
}
|
||||
payload, _ := json.Marshal(in)
|
||||
cmd := exec.CommandContext(call, cmdPath, ref.Manifest.Runtime.Args...)
|
||||
cmd.Dir = ref.Dir
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
cmd.Env = w.environment(ref, in)
|
||||
stdout := &limitBuffer{max: w.maxOutput}
|
||||
stderr := &limitBuffer{max: min(w.maxOutput/4, 256*1024)}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
err = cmd.Run()
|
||||
if call.Err() == context.DeadlineExceeded {
|
||||
return fail(in.RequestID, "SKILL_TIMEOUT", "Skill timed out")
|
||||
}
|
||||
if stdout.overflow {
|
||||
return fail(in.RequestID, "SKILL_OUTPUT_LIMIT", "Skill output too large")
|
||||
}
|
||||
if err != nil {
|
||||
return fail(in.RequestID, "SKILL_PROCESS_FAILED", err.Error()+optionalStderr(stderr.String()))
|
||||
}
|
||||
raw := strings.TrimSpace(stdout.String())
|
||||
if raw == "" {
|
||||
return fail(in.RequestID, "SKILL_EMPTY_OUTPUT", "Skill returned empty output")
|
||||
}
|
||||
var res invokeResponse
|
||||
if err := json.Unmarshal([]byte(raw), &res); err != nil {
|
||||
return fail(in.RequestID, "SKILL_INVALID_OUTPUT", err.Error())
|
||||
}
|
||||
if res.Protocol == "" {
|
||||
res.Protocol = invokeProtocol
|
||||
}
|
||||
if res.Success && ref.Action.OutputSchema != nil {
|
||||
if err := validate(res.Data, ref.Action.OutputSchema, "data"); err != nil {
|
||||
return fail(in.RequestID, "INVALID_SKILL_OUTPUT", err.Error())
|
||||
}
|
||||
}
|
||||
if res.Success && ref.Action.Mutates {
|
||||
if res.Mutated == nil {
|
||||
v := true
|
||||
res.Mutated = &v
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
func optionalStderr(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return ": " + clip(s, 1200)
|
||||
}
|
||||
func (w *worker) resolveCommand(ref skillRef) (string, error) {
|
||||
raw := strings.TrimSpace(ref.Manifest.Runtime.Command)
|
||||
if filepath.IsAbs(raw) {
|
||||
if !w.allowSystemExec || !ref.Manifest.Permissions.SystemExec {
|
||||
return "", errors.New("absolute system exec denied")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
if strings.Contains(raw, "/") || strings.HasPrefix(raw, ".") {
|
||||
p := filepath.Clean(filepath.Join(ref.Dir, raw))
|
||||
base, _ := filepath.Abs(ref.Dir)
|
||||
abs, _ := filepath.Abs(p)
|
||||
if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
|
||||
return "", errors.New("command escapes skill dir")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
local := filepath.Join(ref.Dir, raw)
|
||||
if st, err := os.Stat(local); err == nil && !st.IsDir() {
|
||||
return local, nil
|
||||
}
|
||||
if !w.allowSystemExec || !ref.Manifest.Permissions.SystemExec {
|
||||
return "", fmt.Errorf("system executable %q denied", raw)
|
||||
}
|
||||
return exec.LookPath(raw)
|
||||
}
|
||||
func (w *worker) environment(ref skillRef, in invokeRequest) []string {
|
||||
m := map[string]string{"PATH": os.Getenv("PATH"), "LANG": "C.UTF-8", "TZ": in.Context.Timezone, "JARVIS_SKILL_PROTOCOL": skillProtocol, "JARVIS_SKILL_ID": ref.Manifest.ID, "JARVIS_SKILL_ACTION": ref.Action.Name, "JARVIS_SKILL_REQUEST_ID": in.RequestID, "JARVIS_SKILL_TRACE_ID": in.Context.TraceID, "JARVIS_WORKER_RUNTIME": w.runtime}
|
||||
for _, key := range []string{"HOME", "TMPDIR", "GOCACHE", "GOMODCACHE", "CARGO_HOME", "CARGO_TARGET_DIR", "DOTNET_CLI_HOME", "NUGET_PACKAGES", "npm_config_cache", "PYTHONPATH", "PYTHONUNBUFFERED"} {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
m[key] = v
|
||||
}
|
||||
}
|
||||
for k, v := range ref.Manifest.Runtime.Env {
|
||||
if strings.HasPrefix(strings.ToUpper(k), "JARVIS_") {
|
||||
continue
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
// Secrets/config stay in the worker container and are only exposed to a
|
||||
// skill when the manifest explicitly allowlists the variable name.
|
||||
for _, rawKey := range ref.Manifest.Runtime.EnvFrom {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" || strings.HasPrefix(strings.ToUpper(key), "JARVIS_") {
|
||||
continue
|
||||
}
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
m[key] = v
|
||||
}
|
||||
}
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
out := []string{}
|
||||
for _, k := range ks {
|
||||
out = append(out, k+"="+m[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copySkillDir(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks sind im Skill-Verzeichnis nicht erlaubt: %s", rel)
|
||||
}
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode().Perm())
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("nicht reguläre Datei im Skill: %s", rel)
|
||||
}
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, cpErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if cpErr != nil {
|
||||
return cpErr
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
}
|
||||
|
||||
type limitBuffer struct {
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
overflow bool
|
||||
}
|
||||
|
||||
func (b *limitBuffer) Write(p []byte) (int, error) {
|
||||
remain := b.max - b.buf.Len()
|
||||
if remain <= 0 {
|
||||
b.overflow = true
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > remain {
|
||||
b.buf.Write(p[:remain])
|
||||
b.overflow = true
|
||||
return len(p), nil
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
func (b *limitBuffer) String() string { return b.buf.String() }
|
||||
|
||||
func validate(v any, s map[string]any, path string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
typ, _ := s["type"].(string)
|
||||
switch typ {
|
||||
case "object":
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s muss object sein", path)
|
||||
}
|
||||
req, _ := s["required"].([]any)
|
||||
for _, x := range req {
|
||||
k, _ := x.(string)
|
||||
if _, ok := m[k]; !ok {
|
||||
return fmt.Errorf("%s.%s fehlt", path, k)
|
||||
}
|
||||
}
|
||||
if rr, ok := s["required"].([]string); ok {
|
||||
for _, k := range rr {
|
||||
if _, ok := m[k]; !ok {
|
||||
return fmt.Errorf("%s.%s fehlt", path, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
props, _ := s["properties"].(map[string]any)
|
||||
for k, val := range m {
|
||||
raw, exists := props[k]
|
||||
if !exists {
|
||||
if ap, ok := s["additionalProperties"].(bool); ok && !ap {
|
||||
return fmt.Errorf("%s.%s ist nicht erlaubt", path, k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
child, _ := raw.(map[string]any)
|
||||
if err := validate(val, child, path+"."+k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "array":
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s muss array sein", path)
|
||||
}
|
||||
if item, ok := s["items"].(map[string]any); ok {
|
||||
for i, x := range arr {
|
||||
if err := validate(x, item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case "string":
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("%s muss string sein", path)
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := v.(bool); !ok {
|
||||
return fmt.Errorf("%s muss boolean sein", path)
|
||||
}
|
||||
case "integer":
|
||||
f, ok := number(v)
|
||||
if !ok || f != float64(int64(f)) {
|
||||
return fmt.Errorf("%s muss integer sein", path)
|
||||
}
|
||||
case "number":
|
||||
if _, ok := number(v); !ok {
|
||||
return fmt.Errorf("%s muss number sein", path)
|
||||
}
|
||||
}
|
||||
if en, ok := s["enum"].([]any); ok {
|
||||
found := false
|
||||
for _, x := range en {
|
||||
if fmt.Sprint(x) == fmt.Sprint(v) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("%s außerhalb enum", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func number(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case json.Number:
|
||||
f, e := x.Float64()
|
||||
return f, e == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func fail(idv, code, msg string) invokeResponse {
|
||||
return invokeResponse{Protocol: invokeProtocol, Success: false, Error: &wireError{Code: code, Message: msg}}
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func env(k, d string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func envInt(k string, d int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
|
||||
if err == nil {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func envBool(k string, d bool) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(os.Getenv(k)))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
func trimURL(s string) string { return strings.TrimRight(strings.TrimSpace(s), "/") }
|
||||
func clip(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
func errString(e error) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Error()
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkerReloadCopiesReadOnlySkillAndExecutes(t *testing.T) {
|
||||
skills := t.TempDir()
|
||||
work := t.TempDir()
|
||||
dir := filepath.Join(skills, "demo")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestJSON := `{
|
||||
"protocol":"jarvis.skill.v1",
|
||||
"id":"test.remote",
|
||||
"name":"Remote test",
|
||||
"version":"1.0.0",
|
||||
"runtime":{"type":"process","command":"./run","timeout_ms":5000},
|
||||
"actions":[{
|
||||
"name":"echo",
|
||||
"description":"echo",
|
||||
"input_schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false},
|
||||
"output_schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(manifestJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := `#!/bin/sh
|
||||
python3 -c 'import json,sys; r=json.load(sys.stdin); json.dump({"protocol":"jarvis.skill.invoke.v1","success":True,"data":{"text":r["input"]["text"]}},sys.stdout)'
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "run"), []byte(run), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := &worker{skillsDir: skills, workDir: work, maxOutput: 1 << 20, runtime: "test", skills: map[string]manifest{}, actions: map[string]skillRef{}}
|
||||
if err := w.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, ok := w.actions["test.remote\x00echo"]
|
||||
if !ok {
|
||||
t.Fatalf("action not loaded")
|
||||
}
|
||||
if filepath.Dir(ref.Dir) != work {
|
||||
t.Fatalf("skill did not execute from isolated work copy: %s", ref.Dir)
|
||||
}
|
||||
res := w.execute(context.Background(), ref, invokeRequest{Protocol: invokeProtocol, RequestID: "x", SkillID: "test.remote", Action: "echo", Input: map[string]any{"text": "hello"}, Context: invokeContext{Timezone: "UTC"}})
|
||||
if !res.Success {
|
||||
t.Fatalf("execution failed: %+v", res)
|
||||
}
|
||||
b, _ := json.Marshal(res.Data)
|
||||
if string(b) != `{"text":"hello"}` {
|
||||
t.Fatalf("data=%s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerEnvironmentExplicitEnvFrom(t *testing.T) {
|
||||
t.Setenv("HUE_APP_KEY", "secret-key")
|
||||
t.Setenv("UNRELATED_SECRET", "must-not-leak")
|
||||
w := &worker{runtime: "python"}
|
||||
ref := skillRef{Manifest: manifest{ID: "test.env", Runtime: runtimeSpec{EnvFrom: []string{"HUE_APP_KEY", "JARVIS_MESH_ENROLLMENT_TOKEN"}}}, Action: actionManifest{Name: "run"}}
|
||||
env := w.environment(ref, invokeRequest{RequestID: "req", Context: invokeContext{Timezone: "Europe/Berlin"}})
|
||||
got := map[string]string{}
|
||||
for _, item := range env {
|
||||
parts := strings.SplitN(item, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
got[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
if got["HUE_APP_KEY"] != "secret-key" {
|
||||
t.Fatalf("explicit env_from was not passed: %#v", got)
|
||||
}
|
||||
if _, ok := got["UNRELATED_SECRET"]; ok {
|
||||
t.Fatalf("unrelated parent secret leaked into skill env")
|
||||
}
|
||||
if _, ok := got["JARVIS_MESH_ENROLLMENT_TOKEN"]; ok {
|
||||
t.Fatalf("JARVIS control env must never be passed to skills")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeControlSkillPackManifestsLoad(t *testing.T) {
|
||||
skillsDir := filepath.Clean(filepath.Join("..", "..", "skills", "python"))
|
||||
w := &worker{skillsDir: skillsDir, maxOutput: 1 << 20, runtime: "python", skills: map[string]manifest{}, actions: map[string]skillRef{}}
|
||||
if err := w.reload(); err != nil {
|
||||
t.Fatalf("home-control skill pack did not load: %v", err)
|
||||
}
|
||||
for _, id := range []string{"philips.hue", "unifi.network", "unifi.protect", "proxmox.ve", "dockge.compose", "home.network-tools", "notify.ntfy"} {
|
||||
if _, ok := w.skills[id]; !ok {
|
||||
t.Fatalf("expected skill %s to be loaded", id)
|
||||
}
|
||||
}
|
||||
if len(w.actions) < 42 {
|
||||
t.Fatalf("expected home-control actions, got %d", len(w.actions))
|
||||
}
|
||||
}
|
||||
+338
-5
@@ -1,22 +1,53 @@
|
||||
x-skill-worker-env: &skill-worker-env
|
||||
JARVIS_MASTER_URL: http://jarvis:8080
|
||||
JARVIS_ENROLLMENT_TOKEN: ${JARVIS_MESH_ENROLLMENT_TOKEN:-change-this-enrollment-token}
|
||||
JARVIS_WORKER_ADDR: :8090
|
||||
JARVIS_SKILLS_DIR: /skills
|
||||
JARVIS_WORKER_WORK_DIR: /work
|
||||
JARVIS_WORKER_ALLOW_SYSTEM_EXEC: "true"
|
||||
JARVIS_WORKER_MAX_OUTPUT_KB: 1024
|
||||
TZ: Europe/Berlin
|
||||
|
||||
services:
|
||||
jarvis:
|
||||
image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./models:/models:ro
|
||||
- ./local-tools:/tools:ro
|
||||
- ./skills:/app/skills:ro
|
||||
# Docker Engine control. This socket grants host-level Docker control;
|
||||
# JARVIS only exposes start/stop/restart for explicitly labelled containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
JARVIS_ADDR: :8080
|
||||
JARVIS_DATA: /app/data/store.json
|
||||
JARVIS_RAG_DATA: /app/data/rag.json
|
||||
JARVIS_WAKE_WORD: jarvis
|
||||
JARVIS_TIMEZONE: Europe/Berlin
|
||||
JARVIS_SKILLS_DIR: /app/skills
|
||||
JARVIS_SKILLS_ENABLED: "true"
|
||||
# External runtime code belongs in Skill Mesh workers by default.
|
||||
JARVIS_SKILL_PROCESS_ENABLED: "false"
|
||||
JARVIS_SKILL_ALLOW_SYSTEM_EXEC: "false"
|
||||
JARVIS_SKILL_MAX_TIMEOUT_MS: 30000
|
||||
JARVIS_SKILL_MAX_OUTPUT_KB: 1024
|
||||
JARVIS_MESH_ENABLED: "true"
|
||||
JARVIS_MESH_ENROLLMENT_TOKEN: ${JARVIS_MESH_ENROLLMENT_TOKEN:-change-this-enrollment-token}
|
||||
JARVIS_MESH_LEASE_SECONDS: 30
|
||||
JARVIS_MESH_INVOKE_TIMEOUT_MS: 30000
|
||||
JARVIS_DOCKER_CONTROLLER_ENABLED: "true"
|
||||
JARVIS_DOCKER_SOCKET: /var/run/docker.sock
|
||||
JARVIS_DOCKER_SKILL_LABEL: com.jarvis.skill-service
|
||||
JARVIS_DOCKER_SKILL_LABEL_VALUE: "true"
|
||||
TZ: Europe/Berlin
|
||||
OLLAMA_URL: http://10.10.11.123:11434
|
||||
OLLAMA_MODEL: gemma4:latest
|
||||
OLLAMA_EMBED_MODEL: embeddinggemma:latest
|
||||
OLLAMA_URL: http://ollama:11434
|
||||
OLLAMA_MODEL: auto
|
||||
OLLAMA_EMBED_MODEL: auto
|
||||
TASK_SCOUT_INTERVAL: 2m
|
||||
HOME_AUTOMATION_INTERVAL: 1m
|
||||
KANBAN_AI_INTERVAL: 5m
|
||||
@@ -25,8 +56,310 @@ services:
|
||||
JARVIS_DEBUG_MAX_MB: 25
|
||||
JARVIS_DEBUG_KEEP_FILES: 4
|
||||
VOICE_FFMPEG_BIN: ffmpeg
|
||||
VOICE_WHISPER_BIN: /tools/whisper-cli
|
||||
VOICE_WHISPER_BIN: /usr/local/bin/whisper-cli
|
||||
VOICE_WHISPER_MODEL: /models/ggml-small.bin
|
||||
VOICE_LANGUAGE: de
|
||||
VOICE_PIPER_BIN: /tools/piper
|
||||
VOICE_PIPER_MODEL: /models/de_DE-thorsten-medium.onnx
|
||||
LD_LIBRARY_PATH: /tools:/tools/lib
|
||||
depends_on:
|
||||
- ollama
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
|
||||
skill-python:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: python-main
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-python:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: python-main
|
||||
|
||||
skill-node:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.node
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/node:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: node-main
|
||||
JARVIS_WORKER_RUNTIME: node
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "22"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-node:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: node
|
||||
com.jarvis.worker-name: node-main
|
||||
|
||||
skill-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.golang
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/go:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: go-main
|
||||
JARVIS_WORKER_RUNTIME: go
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "1.23"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-go:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: go
|
||||
com.jarvis.worker-name: go-main
|
||||
|
||||
skill-rust:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.rust
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/rust:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: rust-main
|
||||
JARVIS_WORKER_RUNTIME: rust
|
||||
JARVIS_WORKER_RUNTIME_VERSION: stable
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-rust:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: rust
|
||||
com.jarvis.worker-name: rust-main
|
||||
|
||||
skill-c:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.c
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/c:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: c-main
|
||||
JARVIS_WORKER_RUNTIME: c
|
||||
JARVIS_WORKER_RUNTIME_VERSION: gcc
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-c:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: c
|
||||
com.jarvis.worker-name: c-main
|
||||
|
||||
skill-cpp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.cpp
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/cpp:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: cpp-main
|
||||
JARVIS_WORKER_RUNTIME: cpp
|
||||
JARVIS_WORKER_RUNTIME_VERSION: g++
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-cpp:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: cpp
|
||||
com.jarvis.worker-name: cpp-main
|
||||
|
||||
skill-csharp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.csharp
|
||||
profiles: ["skill-workers"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/csharp:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: csharp-main
|
||||
JARVIS_WORKER_RUNTIME: csharp
|
||||
JARVIS_WORKER_RUNTIME_VERSION: ".NET 8"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-csharp:8090
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: csharp
|
||||
com.jarvis.worker-name: csharp-main
|
||||
|
||||
|
||||
# --- Home Control integration workers (1 container = 1 integration) ---
|
||||
# Start all with: docker compose --profile home-skills up -d --build
|
||||
skill-hue:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python/philips-hue:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: hue
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-hue:8090
|
||||
HUE_BRIDGE_URL: ${HUE_BRIDGE_URL:-}
|
||||
HUE_APP_KEY: ${HUE_APP_KEY:-}
|
||||
HUE_VERIFY_TLS: ${HUE_VERIFY_TLS:-false}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: hue
|
||||
com.jarvis.integration: philips-hue
|
||||
|
||||
skill-unifi-network:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python/unifi-network:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: unifi-network
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-unifi-network:8090
|
||||
UNIFI_NETWORK_URL: ${UNIFI_NETWORK_URL:-}
|
||||
UNIFI_API_KEY: ${UNIFI_API_KEY:-}
|
||||
UNIFI_VERIFY_TLS: ${UNIFI_VERIFY_TLS:-false}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: unifi-network
|
||||
com.jarvis.integration: unifi-network
|
||||
|
||||
skill-unifi-protect:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python/unifi-protect:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: unifi-protect
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-unifi-protect:8090
|
||||
UNIFI_PROTECT_URL: ${UNIFI_PROTECT_URL:-}
|
||||
UNIFI_API_KEY: ${UNIFI_API_KEY:-}
|
||||
UNIFI_VERIFY_TLS: ${UNIFI_VERIFY_TLS:-false}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: unifi-protect
|
||||
com.jarvis.integration: unifi-protect
|
||||
|
||||
skill-proxmox:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python/proxmox-ve:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: proxmox
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-proxmox:8090
|
||||
PROXMOX_BASE_URL: ${PROXMOX_BASE_URL:-}
|
||||
PROXMOX_TOKEN_ID: ${PROXMOX_TOKEN_ID:-}
|
||||
PROXMOX_TOKEN_SECRET: ${PROXMOX_TOKEN_SECRET:-}
|
||||
PROXMOX_VERIFY_TLS: ${PROXMOX_VERIFY_TLS:-false}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: proxmox
|
||||
com.jarvis.integration: proxmox
|
||||
|
||||
skill-network-tools:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python/network-tools:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: network-tools
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-network-tools:8090
|
||||
WOL_BROADCAST: ${WOL_BROADCAST:-255.255.255.255}
|
||||
WOL_PORT: ${WOL_PORT:-9}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: network-tools
|
||||
com.jarvis.integration: network-tools
|
||||
|
||||
skill-ntfy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.python
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes: ["./skills/python/ntfy:/skills:ro"]
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: ntfy
|
||||
JARVIS_WORKER_RUNTIME: python
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-ntfy:8090
|
||||
NTFY_BASE_URL: ${NTFY_BASE_URL:-https://ntfy.sh}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
NTFY_DEFAULT_TOPIC: ${NTFY_DEFAULT_TOPIC:-}
|
||||
NTFY_VERIFY_TLS: ${NTFY_VERIFY_TLS:-true}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python
|
||||
com.jarvis.worker-name: ntfy
|
||||
com.jarvis.integration: ntfy
|
||||
|
||||
# Dockge upstream does not currently expose a stable authenticated management
|
||||
# API. This dedicated worker therefore operates on the same Compose stack
|
||||
# directory and Docker Engine. It is intentionally isolated from the master.
|
||||
skill-dockge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: workers/docker/Dockerfile.dockge
|
||||
profiles: ["home-skills"]
|
||||
depends_on: [jarvis]
|
||||
volumes:
|
||||
- ./skills/python/dockge-compose:/skills:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${DOCKGE_STACKS_DIR:-/opt/stacks}:${DOCKGE_STACKS_DIR:-/opt/stacks}:ro
|
||||
environment:
|
||||
<<: *skill-worker-env
|
||||
JARVIS_WORKER_NAME: dockge
|
||||
JARVIS_WORKER_RUNTIME: python-docker
|
||||
JARVIS_WORKER_RUNTIME_VERSION: "3.13"
|
||||
JARVIS_WORKER_PUBLIC_URL: http://skill-dockge:8090
|
||||
DOCKGE_STACKS_DIR: ${DOCKGE_STACKS_DIR:-/opt/stacks}
|
||||
DOCKGE_ALLOWED_STACKS: ${DOCKGE_ALLOWED_STACKS:-}
|
||||
labels:
|
||||
com.jarvis.skill-service: "true"
|
||||
com.jarvis.skill-runtime: python-docker
|
||||
com.jarvis.worker-name: dockge
|
||||
com.jarvis.integration: dockge
|
||||
|
||||
volumes:
|
||||
ollama-data:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+55
-6
@@ -41,26 +41,26 @@
|
||||
{
|
||||
"id": "grocery_ai_dl0gl2ul9jmg",
|
||||
"name": "Nudeln",
|
||||
"quantity": "1050",
|
||||
"quantity": "900",
|
||||
"unit": "Gramm",
|
||||
"category": "Essensplan",
|
||||
"checked": false,
|
||||
"source": "mealplan:auto:nudeln|gramm",
|
||||
"aiManaged": true,
|
||||
"createdAt": "2026-08-28T11:11:06+02:00",
|
||||
"updatedAt": "2026-08-28T11:28:26+02:00"
|
||||
"updatedAt": "2026-08-29T17:02:11+02:00"
|
||||
},
|
||||
{
|
||||
"id": "grocery_ai_dl0gl2ul9jmg",
|
||||
"name": "Thomatensauce",
|
||||
"quantity": "350",
|
||||
"quantity": "300",
|
||||
"unit": "ml",
|
||||
"category": "Essensplan",
|
||||
"checked": false,
|
||||
"source": "mealplan:auto:thomatensauce|ml",
|
||||
"aiManaged": true,
|
||||
"createdAt": "2026-08-28T11:11:06+02:00",
|
||||
"updatedAt": "2026-08-28T11:28:26+02:00"
|
||||
"updatedAt": "2026-08-29T17:02:11+02:00"
|
||||
}
|
||||
],
|
||||
"recipes": [
|
||||
@@ -178,14 +178,15 @@
|
||||
{
|
||||
"id": "card_ai_dl0dbme6x2a0",
|
||||
"title": "Hausflur putzen",
|
||||
"notes": "Offene Aufgabe ohne Fälligkeit; zunächst in der Inbox sammeln und bei Bedarf in den nächsten Arbeitsschritt verschieben.",
|
||||
"column": "inbox",
|
||||
"priority": "normal",
|
||||
"taskId": "task_tool_dl0dbme5o3ls",
|
||||
"source": "task-sync",
|
||||
"aiManaged": true,
|
||||
"confidence": 0.9,
|
||||
"confidence": 0.72,
|
||||
"createdAt": "2026-08-28T08:37:42+02:00",
|
||||
"updatedAt": "2026-08-28T11:06:29+02:00"
|
||||
"updatedAt": "2026-08-29T16:12:32+02:00"
|
||||
}
|
||||
],
|
||||
"documents": [],
|
||||
@@ -396,6 +397,54 @@
|
||||
}
|
||||
],
|
||||
"activity": [
|
||||
{
|
||||
"id": "act_dl1hmf4ihncw",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T16:12:32+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl1gk0a0hxpo",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T15:22:22+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl1f68r96qrg",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T14:17:23+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl1euvvjnpzw",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T14:02:32+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl1ae39p9qek",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T10:32:32+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl19n519e6yo",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T09:57:20+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl18dgp8z1z4",
|
||||
"kind": "kanban",
|
||||
"message": "KI-Kanban (interval/json-retry): 1 Karten gepflegt",
|
||||
"createdAt": "2026-08-29T08:57:41+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl181l1rtniw",
|
||||
"kind": "automation",
|
||||
"message": "Home-Automation (startup): Einkauf 2 · Reminder 0 · Kanban 0",
|
||||
"createdAt": "2026-08-29T08:42:10+02:00"
|
||||
},
|
||||
{
|
||||
"id": "act_dl0glbhnyzgs",
|
||||
"kind": "assistant",
|
||||
|
||||
@@ -214,7 +214,7 @@ func sanitizeValue(v any, max int) any {
|
||||
}
|
||||
}
|
||||
func secretKey(k string) bool {
|
||||
for _, s := range []string{"password", "passwd", "authorization", "api_key", "apikey", "secret", "bearer", "cookie"} {
|
||||
for _, s := range []string{"password", "passwd", "authorization", "api_key", "apikey", "secret", "token", "bearer", "cookie"} {
|
||||
if strings.Contains(k, s) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestLoggerWritesTraceAndRedactsSecrets(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := WithTrace(context.Background(), "trace_test")
|
||||
l.Record(ctx, "planner", "request", "input", "info", map[string]any{"message": "Montag Nudeln", "authorization": "Bearer secret"}, nil, 0)
|
||||
l.Record(ctx, "planner", "request", "input", "info", map[string]any{"message": "Montag Nudeln", "authorization": "Bearer secret", "enrollment_token": "mesh-bootstrap-secret", "access_token": "worker-session-secret"}, nil, 0)
|
||||
evs, err := l.Events(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -30,7 +30,7 @@ func TestLoggerWritesTraceAndRedactsSecrets(t *testing.T) {
|
||||
}
|
||||
b, _ := json.Marshal(ev.Data)
|
||||
s := string(b)
|
||||
if strings.Contains(s, "Bearer secret") || !strings.Contains(s, "REDACTED") {
|
||||
if strings.Contains(s, "Bearer secret") || strings.Contains(s, "mesh-bootstrap-secret") || strings.Contains(s, "worker-session-secret") || !strings.Contains(s, "REDACTED") {
|
||||
t.Fatalf("secret not redacted: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "Montag Nudeln") {
|
||||
|
||||
@@ -28,7 +28,7 @@ function toast(msg){const el=$('#toast');el.textContent=msg;el.classList.add('sh
|
||||
function tickClock(){const n=new Date();$('#clock').textContent=n.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('#date').textContent=n.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'short',year:'numeric'}).toUpperCase();if($('#cleanTopDate'))$('#cleanTopDate').textContent=n.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}setInterval(tickClock,1000);tickClock();
|
||||
function setChip(id,ok,label){const dot=$(id+'Dot');if(!dot)return;const chip=dot.closest('.status-chip');chip.classList.toggle('online',!!ok);chip.classList.toggle('offline',ok===false);$(id+'Status').textContent=label}
|
||||
async function refresh(){try{state=await api('/api/state');renderState();setChip('#core',true,'ONLINE')}catch(e){setChip('#core',false,'OFFLINE')}}
|
||||
function renderState(){const sys=state.system||{},voice=sys.voice||{},ollama=sys.ollama||{},chunks=Number(sys.ragChunks||0);const chatReady=ollama.chatReady!==false&&!!(ollama.model||sys.model),embedReady=ollama.embeddingReady!==false&&!!(ollama.embeddingModel||sys.embeddingModel);setChip('#core',ollama.reachable===false?false:chatReady,chatReady?'LOCAL':(ollama.reachable===false?'OLLAMA OFF':'NO MODEL'));$('#coreStatus').title=chatReady?`ACTIVE MODEL: ${ollama.model||sys.model}`:(ollama.lastError||'Kein Chat-Modell erkannt');setChip('#rag',embedReady?(chunks>0?true:null):false,embedReady?(chunks?`${chunks} CHUNKS`:'EMPTY'):'NO EMBED');const sttLabel=voice.sttReady?'LOCAL':(!voice.whisperBinReady?'BIN':(!voice.whisperModelReady?'MODEL':(!voice.ffmpegReady?'FFMPEG':'SETUP'))),ttsLabel=voice.ttsReady?'LOCAL':(!voice.piperBinReady?'BIN':(!voice.piperModelReady?'MODEL':'SETUP'));setChip('#stt',!!voice.sttReady,sttLabel);setChip('#tts',!!voice.ttsReady,ttsLabel);$('#stt').title=voice.sttReady?`Whisper: ${voice.whisperBin||'whisper-cli'} · Modell: ${voice.whisperModel||''}`:voiceSetupMessage(voice,'stt');$('#tts').title=voice.ttsReady?`Piper: ${voice.piperBin||'piper'} · Modell: ${voice.piperModel||''}`:voiceSetupMessage(voice,'tts');const mt=$('#modelTag'),ctxN=Number(sys.context?.numCtx||0),ctxLabel=ctxN>=1024?`${Math.round(ctxN/1024)}K`:(ctxN||'—');if(mt){mt.textContent=chatReady?`LOCAL · ${String(ollama.model||sys.model).toUpperCase()} · CTX ${ctxLabel}`:'LOCAL · DEGRADED';mt.title=`Tool Agent: ${sys.agent?.mode||'unknown'} · ${sys.agent?.tools?.length||0} tools · max ${sys.agent?.maxSteps||0} steps · Conversation ${sys.context?.historyTokens||0} tokens`;}const scout=$('#scoutBtn'),kai=$('#kanbanAI'),reindex=$('#reindexBtn');if(scout){scout.disabled=!chatReady;scout.title=chatReady?'Lokalen Task-Scout starten':'Benötigt ein lokales Ollama-Chatmodell'}if(kai){kai.disabled=!chatReady;kai.title=chatReady?'Kanban mit lokalem Modell organisieren':'Benötigt ein lokales Ollama-Chatmodell'}if(reindex){reindex.disabled=!embedReady;reindex.title=embedReady?'RAG neu indexieren':'Benötigt ein lokales Embedding-Modell'}$('#chunkCount').textContent=chunks;$('#memoryBar').style.width=`${Math.min(100,5+Math.log2(chunks+1)*12)}%`;$('#wakeWord').textContent=String(voice.wakeWord||'jarvis').toUpperCase();$('#whisperDiag').textContent=voice.sttReady?'ONLINE':sttLabel;$('#whisperDiag').title=voice.sttReady?'Whisper bereit':voiceSetupMessage(voice,'stt');$('#whisperDiag').className=voice.sttReady?'good':'bad';$('#piperDiag').textContent=voice.ttsReady?'ONLINE':ttsLabel;$('#piperDiag').title=voice.ttsReady?'Piper bereit':voiceSetupMessage(voice,'tts');$('#piperDiag').className=voice.ttsReady?'good':'bad';renderCleanHome();renderBrief();renderCalendar();renderTasks();renderKanban();renderGroceries();renderMeals();renderRecipes();renderReminders();renderDocuments();renderActivity();const dbg=sys.debug||{};const dstat=$('#debugTraceStatus');if(dstat){dstat.classList.toggle('off',!dbg.enabled);dstat.innerHTML=`<i></i> ${dbg.enabled?'DEBUG TRACE':'TRACE OFF'}`};if($('#debugExportBtn'))$('#debugExportBtn').disabled=!dbg.enabled;if($('#debugClearBtn'))$('#debugClearBtn').disabled=!dbg.enabled;if(!bootstrappedChat){renderInitialChat();bootstrappedChat=true}}
|
||||
function renderState(){const sys=state.system||{},voice=sys.voice||{},ollama=sys.ollama||{},chunks=Number(sys.ragChunks||0);const chatReady=ollama.chatReady!==false&&!!(ollama.model||sys.model),embedReady=ollama.embeddingReady!==false&&!!(ollama.embeddingModel||sys.embeddingModel);setChip('#core',ollama.reachable===false?false:chatReady,chatReady?'LOCAL':(ollama.reachable===false?'OLLAMA OFF':'NO MODEL'));$('#coreStatus').title=chatReady?`ACTIVE MODEL: ${ollama.model||sys.model}`:(ollama.lastError||'Kein Chat-Modell erkannt');setChip('#rag',embedReady?(chunks>0?true:null):false,embedReady?(chunks?`${chunks} CHUNKS`:'EMPTY'):'NO EMBED');const sttLabel=voice.sttReady?'LOCAL':(!voice.whisperBinReady?'BIN':(!voice.whisperModelReady?'MODEL':(!voice.ffmpegReady?'FFMPEG':'SETUP'))),ttsLabel=voice.ttsReady?'LOCAL':(!voice.piperBinReady?'BIN':(!voice.piperModelReady?'MODEL':'SETUP'));setChip('#stt',!!voice.sttReady,sttLabel);setChip('#tts',!!voice.ttsReady,ttsLabel);$('#stt').title=voice.sttReady?`Whisper: ${voice.whisperBin||'whisper-cli'} · Modell: ${voice.whisperModel||''}`:voiceSetupMessage(voice,'stt');$('#tts').title=voice.ttsReady?`Piper: ${voice.piperBin||'piper'} · Modell: ${voice.piperModel||''}`:voiceSetupMessage(voice,'tts');const mt=$('#modelTag'),ctxN=Number(sys.context?.numCtx||0),ctxLabel=ctxN>=1024?`${Math.round(ctxN/1024)}K`:(ctxN||'—');if(mt){mt.textContent=chatReady?`LOCAL · ${String(ollama.model||sys.model).toUpperCase()} · CTX ${ctxLabel}`:'LOCAL · DEGRADED';mt.title=`Skill Agent: ${sys.agent?.mode||'unknown'} · ${sys.skills?.action_count||sys.agent?.tools?.length||0} actions · ${sys.skills?.plugin_count||0} plugins · max ${sys.agent?.maxSteps||0} steps · Conversation ${sys.context?.historyTokens||0} tokens`;}const scout=$('#scoutBtn'),kai=$('#kanbanAI'),reindex=$('#reindexBtn');if(scout){scout.disabled=!chatReady;scout.title=chatReady?'Lokalen Task-Scout starten':'Benötigt ein lokales Ollama-Chatmodell'}if(kai){kai.disabled=!chatReady;kai.title=chatReady?'Kanban mit lokalem Modell organisieren':'Benötigt ein lokales Ollama-Chatmodell'}if(reindex){reindex.disabled=!embedReady;reindex.title=embedReady?'RAG neu indexieren':'Benötigt ein lokales Embedding-Modell'}$('#chunkCount').textContent=chunks;$('#memoryBar').style.width=`${Math.min(100,5+Math.log2(chunks+1)*12)}%`;$('#wakeWord').textContent=String(voice.wakeWord||'jarvis').toUpperCase();$('#whisperDiag').textContent=voice.sttReady?'ONLINE':sttLabel;$('#whisperDiag').title=voice.sttReady?'Whisper bereit':voiceSetupMessage(voice,'stt');$('#whisperDiag').className=voice.sttReady?'good':'bad';$('#piperDiag').textContent=voice.ttsReady?'ONLINE':ttsLabel;$('#piperDiag').title=voice.ttsReady?'Piper bereit':voiceSetupMessage(voice,'tts');$('#piperDiag').className=voice.ttsReady?'good':'bad';renderCleanHome();renderBrief();renderCalendar();renderTasks();renderKanban();renderGroceries();renderMeals();renderRecipes();renderReminders();renderDocuments();renderSkills();renderSkillMesh();renderActivity();const dbg=sys.debug||{};const dstat=$('#debugTraceStatus');if(dstat){dstat.classList.toggle('off',!dbg.enabled);dstat.innerHTML=`<i></i> ${dbg.enabled?'DEBUG TRACE':'TRACE OFF'}`};if($('#debugExportBtn'))$('#debugExportBtn').disabled=!dbg.enabled;if($('#debugClearBtn'))$('#debugClearBtn').disabled=!dbg.enabled;if(!bootstrappedChat){renderInitialChat();bootstrappedChat=true}}
|
||||
function renderCleanHome(){
|
||||
const now=new Date(),today=new Date();today.setHours(0,0,0,0);const tomorrow=new Date(today);tomorrow.setDate(tomorrow.getDate()+1);const weekEnd=new Date(today);weekEnd.setDate(weekEnd.getDate()+7);
|
||||
const events=[...(state.events||[])].filter(e=>{const d=parseDate(e.start);return d&&d>=today}).sort((a,b)=>String(a.start).localeCompare(String(b.start)));
|
||||
@@ -43,7 +43,7 @@ function renderCleanHome(){
|
||||
const ev=events[0],evEl=$('#cleanNextEvent');if(evEl)evEl.innerHTML=ev?`<strong>${esc(ev.title)}</strong><p>${esc(fmtDay(parseDate(ev.start)))} · ${esc(ev.allDay?'Ganztägig':fmtClock(ev.start))}${ev.location?' · '+esc(ev.location):''}</p>`:'<strong>Keine Termine</strong><p>Dein Kalender ist frei.</p>';
|
||||
const task=tasks[0],taskEl=$('#cleanNextTask');if(taskEl)taskEl.innerHTML=task?`<strong>${esc(task.title)}</strong><p>${task.priority==='high'?'Hohe Priorität':'Offen'}${task.due?' · '+esc(fmtTime(task.due)):''}</p>`:'<strong>Keine offenen Aufgaben</strong><p>Du bist auf dem aktuellen Stand.</p>';
|
||||
const meal=nextMeal,mealEl=$('#cleanNextMeal');if(mealEl)mealEl.innerHTML=meal?`<strong>${esc(meal.title||meal.recipeTitle||'Geplante Mahlzeit')}</strong><p>${esc(fmtDay(parseDate(meal.date)))} · ${esc(mealName(meal.meal))}${meal.servings?` · ${meal.servings} Portionen`:''}</p>`:'<strong>Noch nichts geplant</strong><p>Plane eine Mahlzeit für heute oder morgen.</p>';
|
||||
const sys=state.system||{},ollama=sys.ollama||{},voice=sys.voice||{};if($('#cleanCoreQuick'))$('#cleanCoreQuick').textContent=ollama.chatReady===false?'Nicht bereit':String(ollama.model||sys.model||'Lokal');if($('#cleanRagQuick'))$('#cleanRagQuick').textContent=Number(sys.ragChunks||0)?`${sys.ragChunks} Chunks`:'Leer';if($('#cleanVoiceQuick')){const cv=$('#cleanVoiceQuick');cv.textContent=voice.sttReady?'Bereit':sttLabel;cv.title=voice.sttReady?'Lokales Whisper bereit':voiceSetupMessage(voice,'stt')}if($('#cleanModelStatus'))$('#cleanModelStatus').textContent=ollama.chatReady===false?'Modell fehlt':String(ollama.model||sys.model||'Online');
|
||||
const sys=state.system||{},ollama=sys.ollama||{},voice=sys.voice||{};if($('#cleanCoreQuick'))$('#cleanCoreQuick').textContent=ollama.chatReady===false?'Nicht bereit':String(ollama.model||sys.model||'Lokal');if($('#cleanRagQuick'))$('#cleanRagQuick').textContent=Number(sys.ragChunks||0)?`${sys.ragChunks} Chunks`:'Leer';if($('#cleanVoiceQuick')){const cv=$('#cleanVoiceQuick');cv.textContent=voice.sttReady?'Bereit':sttLabel;cv.title=voice.sttReady?'Lokales Whisper bereit':voiceSetupMessage(voice,'stt')}if($('#cleanModelStatus'))$('#cleanModelStatus').textContent=ollama.chatReady===false?'Modell fehlt':String(ollama.model||sys.model||'Online');if($('#cleanSkillsQuick'))$('#cleanSkillsQuick').textContent=`${Number(sys.mesh?.online_count||0)} Worker · ${Number(sys.skills?.action_count||0)} Actions`;
|
||||
}
|
||||
function renderBrief(){const start=new Date();start.setHours(0,0,0,0);const until=new Date(start);until.setDate(until.getDate()+7);$('#briefEvents').textContent=(state.events||[]).filter(e=>{const d=parseDate(e.start);return d&&d>=start&&d<until}).length;$('#briefTasks').textContent=(state.tasks||[]).filter(t=>t.status!=='done').length;$('#briefGroceries').textContent=(state.groceries||[]).filter(g=>!g.checked).length;$('#briefReminders').textContent=(state.reminders||[]).filter(r=>!r.done).length}
|
||||
function renderCalendar(){const today=new Date();today.setHours(0,0,0,0);const end=new Date(today);end.setDate(end.getDate()+7);const events=[...(state.events||[])].sort((a,b)=>String(a.start).localeCompare(String(b.start)));const days=[];for(let i=0;i<7;i++){const d=new Date(today);d.setDate(d.getDate()+i);const key=localDateKey(d),count=events.filter(e=>{const ed=parseDate(e.start);return ed&&localDateKey(ed)===key}).length;days.push(`<div class="day-cell ${i===0?'today':''}"><span>${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</span><strong>${d.getDate()}</strong>${count?`<b>${count}</b>`:''}</div>`)}$('#weekRail').innerHTML=days.join('');const visible=events.filter(e=>{const d=parseDate(e.start);return d&&d>=today&&d<end}).slice(0,18);$('#agenda').innerHTML=visible.length?visible.map(e=>`<div class="agenda-item"><div class="agenda-time">${esc(e.allDay?'GANZTÄGIG':fmtClock(e.start))}</div><div><strong>${esc(e.title)}</strong><span>${esc(fmtDay(parseDate(e.start)))}${e.location?' · '+esc(e.location):''} · ${e.aiManaged?'JARVIS':'MANUAL'}</span></div><button class="icon-delete" data-delete-event="${esc(e.id)}">×</button></div>`).join(''):'<div class="empty">KEINE TERMINE IN DEN NÄCHSTEN 7 TAGEN</div>';$$('[data-delete-event]').forEach(b=>b.onclick=()=>deleteEntity('/api/events/',b.dataset.deleteEvent,'Termin entfernt'))}
|
||||
@@ -55,6 +55,8 @@ function renderMeals(){const today=new Date();today.setHours(0,0,0,0);const plan
|
||||
function renderRecipes(){const rs=state.recipes||[];$('#recipeList').innerHTML=rs.length?rs.slice(0,40).map(r=>`<article class="recipe-card"><strong>${esc(r.title)}</strong><p>${esc(r.description||`${(r.ingredients||[]).length} Zutaten · ${(r.steps||[]).length} Schritte`)}</p><footer>${r.prepMinutes?`${r.prepMinutes} MIN · `:''}${r.servings||2} PORT. ${r.aiManaged?'· AI':''}</footer><div class="recipe-actions"><button data-recipe-edit="${esc(r.id)}" title="Rezept bearbeiten">✎</button><button data-recipe-delete="${esc(r.id)}" title="Rezept löschen">×</button></div></article>`).join(''):'<div class="empty">NO CULINARY MEMORY</div>';$$('[data-recipe-edit]').forEach(b=>b.onclick=()=>{const r=state.recipes.find(x=>x.id===b.dataset.recipeEdit);if(r)openDialog('recipe',r)});$$('[data-recipe-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/recipes/',b.dataset.recipeDelete,'Rezept entfernt'))}
|
||||
function renderReminders(){const rs=[...(state.reminders||[])].sort((a,b)=>a.done-b.done||String(a.at).localeCompare(String(b.at)));$('#reminderList').innerHTML=rs.length?rs.slice(0,24).map(r=>`<div class="reminder ${r.done?'done':''}"><input class="reminder-check" type="checkbox" ${r.done?'checked':''} data-reminder-check="${esc(r.id)}"><div class="reminder-main"><strong>${esc(r.title)}</strong><span>${esc(fmtTime(r.at))} · ${r.aiManaged?'AI/AUTO':'MANUAL'}${r.linkedType==='event'?' · KALENDER':''}</span></div><button class="icon-delete" data-reminder-delete="${esc(r.id)}">×</button></div>`).join(''):'<div class="empty">ALERT GRID CLEAR</div>';$$('[data-reminder-check]').forEach(el=>el.onchange=async()=>{const r=state.reminders.find(x=>x.id===el.dataset.reminderCheck);if(!r)return;try{await post('/api/reminders',{...r,done:el.checked});await refresh()}catch(e){toast(e.message)}});$$('[data-reminder-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/reminders/',b.dataset.reminderDelete,'Erinnerung entfernt'))}
|
||||
function renderDocuments(){const docs=state.documents||[];$('#documentList').innerHTML=docs.length?docs.slice(0,14).map(d=>`<div class="doc"><div class="doc-icon">${esc((d.name.split('.').pop()||'DOC').slice(0,4).toUpperCase())}</div><div><strong title="${esc(d.name)}">${esc(d.name)}</strong><span>${d.chunkCount||0} chunks · ${d.indexedAt?'INDEXED':'PENDING'}</span></div><button class="icon-delete" data-doc-delete="${esc(d.id)}">×</button></div>`).join(''):'<div class="empty">KNOWLEDGE VAULT EMPTY</div>';$$('[data-doc-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/documents/',b.dataset.docDelete,'Dokument entfernt'))}
|
||||
function renderSkills(){const skills=state.system?.skills||{},mods=skills.modules||[],el=$('#skillList'),tag=$('#skillCountTag');if(tag)tag.innerHTML=`<i></i> ${Number(skills.action_count||0)} ACTIONS`;if(!el)return;if(!mods.length){el.innerHTML='<div class="empty">KEINE SKILLS GELADEN</div>';return}const sorted=[...mods].sort((a,b)=>(a.source==='plugin'?0:1)-(b.source==='plugin'?0:1)||String(a.name||a.id).localeCompare(String(b.name||b.id)));el.innerHTML=sorted.map(m=>{const actions=(m.actions||[]).map(a=>`<span class="skill-action-chip ${a.mutates?'mutates':''} ${a.requires_confirmation?'confirm':''}" title="${esc(a.tool_name||'')}">${esc(a.name||a.tool_name||'action')}</span>`).join('');const perms=m.source==='plugin'?`Runtime ${esc(m.runtime||'process')} · Network ${m.permissions?.network?'ja':'nein'} · System Exec ${m.permissions?.system_exec?'ja':'nein'}`:m.source==='remote'?`Remote HTTP · ${(m.actions||[]).length} Actions`:`${(m.actions||[]).length} Core Actions`;return `<article class="skill-card"><div class="skill-card-head"><strong>${esc(m.name||m.id)}</strong><span>${esc(m.source||'plugin')} · ${esc(m.version||'')}</span></div>${m.description?`<p>${esc(m.description)}</p>`:''}${m.error?`<p class="skill-error">${esc(m.error)}</p>`:''}<div class="skill-actions">${actions}</div><div class="skill-perms">${perms}</div></article>`}).join('')}
|
||||
function renderSkillMesh(){const mesh=state.system?.mesh||{},docker=state.system?.dockerSkills||{},workers=mesh.workers||[],services=docker.services||[],tag=$('#meshCountTag'),wel=$('#meshWorkerList'),del=$('#dockerSkillList');if(tag)tag.innerHTML=`<i></i> ${Number(mesh.online_count||0)} / ${Number(mesh.worker_count||0)} WORKERS`;if(wel)wel.innerHTML=workers.length?workers.map(w=>`<article class="mesh-card"><div class="mesh-card-head"><div><strong>${esc(w.name||w.id)}</strong><small>${esc(w.runtime||'runtime')}${w.runtime_version?' · '+esc(w.runtime_version):''}</small></div><small>${esc(w.state||'offline').toUpperCase()}</small></div><div class="mesh-meta"><span class="${w.state==='online'?'online':'offline'}">${esc(w.state||'offline')}</span><span>${Number(w.skill_count||0)} Skills</span><span>${Number(w.action_count||0)} Actions</span><span title="${esc(w.endpoint||'')}">${esc((w.endpoint||'').replace(/^https?:\/\//,''))}</span></div>${w.state==='online'?`<div class="docker-actions"><button data-worker-reload="${esc(w.id)}">RELOAD SKILLS</button></div>`:''}</article>`).join(''):'<div class="empty">KEINE REMOTE WORKER ENROLLED</div>';$$('[data-worker-reload]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{const out=await api(`/api/mesh/workers/${encodeURIComponent(b.dataset.workerReload)}/reload`,{method:'POST'});toast(out.ok===false?(out.error||'Worker-Reload fehlgeschlagen'):'Worker-Skills neu geladen');setTimeout(refresh,500)}catch(e){toast('Worker: '+e.message)}finally{b.disabled=false}});if(del){if(!docker.enabled){del.innerHTML='<div class="empty">DOCKER CONTROLLER DEAKTIVIERT</div>'}else if(!docker.available){del.innerHTML=`<div class="empty">DOCKER SOCK NICHT VERFÜGBAR${docker.error?' · '+esc(docker.error):''}</div>`}else{del.innerHTML=services.length?services.map(c=>`<article class="mesh-card"><div class="mesh-card-head"><div><strong>${esc(c.worker_name||c.name||c.id)}</strong><small>${esc(c.runtime||'skill')} · ${esc(c.image||'')}</small></div><small>${esc((c.state||'unknown').toUpperCase())}</small></div><div class="mesh-meta"><span class="${c.state==='running'?'online':'offline'}">${esc(c.status||c.state||'')}</span></div><div class="docker-actions">${c.state==='running'?`<button class="danger" data-docker-action="stop" data-docker-id="${esc(c.id)}">STOP</button><button data-docker-action="restart" data-docker-id="${esc(c.id)}">RESTART</button>`:`<button data-docker-action="start" data-docker-id="${esc(c.id)}">START</button>`}</div></article>`).join(''):'<div class="empty">KEINE GELABELTEN SKILL-CONTAINER</div>';$$('[data-docker-action]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{const out=await api(`/api/docker/skill-services/${encodeURIComponent(b.dataset.dockerId)}/${encodeURIComponent(b.dataset.dockerAction)}`,{method:'POST'});toast(out.ok?`Container ${b.dataset.dockerAction}`:(out.error||'Docker-Aktion fehlgeschlagen'));setTimeout(refresh,700)}catch(e){toast('Docker: '+e.message)}finally{b.disabled=false}})}}}
|
||||
function renderActivity(){const xs=state.activity||[];$('#activityList').innerHTML=xs.length?xs.slice(0,20).map(a=>`<div class="activity ${esc(a.kind)}"><i></i><div><strong>${esc(a.message)}</strong><span>${esc(fmtTime(a.createdAt))} · ${esc(String(a.kind||'event').toUpperCase())}</span></div></div>`).join(''):'<div class="empty">NO TELEMETRY EVENTS</div>'}
|
||||
function renderInitialChat(){const conv=$('#conversation'),turns=(state.chat||[]).slice(-12);if(!turns.length)return;conv.innerHTML='';turns.forEach(t=>addMessage(t.content,t.role==='user'?'user':'assistant',false));conv.scrollTop=conv.scrollHeight}
|
||||
function addMessage(text,role='assistant',animate=true,mode=''){const conv=$('#conversation'),el=document.createElement('div');el.className=`message ${role} ${mode||''}`.trim();const label=role==='user'?'OPERATOR':mode==='action'?'JARVIS // COMMIT':mode==='clarification'?'JARVIS // CLARIFY':'JARVIS';el.innerHTML=`<div class="avatar">${role==='user'?'U':'J'}</div><div><small>${label}</small><p></p></div>`;$('p',el).innerHTML=messageHTML(text);if(animate)el.style.opacity='0';conv.appendChild(el);conv.scrollTop=conv.scrollHeight;if(animate)requestAnimationFrame(()=>{el.style.transition='.22s';el.style.opacity='1'});return el}
|
||||
@@ -72,6 +74,8 @@ $('#scoutBtn').onclick=async()=>{const b=$('#scoutBtn');b.disabled=true;b.textCo
|
||||
$('#kanbanAI').onclick=async()=>{const b=$('#kanbanAI');b.disabled=true;b.textContent='ORGANIZING';try{const out=await api('/api/ai/kanban',{method:'POST'});toast(`${out.updated} KI-Karten gepflegt`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='AI ORGANIZE'}};
|
||||
const drop=$('#dropzone'),fileInput=$('#fileInput');['dragenter','dragover'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag')}));['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag')}));drop.ondrop=e=>{const f=e.dataTransfer.files?.[0];if(f)uploadDocument(f)};fileInput.onchange=()=>{if(fileInput.files?.[0])uploadDocument(fileInput.files[0]);fileInput.value=''};
|
||||
async function uploadDocument(file){const fd=new FormData();fd.append('file',file);toast('Dokument wird lokal indexiert …');setCoreMode('INDEXING');try{const out=await api('/api/documents',{method:'POST',body:fd});toast(out.indexed?'Dokument im RAG':'Text gespeichert, Indexierung fehlgeschlagen');await refresh()}catch(e){toast(e.message)}finally{setCoreMode('STANDBY')}}
|
||||
if($('#reloadSkillsBtn'))$('#reloadSkillsBtn').onclick=async()=>{try{const out=await api('/api/skills/reload',{method:'POST'});toast(out.ok?'Skills neu geladen':(out.warning||'Skills mit Warnungen geladen'));await refresh()}catch(e){toast('Skills: '+e.message)}};
|
||||
if($('#refreshMeshBtn'))$('#refreshMeshBtn').onclick=async()=>{try{await refresh();toast('Skill Mesh aktualisiert')}catch(e){toast('Mesh: '+e.message)}};
|
||||
$('#reindexBtn').onclick=async()=>{const b=$('#reindexBtn');b.disabled=true;b.textContent='INDEXING';try{const o=await api('/api/rag/reindex',{method:'POST'});toast(`${o.indexed} Dokument(e), ${o.chunks} Chunks`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='REINDEX'}};
|
||||
|
||||
const dialogDefs={
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</article>
|
||||
<article class="clean-home-card clean-system-card">
|
||||
<header><span>Local AI</span><button data-clean-jump="knowledge">System</button></header>
|
||||
<div class="clean-system-grid"><div><span>Core</span><strong id="cleanCoreQuick">—</strong></div><div><span>RAG</span><strong id="cleanRagQuick">—</strong></div><div><span>Voice</span><strong id="cleanVoiceQuick">—</strong></div></div>
|
||||
<div class="clean-system-grid"><div><span>Core</span><strong id="cleanCoreQuick">—</strong></div><div><span>RAG</span><strong id="cleanRagQuick">—</strong></div><div><span>Voice</span><strong id="cleanVoiceQuick">—</strong></div><div><span>Skills</span><strong id="cleanSkillsQuick">—</strong></div></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -153,15 +153,27 @@
|
||||
<div class="memory-meter"><span>VECTOR CHUNKS</span><strong id="chunkCount">0</strong><div><i id="memoryBar"></i></div></div><div id="documentList" class="document-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel skills-panel">
|
||||
<header class="panel-head"><div><span class="micro-label">09 // SKILL REGISTRY</span><h2>Skills</h2></div><div class="head-actions"><span id="skillCountTag" class="live-tag"><i></i> 0 ACTIONS</span><button id="reloadSkillsBtn" class="hud-button">RELOAD</button></div></header>
|
||||
<p class="skill-intro">Core-Skills, lokale Entwicklungsplugins und Remote-Worker teilen sich dieselbe JSON-Schnittstelle. JARVIS kann Skill-Aktionen unabhängig vom Provider auswählen und zu Workflows kombinieren.</p>
|
||||
<div id="skillList" class="skill-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel mesh-panel full-span">
|
||||
<header class="panel-head"><div><span class="micro-label">10 // DISTRIBUTED SKILL MESH</span><h2>Skill Worker & Container</h2></div><div class="head-actions"><span id="meshCountTag" class="live-tag"><i></i> 0 WORKERS</span><button id="refreshMeshBtn" class="hud-button">REFRESH</button></div></header>
|
||||
<p class="skill-intro">Remote Worker registrieren ihre Skills per HTTP beim Master. Der Docker-Controller zeigt nur Container mit dem freigegebenen JARVIS-Service-Label und kann ausschließlich Start, Stop und Restart ausführen.</p>
|
||||
<div class="mesh-layout"><div><h3 class="mesh-subtitle">Enrolled Worker</h3><div id="meshWorkerList" class="mesh-worker-list"></div></div><div><h3 class="mesh-subtitle">Docker Skill Services</h3><div id="dockerSkillList" class="docker-skill-list"></div></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel voice-panel">
|
||||
<header class="panel-head"><div><span class="micro-label">09 // VOICE MATRIX</span><h2>Wake Protocol</h2></div><button class="module-mic" data-voice-context="Systemsteuerung" title="System per Sprache">◉</button></header>
|
||||
<header class="panel-head"><div><span class="micro-label">11 // VOICE MATRIX</span><h2>Wake Protocol</h2></div><button class="module-mic" data-voice-context="Systemsteuerung" title="System per Sprache">◉</button></header>
|
||||
<div class="wake-row"><div class="wake-word"><span>CODEWORD</span><strong id="wakeWord">JARVIS</strong></div><label class="switch"><input type="checkbox" id="wakeToggle"><span></span></label></div>
|
||||
<p id="wakeHelp">Wake-Word aus. Jeder Modul-Button und der globale Voice-Core starten Push-to-talk.</p>
|
||||
<div class="voice-diag"><div><span>MIC</span><b id="micDiag">READY</b></div><div><span>WHISPER</span><b id="whisperDiag">CHECK</b></div><div><span>PIPER</span><b id="piperDiag">CHECK</b></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel telemetry-panel full-span">
|
||||
<header class="panel-head"><div><span class="micro-label">10 // SYSTEM TELEMETRY</span><h2>Activity Stream</h2></div><div class="head-actions"><span id="debugTraceStatus" class="live-tag"><i></i> DEBUG TRACE</span><button id="debugClearBtn" class="hud-button ghost" title="Nur Debug-Trace löschen">CLEAR TRACE</button></div></header><div id="activityList" class="activity-list"></div>
|
||||
<header class="panel-head"><div><span class="micro-label">12 // SYSTEM TELEMETRY</span><h2>Activity Stream</h2></div><div class="head-actions"><span id="debugTraceStatus" class="live-tag"><i></i> DEBUG TRACE</span><button id="debugClearBtn" class="hud-button ghost" title="Nur Debug-Trace löschen">CLEAR TRACE</button></div></header><div id="activityList" class="activity-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.shell{width:min(1880px,calc(100% - 30px));margin:auto;position:relative;z-index:2}.hud-top{min-height:88px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);position:relative}.hud-top:after{content:"";position:absolute;bottom:-1px;left:0;width:230px;height:1px;background:var(--cyan);box-shadow:0 0 14px var(--cyan)}.identity{display:flex;align-items:center;gap:13px}.sigil{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--line2);clip-path:polygon(18% 0,82% 0,100% 18%,100% 82%,82% 100%,18% 100%,0 82%,0 18%);color:var(--cyan);font-weight:900;font-size:20px;text-shadow:0 0 14px var(--cyan);background:rgba(99,236,255,.03)}.micro-label{display:block;color:#5e8996;font-size:9px;letter-spacing:.22em;font-weight:800;text-transform:uppercase}.identity h1{font-size:17px;letter-spacing:.14em;margin:3px 0 0}.identity h1 b{color:var(--cyan);font-weight:500}.system-strip{display:flex;gap:6px}.status-chip{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;column-gap:8px;align-items:center;padding:7px 11px;border:1px solid var(--line);background:rgba(4,18,26,.68);min-width:84px}.status-chip i{grid-row:1/3;width:6px;height:6px;border-radius:50%;background:var(--amber);box-shadow:0 0 10px currentColor}.status-chip span{font-size:7px;letter-spacing:.18em;color:var(--muted)}.status-chip strong{font-size:9px;color:#b8dce5;letter-spacing:.07em}.status-chip.online i{background:var(--green)}.status-chip.offline i{background:var(--red)}.clock{text-align:right}.clock strong{display:block;font-weight:500;font-variant-numeric:tabular-nums;letter-spacing:.14em;color:#c8f3ff}.clock span{font-size:9px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}
|
||||
.panel{position:relative;border:1px solid var(--line);background:linear-gradient(135deg,rgba(99,236,255,.025),transparent 30%),var(--panel);box-shadow:var(--shadow);padding:16px;overflow:hidden}.panel:before{content:"";position:absolute;top:-1px;left:-1px;width:54px;height:1px;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.panel:after{content:"";position:absolute;right:-1px;bottom:-1px;width:26px;height:26px;border-right:1px solid rgba(99,236,255,.32);border-bottom:1px solid rgba(99,236,255,.32)}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:12px;border-bottom:1px solid rgba(99,236,255,.09)}.panel-head.no-border{border:0;padding-bottom:5px}.panel-head h2{font-size:15px;letter-spacing:.05em;margin:3px 0 0;font-weight:650}.head-actions{display:flex;gap:7px;align-items:center;flex-wrap:wrap;justify-content:flex-end}.hint{font-size:9px;color:var(--muted);letter-spacing:.08em}.live-tag{font-size:8px;letter-spacing:.16em;color:var(--green);display:flex;align-items:center;gap:6px}.live-tag i{width:5px;height:5px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green)}.hud-button,.add-button,.module-mic,.mic-button,.execute,.dialog-close{border:1px solid var(--line2);background:rgba(11,45,57,.45);cursor:pointer;transition:.18s}.hud-button:hover,.add-button:hover,.module-mic:hover,.mic-button:hover{border-color:var(--cyan);box-shadow:0 0 18px rgba(99,236,255,.12)}.hud-button,.add-button{font-size:8px;font-weight:800;letter-spacing:.12em;padding:8px 9px;color:#bfeef7}.hud-button.secondary{border-color:rgba(165,116,255,.3);color:#cdb6ff;background:rgba(94,47,147,.13)}.hud-button.wide{width:100%;margin-top:8px}.add-button{color:var(--cyan)}.module-mic,.mic-button{width:31px;height:31px;border-radius:50%;color:var(--cyan);font-size:12px;box-shadow:inset 0 0 15px rgba(99,236,255,.05)}
|
||||
.command-deck{display:grid;grid-template-columns:220px minmax(0,1fr) 220px;gap:18px;margin-top:12px;min-height:390px}.command-core{position:relative;display:grid;place-items:center;min-height:315px}.radar-ring{position:absolute;border:1px solid rgba(99,236,255,.22);border-radius:50%;animation:spin 16s linear infinite}.r1{width:190px;height:190px;border-style:dashed}.r2{width:145px;height:145px;animation-direction:reverse;animation-duration:10s;border-color:rgba(165,116,255,.2)}.r3{width:105px;height:105px;animation-duration:7s}.command-core.active .radar-ring{border-color:rgba(99,236,255,.62);box-shadow:0 0 16px rgba(99,236,255,.11)}@keyframes spin{to{transform:rotate(360deg)}}.core-orb{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--cyan);box-shadow:0 0 35px rgba(99,236,255,.25),inset 0 0 26px rgba(99,236,255,.15);background:radial-gradient(circle,rgba(99,236,255,.22),rgba(99,236,255,.02) 60%,transparent)}.core-orb span{font-weight:900;color:var(--cyan);font-size:28px;text-shadow:0 0 12px var(--cyan)}.voice-state{position:absolute;bottom:24px;text-align:center}.voice-state small{display:block;font-size:7px;letter-spacing:.19em;color:var(--muted)}.voice-state strong{font-size:10px;letter-spacing:.18em;color:var(--cyan)}.command-main{display:flex;min-width:0;flex-direction:column}.conversation{height:235px;overflow:auto;padding:5px 4px 8px;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}.message{display:flex;gap:9px;margin:10px 0;max-width:92%}.message.user{margin-left:auto;flex-direction:row-reverse}.avatar{width:25px;height:25px;min-width:25px;border:1px solid var(--line2);display:grid;place-items:center;font-size:9px;color:var(--cyan)}.message.user .avatar{color:var(--violet);border-color:rgba(165,116,255,.35)}.message small{font-size:7px;letter-spacing:.15em;color:var(--muted)}.message p{margin:2px 0 0;padding:8px 10px;background:rgba(99,236,255,.035);border-left:1px solid var(--line2);font-size:12px;line-height:1.55;color:#cce7ed;white-space:pre-wrap}.message.user p{border-left:0;border-right:1px solid rgba(165,116,255,.45);background:rgba(165,116,255,.045)}.message.pending p{opacity:.6;animation:blink 1.2s ease-in-out infinite}@keyframes blink{50%{opacity:.3}}.command-bar{display:flex;gap:8px;align-items:flex-end;border-top:1px solid rgba(99,236,255,.1);padding-top:10px}.command-bar textarea{flex:1;resize:none;min-height:38px;max-height:90px;background:rgba(0,8,13,.7);border:1px solid var(--line);padding:10px 12px;outline:0;font-size:12px}.command-bar textarea:focus{border-color:var(--line2);box-shadow:inset 0 0 20px rgba(99,236,255,.03)}.execute{height:38px;padding:0 15px;color:#031015;background:var(--cyan);border-color:var(--cyan);font-size:9px;font-weight:900;letter-spacing:.13em}.execute:hover{box-shadow:0 0 20px rgba(99,236,255,.24)}.source-rail{display:flex;gap:5px;overflow:auto;min-height:24px;padding-top:7px}.source-chip{white-space:nowrap;border:1px solid rgba(99,236,255,.1);padding:4px 6px;color:#668b95;font-size:7px}.source-chip b{color:#8dcbd8}.mission-brief{border-left:1px solid var(--line);padding-left:16px;align-self:stretch;display:flex;flex-direction:column;justify-content:center}.brief-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:12px 0}.brief-grid div{border:1px solid var(--line);padding:10px;background:rgba(0,8,13,.42)}.brief-grid strong{font-size:23px;font-weight:350;color:var(--cyan);display:block}.brief-grid span{font-size:7px;color:var(--muted);letter-spacing:.12em}
|
||||
.dashboard-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:12px;padding:12px 0 110px}.calendar-panel{grid-column:span 8}.tasks-panel{grid-column:span 4}.kanban-panel{grid-column:1/-1}.grocery-panel{grid-column:span 4}.meals-panel{grid-column:span 4}.recipes-panel{grid-column:span 4}.reminders-panel{grid-column:span 4}.knowledge-panel{grid-column:span 5}.voice-panel{grid-column:span 3}.telemetry-panel{grid-column:1/-1}.full-span{grid-column:1/-1}
|
||||
.dashboard-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:12px;padding:12px 0 110px}.calendar-panel{grid-column:span 8}.tasks-panel{grid-column:span 4}.kanban-panel{grid-column:1/-1}.grocery-panel{grid-column:span 4}.meals-panel{grid-column:span 4}.recipes-panel{grid-column:span 4}.reminders-panel{grid-column:span 4}.knowledge-panel{grid-column:span 5}.skills-panel{grid-column:span 4}.voice-panel{grid-column:span 3}.telemetry-panel{grid-column:1/-1}.full-span{grid-column:1/-1}
|
||||
.week-rail{display:grid;grid-template-columns:repeat(7,1fr);gap:5px;margin:12px 0}.day-cell{padding:9px 5px;border:1px solid rgba(99,236,255,.09);background:rgba(0,8,13,.35);text-align:center;position:relative;min-height:62px}.day-cell.today{border-color:var(--line2);background:rgba(99,236,255,.045)}.day-cell span{display:block;font-size:7px;color:var(--muted);letter-spacing:.13em}.day-cell strong{font-size:18px;font-weight:350}.day-cell b{display:inline-block;margin-top:4px;min-width:17px;padding:2px 4px;font-size:7px;color:#001014;background:var(--cyan);border-radius:10px}.agenda{display:grid;gap:6px;max-height:250px;overflow:auto}.agenda-item{display:grid;grid-template-columns:62px 1fr auto;gap:10px;align-items:center;padding:8px;border-left:2px solid var(--cyan2);background:rgba(99,236,255,.025)}.agenda-time{font-size:9px;color:var(--cyan);letter-spacing:.08em}.agenda-item strong{font-size:11px}.agenda-item span{display:block;font-size:8px;color:var(--muted);margin-top:2px}.icon-delete{border:0;background:transparent;color:#527681;cursor:pointer;font-size:16px}.icon-delete:hover{color:var(--red)}
|
||||
.task-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:11px 0}.task-kpis div{border:1px solid var(--line);padding:8px}.task-kpis span{display:block;font-size:6px;color:var(--muted);letter-spacing:.14em}.task-kpis strong{font-size:18px;font-weight:400;color:var(--cyan)}.task-list,.grocery-list,.reminder-list,.document-list{display:grid;gap:5px;max-height:275px;overflow:auto}.task,.grocery-item,.reminder,.doc{display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid rgba(99,236,255,.07);background:rgba(0,8,13,.28)}.task.done,.grocery-item.done,.reminder.done{opacity:.42}.task.done strong,.grocery-item.done strong,.reminder.done strong{text-decoration:line-through}.task-check,.grocery-check,.reminder-check{accent-color:var(--cyan);margin-top:3px}.task-main,.grocery-main,.reminder-main{min-width:0;flex:1}.task-main strong,.grocery-main strong,.reminder-main strong,.doc strong{font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-meta{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}.tag{border:1px solid rgba(99,236,255,.12);font-size:6px;padding:2px 4px;color:#668d98;letter-spacing:.08em}.tag.ai{color:var(--violet);border-color:rgba(165,116,255,.22)}.tag.high{color:var(--red);border-color:rgba(255,97,125,.2)}.confidence{font-size:6px;color:#516f77;margin-top:4px;letter-spacing:.08em}
|
||||
.kanban-board{display:grid;grid-template-columns:repeat(5,minmax(190px,1fr));gap:7px;overflow-x:auto;padding-top:11px}.kanban-col{border:1px solid rgba(99,236,255,.09);background:rgba(0,8,13,.28);min-height:280px;padding:7px}.kanban-col.drag-over{border-color:var(--cyan);box-shadow:inset 0 0 28px rgba(99,236,255,.05)}.kanban-col-head{display:flex;justify-content:space-between;align-items:center;padding:4px 3px 8px;border-bottom:1px solid rgba(99,236,255,.08)}.kanban-col-head strong{font-size:8px;letter-spacing:.14em;color:#9fd7e3}.kanban-col-head span{font-size:8px;color:var(--cyan)}.kanban-cards{display:grid;gap:6px;padding-top:6px}.kanban-card{padding:9px;border:1px solid rgba(99,236,255,.09);border-left:2px solid var(--cyan2);background:rgba(6,21,29,.72);cursor:grab}.kanban-card.manual{border-left-color:var(--violet)}.kanban-card:active{cursor:grabbing}.kanban-card strong{display:block;font-size:9px}.kanban-card p{font-size:7px;color:var(--muted);line-height:1.4;margin:5px 0}.kanban-foot{display:flex;justify-content:space-between;gap:5px;align-items:center}.kanban-foot span{font-size:6px;color:#618690;letter-spacing:.07em}.kanban-foot button{border:0;background:transparent;color:#4f727b;cursor:pointer}.kanban-foot button:hover{color:var(--red)}
|
||||
@@ -12,8 +12,8 @@
|
||||
.wake-row{display:flex;align-items:center;justify-content:space-between;margin:18px 0}.wake-word span{font-size:6px;letter-spacing:.13em;color:var(--muted);display:block}.wake-word strong{font-size:24px;letter-spacing:.18em;color:var(--cyan);font-weight:350}.switch input{display:none}.switch span{display:block;width:43px;height:22px;border:1px solid var(--line2);position:relative;cursor:pointer}.switch span:after{content:"";position:absolute;width:14px;height:14px;left:3px;top:3px;background:#42626b;transition:.2s}.switch input:checked+span:after{left:24px;background:var(--green);box-shadow:0 0 12px var(--green)}.voice-panel p{font-size:8px;color:var(--muted);line-height:1.5}.voice-diag{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.voice-diag div{border:1px solid rgba(99,236,255,.08);padding:7px}.voice-diag span{font-size:6px;color:var(--muted);display:block}.voice-diag b{font-size:8px}.voice-diag .good{color:var(--green)}.voice-diag .bad{color:var(--red)}.activity-list{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;max-height:180px;overflow:auto;padding-top:10px}.activity{display:flex;gap:7px;padding:7px;border:1px solid rgba(99,236,255,.06)}.activity i{width:5px;height:5px;border-radius:50%;background:var(--cyan);margin-top:3px;box-shadow:0 0 7px currentColor}.activity.warning i{background:var(--amber)}.activity.scout i,.activity.kanban i,.activity.automation i{background:var(--violet)}.activity strong{display:block;font-size:7px;line-height:1.4}.activity span{font-size:6px;color:var(--muted)}.empty{padding:16px;text-align:center;font-size:7px;color:#46646d;letter-spacing:.14em;border:1px dashed rgba(99,236,255,.08)}
|
||||
.global-mic{position:fixed;z-index:60;right:22px;bottom:22px;width:72px;height:72px;border-radius:50%;border:1px solid var(--cyan);background:rgba(2,14,20,.94);color:var(--cyan);cursor:pointer;box-shadow:0 0 35px rgba(99,236,255,.18),inset 0 0 22px rgba(99,236,255,.08)}.global-mic .mic-icon{font-size:20px;display:block}.global-mic small{font-size:6px;letter-spacing:.16em;display:block}.mic-halo{position:absolute;inset:-8px;border:1px solid rgba(99,236,255,.16);border-radius:50%;animation:pulse 2.2s ease-out infinite}@keyframes pulse{50%{transform:scale(1.08);opacity:.35}}.global-mic.recording{border-color:var(--red);color:var(--red);box-shadow:0 0 35px rgba(255,97,125,.28)}.global-mic.recording .mic-halo{border-color:var(--red);animation-duration:.8s}.voice-overlay{position:fixed;z-index:55;inset:0;background:rgba(0,5,8,.78);backdrop-filter:blur(8px);display:grid;place-items:center;opacity:0;pointer-events:none;transition:.2s}.voice-overlay.show{opacity:1;pointer-events:auto}.voice-overlay-inner{width:min(500px,calc(100% - 35px));padding:35px;border:1px solid var(--line2);background:rgba(3,16,23,.95);text-align:center;box-shadow:0 0 70px rgba(99,236,255,.12)}.voice-overlay-inner strong{font-size:24px;letter-spacing:.18em;color:var(--cyan);display:block;margin:8px}.voice-overlay-inner p{color:#a8d1da;font-size:13px}.voice-overlay-inner .micro-label b{color:var(--violet)}.toast{position:fixed;z-index:100;left:50%;bottom:28px;transform:translate(-50%,20px);opacity:0;border:1px solid var(--line2);background:#06141b;padding:10px 14px;color:#c9edf4;font-size:9px;pointer-events:none;transition:.2s;max-width:80vw}.toast.show{opacity:1;transform:translate(-50%,0)}
|
||||
.entity-dialog{width:min(650px,calc(100% - 28px));border:1px solid var(--line2);background:#031018;color:var(--text);padding:0;box-shadow:0 30px 110px rgba(0,0,0,.75),0 0 40px rgba(99,236,255,.09)}.entity-dialog::backdrop{background:rgba(0,4,7,.82);backdrop-filter:blur(5px)}.entity-dialog form>header{display:flex;justify-content:space-between;align-items:center;padding:17px;border-bottom:1px solid var(--line)}.entity-dialog h2{font-size:16px;margin:3px 0}.dialog-close{width:32px;height:32px;color:var(--muted);font-size:20px}.dialog-fields{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:17px;max-height:65vh;overflow:auto}.field{display:grid;gap:5px}.field.full{grid-column:1/-1}.field label{font-size:7px;letter-spacing:.13em;color:var(--muted);text-transform:uppercase}.field input,.field textarea,.field select{width:100%;border:1px solid var(--line);background:rgba(0,7,11,.8);padding:9px;outline:0;font-size:11px}.field textarea{min-height:75px;resize:vertical}.field input:focus,.field textarea:focus,.field select:focus{border-color:var(--line2)}.entity-dialog footer{display:flex;justify-content:flex-end;gap:8px;padding:13px 17px;border-top:1px solid var(--line)}
|
||||
@media(max-width:1200px){.command-deck{grid-template-columns:170px 1fr}.mission-brief{display:none}.calendar-panel{grid-column:span 7}.tasks-panel{grid-column:span 5}.grocery-panel,.meals-panel,.recipes-panel{grid-column:span 6}.reminders-panel,.knowledge-panel{grid-column:span 6}.voice-panel{grid-column:span 12}.activity-list{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:850px){.shell{width:min(100% - 18px,1880px)}.hud-top{grid-template-columns:1fr auto;min-height:76px}.system-strip{display:none}.identity h1{font-size:14px}.clock strong{font-size:12px}.command-deck{grid-template-columns:1fr;padding:12px}.command-core{min-height:170px}.r1{width:150px;height:150px}.r2{width:112px;height:112px}.r3{width:80px;height:80px}.core-orb{width:58px;height:58px}.voice-state{bottom:5px}.conversation{height:210px}.dashboard-grid{grid-template-columns:1fr}.calendar-panel,.tasks-panel,.grocery-panel,.meals-panel,.recipes-panel,.reminders-panel,.knowledge-panel,.voice-panel,.full-span{grid-column:1}.kanban-board{grid-template-columns:repeat(5,220px)}.meal-timeline{grid-template-columns:repeat(7,125px)}.activity-list{grid-template-columns:1fr 1fr}.global-mic{width:64px;height:64px;right:14px;bottom:14px}.hint{display:none}}
|
||||
@media(max-width:1200px){.command-deck{grid-template-columns:170px 1fr}.mission-brief{display:none}.calendar-panel{grid-column:span 7}.tasks-panel{grid-column:span 5}.grocery-panel,.meals-panel,.recipes-panel{grid-column:span 6}.reminders-panel,.knowledge-panel,.skills-panel{grid-column:span 6}.voice-panel{grid-column:span 12}.activity-list{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:850px){.shell{width:min(100% - 18px,1880px)}.hud-top{grid-template-columns:1fr auto;min-height:76px}.system-strip{display:none}.identity h1{font-size:14px}.clock strong{font-size:12px}.command-deck{grid-template-columns:1fr;padding:12px}.command-core{min-height:170px}.r1{width:150px;height:150px}.r2{width:112px;height:112px}.r3{width:80px;height:80px}.core-orb{width:58px;height:58px}.voice-state{bottom:5px}.conversation{height:210px}.dashboard-grid{grid-template-columns:1fr}.calendar-panel,.tasks-panel,.grocery-panel,.meals-panel,.recipes-panel,.reminders-panel,.knowledge-panel,.skills-panel,.voice-panel,.full-span{grid-column:1}.kanban-board{grid-template-columns:repeat(5,220px)}.meal-timeline{grid-template-columns:repeat(7,125px)}.activity-list{grid-template-columns:1fr 1fr}.global-mic{width:64px;height:64px;right:14px;bottom:14px}.hint{display:none}}
|
||||
@media(max-width:560px){.panel{padding:12px}.identity .micro-label{display:none}.sigil{width:37px;height:37px}.identity h1{font-size:12px}.clock span{font-size:7px}.week-rail{grid-template-columns:repeat(7,68px);overflow:auto}.day-cell{min-height:56px}.panel-head{align-items:flex-start}.panel-head h2{font-size:13px}.head-actions{gap:4px}.add-button{font-size:0;width:31px;height:31px;padding:0}.add-button:before{content:"+";font-size:15px}.agenda-item{grid-template-columns:50px 1fr auto}.recipe-grid{grid-template-columns:1fr}.activity-list{grid-template-columns:1fr}.command-bar{display:grid;grid-template-columns:32px 1fr}.command-bar textarea{grid-column:2}.execute{grid-column:1/-1}.dialog-fields{grid-template-columns:1fr}.field.full{grid-column:1}.voice-diag{grid-template-columns:1fr}.mission-brief{display:none}}
|
||||
|
||||
/* Command router polish */
|
||||
@@ -160,7 +160,7 @@ body[data-theme="apple"]:not([data-view="home"]) .command-deck{display:none}
|
||||
body[data-theme="apple"] .clean-home-overview{display:none}
|
||||
body[data-theme="apple"][data-view="home"] .clean-home-overview{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:14px;margin-bottom:18px}
|
||||
.clean-welcome-card,.clean-home-card{border:1px solid rgba(15,23,42,.065);border-radius:26px;background:rgba(255,255,255,.82);box-shadow:0 16px 42px rgba(15,23,42,.055)}
|
||||
.clean-welcome-card{grid-column:span 7;padding:26px;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(300px,.75fr);gap:28px;align-items:center;background:linear-gradient(145deg,#fff 0%,#f9fafb 55%,#f0f4f8 100%)}.clean-eyebrow{font-size:11px;color:#7e8794;font-weight:650}.clean-welcome-card h2{font-size:30px;line-height:1.08;letter-spacing:-.035em;margin:4px 0 9px;color:#111827}.clean-welcome-card p{margin:0;max-width:560px;color:#667085;font-size:13px;line-height:1.55}.clean-quick-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.clean-quick-grid button{display:grid;grid-template-columns:30px 1fr;grid-template-rows:auto auto;align-items:center;column-gap:7px;padding:12px;border-radius:17px;border:1px solid rgba(15,23,42,.06);background:rgba(255,255,255,.8);text-align:left;cursor:pointer;color:#111827;transition:.17s}.clean-quick-grid button:hover{transform:translateY(-1px);background:#fff;box-shadow:0 9px 20px rgba(15,23,42,.07)}.clean-quick-grid button>span{grid-row:1/3;width:30px;height:30px;border-radius:10px;background:#f1f3f6;display:grid;place-items:center;font-size:15px}.clean-quick-grid strong{font-size:11px}.clean-quick-grid small{font-size:9px;color:#8c95a2;margin-top:2px}.clean-home-card{padding:19px 20px}.clean-next-card{grid-column:span 5}.clean-task-card,.clean-meal-card{grid-column:span 6}.clean-home-card header{display:flex;align-items:center;justify-content:space-between;margin-bottom:17px}.clean-home-card header>span{font-size:12px;font-weight:700;color:#374151}.clean-home-card header button{border:0;background:transparent;color:#6b7280;font-size:10px;cursor:pointer;padding:0}.clean-home-card header button:hover{color:#111827}.clean-home-card-body strong{display:block;font-size:17px;letter-spacing:-.02em;color:#111827}.clean-home-card-body p{margin:5px 0 0;font-size:11px;color:#7c8592}.clean-system-card{grid-column:1/-1}.clean-system-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.clean-system-grid div{padding:12px;border-radius:16px;background:#f5f6f8}.clean-system-grid span{display:block;font-size:9px;color:#8b93a1}.clean-system-grid strong{display:block;margin-top:4px;font-size:11px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.clean-welcome-card{grid-column:span 7;padding:26px;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(300px,.75fr);gap:28px;align-items:center;background:linear-gradient(145deg,#fff 0%,#f9fafb 55%,#f0f4f8 100%)}.clean-eyebrow{font-size:11px;color:#7e8794;font-weight:650}.clean-welcome-card h2{font-size:30px;line-height:1.08;letter-spacing:-.035em;margin:4px 0 9px;color:#111827}.clean-welcome-card p{margin:0;max-width:560px;color:#667085;font-size:13px;line-height:1.55}.clean-quick-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.clean-quick-grid button{display:grid;grid-template-columns:30px 1fr;grid-template-rows:auto auto;align-items:center;column-gap:7px;padding:12px;border-radius:17px;border:1px solid rgba(15,23,42,.06);background:rgba(255,255,255,.8);text-align:left;cursor:pointer;color:#111827;transition:.17s}.clean-quick-grid button:hover{transform:translateY(-1px);background:#fff;box-shadow:0 9px 20px rgba(15,23,42,.07)}.clean-quick-grid button>span{grid-row:1/3;width:30px;height:30px;border-radius:10px;background:#f1f3f6;display:grid;place-items:center;font-size:15px}.clean-quick-grid strong{font-size:11px}.clean-quick-grid small{font-size:9px;color:#8c95a2;margin-top:2px}.clean-home-card{padding:19px 20px}.clean-next-card{grid-column:span 5}.clean-task-card,.clean-meal-card{grid-column:span 6}.clean-home-card header{display:flex;align-items:center;justify-content:space-between;margin-bottom:17px}.clean-home-card header>span{font-size:12px;font-weight:700;color:#374151}.clean-home-card header button{border:0;background:transparent;color:#6b7280;font-size:10px;cursor:pointer;padding:0}.clean-home-card header button:hover{color:#111827}.clean-home-card-body strong{display:block;font-size:17px;letter-spacing:-.02em;color:#111827}.clean-home-card-body p{margin:5px 0 0;font-size:11px;color:#7c8592}.clean-system-card{grid-column:1/-1}.clean-system-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px}.clean-system-grid div{padding:12px;border-radius:16px;background:#f5f6f8}.clean-system-grid span{display:block;font-size:9px;color:#8b93a1}.clean-system-grid strong{display:block;margin-top:4px;font-size:11px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* Clean views: one job per screen. */
|
||||
body[data-theme="apple"] .dashboard-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:14px;padding:0 0 45px}
|
||||
@@ -170,12 +170,12 @@ body[data-theme="apple"][data-view="calendar"] .calendar-panel,body[data-theme="
|
||||
body[data-theme="apple"][data-view="tasks"] .tasks-panel{display:block!important}
|
||||
body[data-theme="apple"][data-view="kanban"] .kanban-panel{display:block!important}
|
||||
body[data-theme="apple"][data-view="food"] .grocery-panel,body[data-theme="apple"][data-view="food"] .meals-panel,body[data-theme="apple"][data-view="food"] .recipes-panel{display:block!important}
|
||||
body[data-theme="apple"][data-view="knowledge"] .knowledge-panel,body[data-theme="apple"][data-view="knowledge"] .voice-panel,body[data-theme="apple"][data-view="knowledge"] .telemetry-panel{display:block!important}
|
||||
body[data-theme="apple"][data-view="knowledge"] .knowledge-panel,body[data-theme="apple"][data-view="knowledge"] .skills-panel,body[data-theme="apple"][data-view="knowledge"] .voice-panel,body[data-theme="apple"][data-view="knowledge"] .telemetry-panel{display:block!important}
|
||||
body[data-theme="apple"][data-view="calendar"] .calendar-panel{grid-column:span 8}body[data-theme="apple"][data-view="calendar"] .reminders-panel{grid-column:span 4}
|
||||
body[data-theme="apple"][data-view="tasks"] .tasks-panel{grid-column:1/-1;min-height:560px}
|
||||
body[data-theme="apple"][data-view="kanban"] .kanban-panel{grid-column:1/-1;min-height:620px}
|
||||
body[data-theme="apple"][data-view="food"] .meals-panel{grid-column:span 7}body[data-theme="apple"][data-view="food"] .grocery-panel{grid-column:span 5}body[data-theme="apple"][data-view="food"] .recipes-panel{grid-column:1/-1}
|
||||
body[data-theme="apple"][data-view="knowledge"] .knowledge-panel{grid-column:span 7}body[data-theme="apple"][data-view="knowledge"] .voice-panel{grid-column:span 5}body[data-theme="apple"][data-view="knowledge"] .telemetry-panel{grid-column:1/-1}
|
||||
body[data-theme="apple"][data-view="knowledge"] .knowledge-panel{grid-column:span 6}body[data-theme="apple"][data-view="knowledge"] .skills-panel{grid-column:span 6}body[data-theme="apple"][data-view="knowledge"] .voice-panel{grid-column:1/-1}body[data-theme="apple"][data-view="knowledge"] .telemetry-panel{grid-column:1/-1}
|
||||
|
||||
/* Remove HUD language and increase real content contrast in Clean mode. */
|
||||
body[data-theme="apple"] .panel{padding:22px 22px 24px;border-radius:26px;box-shadow:0 16px 42px rgba(15,23,42,.055)}
|
||||
@@ -352,3 +352,25 @@ body[data-theme="apple"] .recipe-card .recipe-actions button:hover{color:#1d4ed8
|
||||
body[data-theme="apple"] .message{max-width:94%}
|
||||
body[data-theme="apple"] .message p{font-size:13.5px;line-height:1.58}
|
||||
}
|
||||
/* Skill registry */
|
||||
.skill-intro{margin:2px 0 12px;font-size:8px;line-height:1.55;color:var(--muted)}
|
||||
.skill-list{display:grid;gap:7px;max-height:310px;overflow:auto;padding-right:2px}
|
||||
.skill-card{border:1px solid rgba(99,236,255,.08);padding:10px 11px;background:rgba(255,255,255,.015);display:grid;gap:6px}
|
||||
.skill-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.skill-card-head strong{font-size:9px}.skill-card-head span{font-size:6px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}.skill-card p{margin:0;font-size:7px;line-height:1.45;color:var(--muted)}
|
||||
.skill-actions{display:flex;gap:5px;flex-wrap:wrap}.skill-action-chip{font-size:6px;border:1px solid rgba(99,236,255,.09);padding:4px 6px;color:#8fb5be}.skill-action-chip.mutates{border-color:rgba(255,184,74,.18);color:var(--amber)}.skill-action-chip.confirm{border-color:rgba(255,90,120,.18);color:#ff7895}
|
||||
.skill-perms{font-size:6px;color:#46656e;letter-spacing:.06em}.skill-error{color:var(--red)!important}
|
||||
body[data-theme="apple"] .skill-intro{font-size:11px;color:#697386;line-height:1.55}
|
||||
body[data-theme="apple"] .skill-list{max-height:420px;gap:9px}
|
||||
body[data-theme="apple"] .skill-card{border-color:rgba(15,23,42,.07);background:#f7f8fa;border-radius:18px;padding:14px 15px;gap:8px}
|
||||
body[data-theme="apple"] .skill-card-head strong{font-size:12px;color:#111827}body[data-theme="apple"] .skill-card-head span{font-size:9px;color:#7c8592}
|
||||
body[data-theme="apple"] .skill-card p{font-size:10px;color:#667085}
|
||||
body[data-theme="apple"] .skill-action-chip{border:0;border-radius:999px;background:#eef2f7;color:#475569;font-size:9px;padding:5px 8px}body[data-theme="apple"] .skill-action-chip.mutates{background:#fff7ed;color:#b45309}body[data-theme="apple"] .skill-action-chip.confirm{box-shadow:inset 0 0 0 1px rgba(220,38,38,.12)}
|
||||
body[data-theme="apple"] .skill-perms{font-size:9px;color:#8a93a0}
|
||||
|
||||
@media(max-width:700px){body[data-theme="apple"] .clean-system-grid{grid-template-columns:1fr 1fr}}
|
||||
|
||||
/* v9 distributed Skill Mesh */
|
||||
.mesh-panel{grid-column:1/-1}.mesh-layout{display:grid;grid-template-columns:1fr 1fr;gap:12px}.mesh-subtitle{margin:2px 0 8px;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}.mesh-worker-list,.docker-skill-list{display:grid;gap:8px}.mesh-card{border:1px solid var(--line);background:rgba(255,255,255,.025);padding:10px 11px;clip-path:polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px)}.mesh-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.mesh-card strong{font-size:11px}.mesh-card small{color:var(--muted);font-size:8px;letter-spacing:.06em}.mesh-meta{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}.mesh-meta span{font-size:8px;padding:4px 6px;border:1px solid var(--line);color:var(--muted)}.mesh-meta .online{color:var(--green)}.mesh-meta .offline{color:var(--red)}.docker-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}.docker-actions button{border:1px solid var(--line);background:transparent;color:var(--text);font:700 8px Inter,sans-serif;letter-spacing:.08em;padding:5px 8px;cursor:pointer}.docker-actions button:hover{border-color:var(--cyan);color:var(--cyan)}.docker-actions button.danger:hover{border-color:var(--red);color:var(--red)}
|
||||
body[data-theme="apple"] .mesh-card{clip-path:none;border-radius:18px;background:rgba(255,255,255,.72);border-color:rgba(15,23,42,.07);box-shadow:0 8px 20px rgba(15,23,42,.035)}body[data-theme="apple"] .mesh-card strong{color:#111827}body[data-theme="apple"] .mesh-meta span{border-radius:999px;background:#f3f4f6;border-color:rgba(15,23,42,.05);color:#64748b}body[data-theme="apple"] .docker-actions button{border-radius:999px;background:#fff;border-color:rgba(15,23,42,.08);color:#334155;padding:6px 10px}body[data-theme="apple"] .mesh-subtitle{color:#667085;letter-spacing:.04em;text-transform:none;font-size:11px}
|
||||
body[data-theme="apple"][data-view="knowledge"] .mesh-panel{display:block!important;grid-column:1/-1}
|
||||
@media(max-width:900px){.mesh-layout{grid-template-columns:1fr}}
|
||||
|
||||
@@ -35,3 +35,37 @@ check_file "Piper model" "${VOICE_PIPER_MODEL:-/models/de_DE-thorsten-medium.onn
|
||||
check_file "Piper model config" "${VOICE_PIPER_MODEL:-/models/de_DE-thorsten-medium.onnx}.json"
|
||||
printf 'LD_LIBRARY_PATH=%s\n' "${LD_LIBRARY_PATH:-}"
|
||||
printf 'PATH=%s\n' "$PATH"
|
||||
|
||||
printf '%s\n' '--- JARVIS Skill Doctor ---'
|
||||
skills_dir="${JARVIS_SKILLS_DIR:-./skills}"
|
||||
if [ -d "$skills_dir" ]; then
|
||||
ok "Skill directory: $skills_dir"
|
||||
find "$skills_dir" -mindepth 2 -maxdepth 2 -name skill.json -print 2>/dev/null | while IFS= read -r manifest; do
|
||||
printf ' - %s\n' "$manifest"
|
||||
done
|
||||
else
|
||||
warn "Skill directory fehlt: $skills_dir"
|
||||
fi
|
||||
printf 'JARVIS_SKILLS_ENABLED=%s\n' "${JARVIS_SKILLS_ENABLED:-true}"
|
||||
printf 'JARVIS_SKILL_PROCESS_ENABLED=%s\n' "${JARVIS_SKILL_PROCESS_ENABLED:-true}"
|
||||
printf 'JARVIS_SKILL_ALLOW_SYSTEM_EXEC=%s\n' "${JARVIS_SKILL_ALLOW_SYSTEM_EXEC:-false}"
|
||||
|
||||
printf '%s\n' '--- JARVIS Skill Mesh Doctor ---'
|
||||
printf 'JARVIS_MESH_ENABLED=%s\n' "${JARVIS_MESH_ENABLED:-true}"
|
||||
if [ -n "${JARVIS_MESH_ENROLLMENT_TOKEN:-}" ]; then
|
||||
ok "Mesh enrollment token: configured (value hidden)"
|
||||
else
|
||||
warn "Mesh enrollment token not configured"
|
||||
fi
|
||||
printf 'JARVIS_MESH_LEASE_SECONDS=%s\n' "${JARVIS_MESH_LEASE_SECONDS:-30}"
|
||||
printf 'JARVIS_MESH_INVOKE_TIMEOUT_MS=%s\n' "${JARVIS_MESH_INVOKE_TIMEOUT_MS:-30000}"
|
||||
printf 'JARVIS_DOCKER_CONTROLLER_ENABLED=%s\n' "${JARVIS_DOCKER_CONTROLLER_ENABLED:-false}"
|
||||
docker_sock="${JARVIS_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
if [ -S "$docker_sock" ]; then
|
||||
ok "Docker socket: $docker_sock"
|
||||
elif [ "${JARVIS_DOCKER_CONTROLLER_ENABLED:-false}" = "true" ]; then
|
||||
warn "Docker controller enabled but socket missing: $docker_sock"
|
||||
else
|
||||
printf 'Docker socket: %s (not required while controller is disabled)\n' "$docker_sock"
|
||||
fi
|
||||
printf 'Docker skill label=%s=%s\n' "${JARVIS_DOCKER_SKILL_LABEL:-com.jarvis.skill-service}" "${JARVIS_DOCKER_SKILL_LABEL_VALUE:-true}"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# JARVIS Skills – v9 Skill Mesh
|
||||
|
||||
Ein Skill bleibt ein Modul nach `jarvis.skill.v1`. In Docker wird Plugin-Code jetzt standardmäßig **nicht im Master**, sondern in einem Runtime-Worker ausgeführt.
|
||||
|
||||
## Ordnerlayout
|
||||
|
||||
```text
|
||||
skills/
|
||||
python/<skill>/skill.json
|
||||
node/<skill>/skill.json
|
||||
go/<skill>/skill.json
|
||||
rust/<skill>/skill.json
|
||||
c/<skill>/skill.json
|
||||
cpp/<skill>/skill.json
|
||||
csharp/<skill>/skill.json
|
||||
```
|
||||
|
||||
Jeder Worker mountet nur seinen Runtime-Ordner als `/skills`.
|
||||
|
||||
## skill.json
|
||||
|
||||
Beispiel Python:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "weather.local",
|
||||
"name": "Local Weather",
|
||||
"version": "1.0.0",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": ["main.py"],
|
||||
"timeout_ms": 5000
|
||||
},
|
||||
"permissions": {"system_exec": true},
|
||||
"actions": [{
|
||||
"name": "forecast",
|
||||
"description": "Liefert eine Vorhersage.",
|
||||
"mutates": false,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
`system_exec` bezieht sich hier nur auf den **Worker-Container**. Er erlaubt z. B. `python3`, `node`, `go`, `cargo`, `gcc`, `g++` oder `dotnet` aus dessen PATH. Der Worker hat keinen Docker-Socket.
|
||||
|
||||
Alternativ kann `runtime.command` auf ein lokales `./run` zeigen. Das ist besonders für C/C++/Rust/Go praktisch: `run` kann kompilierte Artefakte cachen und anschließend das Binary starten.
|
||||
|
||||
## Invoke Input
|
||||
|
||||
Der Skill-Prozess bekommt ein JSON-Objekt auf stdin:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.invoke.v1",
|
||||
"request_id": "remote_...",
|
||||
"skill_id": "weather.local",
|
||||
"action": "forecast",
|
||||
"input": {"location": "Berlin"},
|
||||
"context": {
|
||||
"trace_id": "http_...",
|
||||
"now": "2026-08-29T10:20:00+02:00",
|
||||
"timezone": "Europe/Berlin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
stdout muss genau eine JSON-Antwort enthalten. Logs gehören auf stderr.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "jarvis.skill.invoke.v1",
|
||||
"success": true,
|
||||
"data": {"summary": "Sonnig"},
|
||||
"message": "Vorhersage geladen.",
|
||||
"mutated": false
|
||||
}
|
||||
```
|
||||
|
||||
Input und Output werden im Worker **und** beim Master gegen die veröffentlichten Schemas geprüft.
|
||||
|
||||
## Reload
|
||||
|
||||
Nach Änderungen am gemounteten Skill-Verzeichnis kann der Worker ohne Master-Neustart neu laden. Im UI unter **Wissen & System -> Skill Worker & Container** auf `RELOAD SKILLS` klicken.
|
||||
|
||||
## Lokale Process-Skills
|
||||
|
||||
Der alte lokale Provider bleibt für Entwicklung erhalten. Ordner direkt unter `skills/<name>` können weiterhin vom Master geladen werden, wenn `JARVIS_SKILL_PROCESS_ENABLED=true` gesetzt ist. Im Docker-v9-Compose ist das absichtlich `false`; dort sollen Runtime-Skills über Worker laufen.
|
||||
|
||||
Ein Worker kann mehrere Skills hosten **oder** genau einen Skill-Container darstellen. Für 1 Container = 1 Skill darf direkt der Skill-Ordner auf `/skills` gemountet werden; der Worker erkennt auch `/skills/skill.json` als Root-Skill.
|
||||
|
||||
## v9.1: `runtime.env_from`
|
||||
|
||||
Remote-Skills können einzelne Konfigurationswerte/Secrets explizit aus der Worker-Environment übernehmen, ohne sie in `skill.json` zu speichern:
|
||||
|
||||
```json
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": ["main.py"],
|
||||
"env_from": ["SERVICE_URL", "SERVICE_API_TOKEN"]
|
||||
}
|
||||
```
|
||||
|
||||
Nur die aufgelisteten Variablen werden an den Skill-Prozess weitergereicht. Variablen mit `JARVIS_`-Präfix werden unabhängig vom Manifest nicht vererbt.
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
payload=$(cat)
|
||||
# This demo intentionally avoids jq so the stock Docker image can run it.
|
||||
# Extract the input text for a very small, deterministic protocol example.
|
||||
text=$(printf '%s' "$payload" | sed -n 's/.*"text"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
chars=$(printf '%s' "$text" | wc -c | tr -d ' ')
|
||||
words=$(printf '%s' "$text" | awk '{print NF}')
|
||||
printf '{"protocol":"jarvis.skill.v1","success":true,"data":{"characters":%s,"words":%s},"message":"Text analysiert: %s Wörter, %s Zeichen."}\n' "$chars" "$words" "$words" "$chars"
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "demo.text",
|
||||
"name": "Demo Text Skill",
|
||||
"version": "1.0.0",
|
||||
"description": "Kleine Beispiel-Erweiterung für Textoperationen. Demonstriert den pluginfähigen Skill-Frame.",
|
||||
"enabled": true,
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "./run",
|
||||
"timeout_ms": 3000
|
||||
},
|
||||
"permissions": {
|
||||
"network": false,
|
||||
"system_exec": false
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "inspect",
|
||||
"description": "Analysiert einen Text deterministisch und liefert Zeichen- und Wortanzahl. Verwenden, wenn der Nutzer ausdrücklich Textstatistiken oder Wortanzahl verlangt.",
|
||||
"triggers": ["wortanzahl", "wörter zählen", "textstatistik", "zeichen zählen"],
|
||||
"mutates": false,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Zu analysierender Text"}
|
||||
},
|
||||
"required": ["text"]
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"characters": {"type": "integer"},
|
||||
"words": {"type": "integer"}
|
||||
},
|
||||
"required": ["characters", "words"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# stdin: one jarvis.skill.v1 request envelope as JSON
|
||||
# stdout: exactly one jarvis.skill.v1 response envelope as JSON
|
||||
# Logs belong on stderr.
|
||||
cat >/tmp/jarvis-skill-input.json
|
||||
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"result":"template"},"message":"Template ausgeführt."}'
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "my.skill",
|
||||
"name": "My Skill",
|
||||
"version": "1.0.0",
|
||||
"description": "Kurze Beschreibung, wann JARVIS diesen Skill verwenden soll.",
|
||||
"enabled": true,
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "./run",
|
||||
"args": [],
|
||||
"timeout_ms": 5000,
|
||||
"env": {}
|
||||
},
|
||||
"permissions": {
|
||||
"network": false,
|
||||
"system_exec": false
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "example",
|
||||
"description": "Beschreibt präzise, was diese Aktion kann.",
|
||||
"triggers": ["beispiel", "mein skill"],
|
||||
"mutates": false,
|
||||
"requires_confirmation": false,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Eingabetext"}
|
||||
},
|
||||
"required": ["text"]
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"result": {"type": "string"}
|
||||
},
|
||||
"required": ["result"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import json, os, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
NAMES=('compose.yaml','compose.yml','docker-compose.yaml','docker-compose.yml')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def root():
|
||||
p=Path(os.getenv('DOCKGE_STACKS_DIR','/opt/stacks')).resolve()
|
||||
if not p.exists() or not p.is_dir(): raise RuntimeError(f'Dockge-Stacks-Verzeichnis fehlt: {p}')
|
||||
return p
|
||||
def stack_dir(name):
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+',name or ''): raise RuntimeError('Ungültiger Stack-Name')
|
||||
p=(root()/name).resolve(); r=root()
|
||||
if p.parent!=r or not p.is_dir(): raise RuntimeError('Stack nicht gefunden')
|
||||
if not any((p/n).is_file() for n in NAMES): raise RuntimeError('Keine Compose-Datei im Stack gefunden')
|
||||
return p
|
||||
def allowed(name):
|
||||
vals=[x.strip() for x in os.getenv('DOCKGE_ALLOWED_STACKS','').split(',') if x.strip()]
|
||||
return '*' in vals or name in vals
|
||||
def require_allowed(name):
|
||||
if not allowed(name): raise RuntimeError('Mutation verweigert: Stack ist nicht in DOCKGE_ALLOWED_STACKS freigegeben')
|
||||
def compose_cmd():
|
||||
# Prefer modern plugin; fall back to legacy docker-compose.
|
||||
try:
|
||||
subprocess.run(['docker','compose','version'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=5)
|
||||
return ['docker','compose']
|
||||
except Exception:
|
||||
return ['docker-compose']
|
||||
def run(name,args,timeout=90):
|
||||
p=stack_dir(name); cmd=compose_cmd()+args
|
||||
cp=subprocess.run(cmd,cwd=p,text=True,capture_output=True,timeout=timeout)
|
||||
if cp.returncode!=0: raise RuntimeError((cp.stderr or cp.stdout or f'Exit {cp.returncode}')[-1800:])
|
||||
return (cp.stdout or '').strip()
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='list_stacks':
|
||||
out=[]
|
||||
for p in sorted(root().iterdir()):
|
||||
if not p.is_dir(): continue
|
||||
cf=next((n for n in NAMES if (p/n).is_file()),None)
|
||||
if cf: out.append({'name':p.name,'compose_file':cf,'mutation_allowed':allowed(p.name)})
|
||||
return ok({'stacks':out,'count':len(out)},'Dockge-Stacks geladen.')
|
||||
name=x['stack']
|
||||
if a=='stack_status':
|
||||
raw=run(name,['ps','--format','json'],30); items=[]
|
||||
for line in raw.splitlines():
|
||||
try:
|
||||
v=json.loads(line)
|
||||
items.extend(v if isinstance(v,list) else [v])
|
||||
except Exception: pass
|
||||
return ok({'stack':name,'services':items,'count':len(items)},'Stack-Status geladen.')
|
||||
require_allowed(name)
|
||||
if a=='start_stack': run(name,['up','-d','--remove-orphans']); return ok({'stack':name,'action':'start'},'Stack gestartet.',True)
|
||||
if a=='stop_stack': run(name,['stop']); return ok({'stack':name,'action':'stop'},'Stack gestoppt.',True)
|
||||
if a=='restart_stack': run(name,['restart']); return ok({'stack':name,'action':'restart'},'Stack neu gestartet.',True)
|
||||
if a=='update_stack':
|
||||
run(name,['pull'],120); run(name,['up','-d','--remove-orphans'],120); return ok({'stack':name,'action':'update'},'Stack aktualisiert.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except subprocess.TimeoutExpired: res=fail('DOCKGE_TIMEOUT','Docker-Compose-Aktion hat das Zeitlimit überschritten')
|
||||
except Exception as e: res=fail('DOCKGE_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "dockge.compose",
|
||||
"name": "Dockge Stack Control",
|
||||
"version": "1.0.0",
|
||||
"description": "Steuert Dockge-verwaltete Compose-Stacks direkt über deren Stack-Verzeichnis und Docker Compose. Nutzt bewusst nicht Dockges instabile interne Socket-API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 120000,
|
||||
"env_from": [
|
||||
"DOCKGE_STACKS_DIR",
|
||||
"DOCKGE_ALLOWED_STACKS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "list_stacks",
|
||||
"description": "Listet Dockge-Stack-Verzeichnisse und deren Compose-Dateien.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stacks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stacks",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stack_status",
|
||||
"description": "Liest den Docker-Compose-Status eines Dockge-Stacks.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start_stack",
|
||||
"description": "Startet/deployed einen erlaubten Dockge-Stack mit docker compose up -d.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stop_stack",
|
||||
"description": "Stoppt einen erlaubten Dockge-Stack.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "restart_stack",
|
||||
"description": "Startet einen erlaubten Dockge-Stack neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_stack",
|
||||
"description": "Pullt Images eines erlaubten Dockge-Stacks und deployed ihn anschließend neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, sys
|
||||
req=json.load(sys.stdin)
|
||||
text=str(req.get("input",{}).get("text",""))
|
||||
json.dump({"protocol":"jarvis.skill.invoke.v1","success":True,"data":{"text":text.upper()},"message":"Text verarbeitet."},sys.stdout)
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "example.python.text",
|
||||
"name": "Python Text Example",
|
||||
"version": "1.0.0",
|
||||
"description": "Beispiel für einen Remote-Python-Skill im Skill Mesh.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": ["main.py"],
|
||||
"timeout_ms": 5000
|
||||
},
|
||||
"permissions": {"system_exec": true},
|
||||
"actions": [{
|
||||
"name": "uppercase",
|
||||
"description": "Wandelt Text in Großbuchstaben um.",
|
||||
"triggers": ["großbuchstaben", "uppercase"],
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import json, os, socket, ssl, sys, time
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='tcp_check':
|
||||
t=time.perf_counter(); reachable=True
|
||||
try:
|
||||
with socket.create_connection((x['host'],int(x['port'])),timeout=float(x.get('timeout_seconds',3))): pass
|
||||
except OSError: reachable=False
|
||||
ms=(time.perf_counter()-t)*1000; return ok({'reachable':reachable,'latency_ms':round(ms,2)},'TCP-Prüfung abgeschlossen.')
|
||||
if a=='http_check':
|
||||
t=time.perf_counter(); status=0; reachable=False; verify=bool(x.get('verify_tls',True)); ctx=None
|
||||
if x['url'].startswith('https://') and not verify: ctx=ssl._create_unverified_context()
|
||||
try:
|
||||
with urlopen(Request(x['url'],headers={'User-Agent':'JARVIS-Skill/1'}),timeout=float(x.get('timeout_seconds',5)),context=ctx) as r: status=int(r.status); reachable=True
|
||||
except HTTPError as e: status=int(e.code); reachable=True
|
||||
except Exception: pass
|
||||
ms=(time.perf_counter()-t)*1000; return ok({'reachable':reachable,'status':status,'latency_ms':round(ms,2)},'HTTP-Prüfung abgeschlossen.')
|
||||
if a=='dns_lookup':
|
||||
vals=sorted({i[4][0] for i in socket.getaddrinfo(x['host'],None)}); return ok({'addresses':vals,'count':len(vals)},'DNS aufgelöst.')
|
||||
if a=='wake_on_lan':
|
||||
mac=''.join(c for c in x['mac'] if c.isalnum())
|
||||
if len(mac)!=12: return fail('INVALID_MAC','MAC-Adresse ungültig')
|
||||
packet=b'\xff'*6+bytes.fromhex(mac)*16; broad=x.get('broadcast') or os.getenv('WOL_BROADCAST','255.255.255.255'); port=int(x.get('port') or os.getenv('WOL_PORT','9'))
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1); s.sendto(packet,(broad,port)); s.close()
|
||||
return ok({'sent':True,'broadcast':broad,'port':port},'Wake-on-LAN gesendet.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('NETWORK_TOOL_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "home.network-tools",
|
||||
"name": "Home Network Tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Kleine Netzwerk-Werkzeuge für Erreichbarkeit, DNS und Wake-on-LAN.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 10000,
|
||||
"env_from": [
|
||||
"WOL_BROADCAST",
|
||||
"WOL_PORT"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "tcp_check",
|
||||
"description": "Prüft, ob ein TCP-Port erreichbar ist.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reachable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reachable",
|
||||
"latency_ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "http_check",
|
||||
"description": "Prüft einen HTTP/HTTPS-Endpunkt und misst die Antwortzeit.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"verify_tls": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reachable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reachable",
|
||||
"status",
|
||||
"latency_ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dns_lookup",
|
||||
"description": "Löst einen Hostnamen in IP-Adressen auf.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"addresses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"addresses",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wake_on_lan",
|
||||
"description": "Sendet ein Wake-on-LAN Magic Packet an ein Gerät.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mac": {
|
||||
"type": "string"
|
||||
},
|
||||
"broadcast": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mac"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"broadcast": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sent",
|
||||
"broadcast",
|
||||
"port"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def main(inv):
|
||||
x=inv.get('input') or {}; base=os.getenv('NTFY_BASE_URL','https://ntfy.sh').strip().rstrip('/'); topic=(x.get('topic') or os.getenv('NTFY_DEFAULT_TOPIC','')).strip()
|
||||
if not topic: return fail('CONFIG_MISSING','NTFY_DEFAULT_TOPIC oder input.topic fehlt')
|
||||
h={'Content-Type':'text/plain; charset=utf-8'}; token=os.getenv('NTFY_TOKEN','').strip()
|
||||
if token: h['Authorization']='Bearer '+token
|
||||
if x.get('title'): h['Title']=x['title']
|
||||
if x.get('priority'): h['Priority']=x['priority']
|
||||
if x.get('tags'): h['Tags']=x['tags']
|
||||
ctx=ssl._create_unverified_context() if base.startswith('https://') and not truthy(os.getenv('NTFY_VERIFY_TLS','true')) else None
|
||||
try:
|
||||
with urlopen(Request(base+'/'+topic,data=x['message'].encode(),headers=h,method='POST'),timeout=6,context=ctx) as r: r.read()
|
||||
except HTTPError as e: raise RuntimeError(f'ntfy HTTP {e.code}: {e.read().decode(errors="replace")[:500]}')
|
||||
except URLError as e: raise RuntimeError(f'ntfy nicht erreichbar: {e.reason}')
|
||||
return ok({'sent':True,'topic':topic},'Benachrichtigung gesendet.',True)
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('NTFY_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "notify.ntfy",
|
||||
"name": "ntfy Notifications",
|
||||
"version": "1.0.0",
|
||||
"description": "Sendet Benachrichtigungen an einen selbstgehosteten oder öffentlichen ntfy-Server.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 8000,
|
||||
"env_from": [
|
||||
"NTFY_BASE_URL",
|
||||
"NTFY_TOKEN",
|
||||
"NTFY_DEFAULT_TOPIC",
|
||||
"NTFY_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "send",
|
||||
"description": "Sendet eine ntfy-Benachrichtigung.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sent",
|
||||
"topic"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
PROTO="jarvis.skill.invoke.v1"
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(code,msg): return {"protocol":PROTO,"success":False,"error":{"code":code,"message":msg}}
|
||||
def ok(data,msg="OK",mut=False): return {"protocol":PROTO,"success":True,"data":data,"message":msg,"mutated":mut}
|
||||
def config():
|
||||
base=os.getenv('HUE_BRIDGE_URL','').strip().rstrip('/')
|
||||
key=os.getenv('HUE_APP_KEY','').strip()
|
||||
if not base or not key: raise RuntimeError('HUE_BRIDGE_URL und HUE_APP_KEY müssen im Worker gesetzt sein')
|
||||
if not base.startswith(('http://','https://')): base='https://'+base
|
||||
return base,key,truthy(os.getenv('HUE_VERIFY_TLS','false'))
|
||||
def req(method,path,body=None):
|
||||
base,key,verify=config(); data=None
|
||||
headers={'Accept':'application/json','hue-application-key':key}
|
||||
if body is not None:
|
||||
data=json.dumps(body).encode(); headers['Content-Type']='application/json'
|
||||
ctx=None
|
||||
if base.startswith('https://') and not verify: ctx=ssl._create_unverified_context()
|
||||
r=Request(base+path,data=data,headers=headers,method=method)
|
||||
try:
|
||||
with urlopen(r,timeout=6,context=ctx) as resp:
|
||||
raw=resp.read(); return json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as e:
|
||||
detail=e.read().decode(errors='replace')[:1000]; raise RuntimeError(f'Hue HTTP {e.code}: {detail}')
|
||||
except URLError as e: raise RuntimeError(f'Hue nicht erreichbar: {e.reason}')
|
||||
def data(path):
|
||||
raw=req('GET',path); return raw.get('data',[]) if isinstance(raw,dict) else []
|
||||
def n(v):
|
||||
if isinstance(v,dict): return v.get('name') or v.get('value') or ''
|
||||
return v or ''
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='list_lights':
|
||||
out=[]
|
||||
for v in data('/clip/v2/resource/light'):
|
||||
out.append({'id':v.get('id',''),'name':n(v.get('metadata')),'on':(v.get('on') or {}).get('on'), 'brightness':(v.get('dimming') or {}).get('brightness'),'owner_id':(v.get('owner') or {}).get('rid','')})
|
||||
return ok({'lights':out,'count':len(out)},'Hue-Lampen geladen.')
|
||||
if a=='list_rooms':
|
||||
out=[]
|
||||
for v in data('/clip/v2/resource/room'):
|
||||
gid=''
|
||||
for s in v.get('services') or []:
|
||||
if s.get('rtype')=='grouped_light': gid=s.get('rid',''); break
|
||||
out.append({'id':v.get('id',''),'name':n(v.get('metadata')),'grouped_light_id':gid})
|
||||
return ok({'rooms':out,'count':len(out)},'Hue-Räume geladen.')
|
||||
if a=='list_scenes':
|
||||
out=[]
|
||||
for v in data('/clip/v2/resource/scene'):
|
||||
out.append({'id':v.get('id',''),'name':n(v.get('metadata')),'group_id':(v.get('group') or {}).get('rid','')})
|
||||
return ok({'scenes':out,'count':len(out)},'Hue-Szenen geladen.')
|
||||
if a in ('set_light','set_room'):
|
||||
ident=x['light_id'] if a=='set_light' else x['grouped_light_id']
|
||||
body={}
|
||||
if 'on' in x: body['on']={'on':bool(x['on'])}
|
||||
if 'brightness' in x: body['dimming']={'brightness':max(0.0,min(100.0,float(x['brightness'])))}
|
||||
if a=='set_light' and 'x' in x and 'y' in x: body['color']={'xy':{'x':float(x['x']),'y':float(x['y'])}}
|
||||
if not body: return fail('INVALID_INPUT','Mindestens on, brightness oder x/y angeben')
|
||||
typ='light' if a=='set_light' else 'grouped_light'
|
||||
req('PUT',f'/clip/v2/resource/{typ}/{ident}',body)
|
||||
return ok({'id':ident,'changed':True},'Hue-Zustand geändert.',True)
|
||||
if a=='activate_scene':
|
||||
ident=x['scene_id']; req('PUT',f'/clip/v2/resource/scene/{ident}',{'recall':{'action':'active'}})
|
||||
return ok({'id':ident,'activated':True},'Hue-Szene aktiviert.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try:
|
||||
inv=json.load(sys.stdin); res=main(inv)
|
||||
except Exception as e: res=fail('HUE_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "philips.hue",
|
||||
"name": "Philips Hue",
|
||||
"version": "1.0.0",
|
||||
"description": "Lokale Philips-Hue-Bridge-Steuerung über die CLIP API v2.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 8000,
|
||||
"env_from": [
|
||||
"HUE_BRIDGE_URL",
|
||||
"HUE_APP_KEY",
|
||||
"HUE_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "list_lights",
|
||||
"description": "Listet Hue-Lampen mit ID, Name, Ein/Aus und Helligkeit.",
|
||||
"triggers": [
|
||||
"hue lampen",
|
||||
"lichter",
|
||||
"beleuchtung"
|
||||
],
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lights": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lights",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_rooms",
|
||||
"description": "Listet Hue-Räume und die zugehörige grouped_light-ID zur Raumsteuerung.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rooms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rooms",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_scenes",
|
||||
"description": "Listet Hue-Szenen mit ID und Name.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"scenes",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "set_light",
|
||||
"description": "Schaltet eine Hue-Lampe oder setzt Helligkeit/Farbe.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"light_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"on": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"brightness": {
|
||||
"type": "number"
|
||||
},
|
||||
"x": {
|
||||
"type": "number"
|
||||
},
|
||||
"y": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"light_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"changed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"changed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "set_room",
|
||||
"description": "Schaltet einen Hue-Raum über seine grouped_light-ID oder setzt die Helligkeit.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"grouped_light_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"on": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"brightness": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"grouped_light_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"changed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"changed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "activate_scene",
|
||||
"description": "Aktiviert eine vorhandene Hue-Szene anhand ihrer ID.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scene_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"scene_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"activated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"activated"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def cfg():
|
||||
b=os.getenv('PROXMOX_BASE_URL','').strip().rstrip('/'); tid=os.getenv('PROXMOX_TOKEN_ID','').strip(); sec=os.getenv('PROXMOX_TOKEN_SECRET','').strip()
|
||||
if not b or not tid or not sec: raise RuntimeError('PROXMOX_BASE_URL, PROXMOX_TOKEN_ID und PROXMOX_TOKEN_SECRET müssen gesetzt sein')
|
||||
if not b.startswith(('http://','https://')): b='https://'+b
|
||||
return b,tid,sec,truthy(os.getenv('PROXMOX_VERIFY_TLS','false'))
|
||||
def request(method,path,form=None,query=None):
|
||||
b,tid,sec,verify=cfg(); url=b+'/api2/json'+path
|
||||
if query: url+='?'+urlencode({k:v for k,v in query.items() if v not in (None,'')})
|
||||
data=None; h={'Accept':'application/json','Authorization':f'PVEAPIToken={tid}={sec}'}
|
||||
if form is not None: data=urlencode(form).encode(); h['Content-Type']='application/x-www-form-urlencoded'
|
||||
elif method=='POST': data=b''
|
||||
ctx=ssl._create_unverified_context() if url.startswith('https://') and not verify else None
|
||||
try:
|
||||
with urlopen(Request(url,data=data,headers=h,method=method),timeout=12,context=ctx) as r:
|
||||
raw=r.read(); obj=json.loads(raw.decode()) if raw else {}; return obj.get('data') if isinstance(obj,dict) and 'data' in obj else obj
|
||||
except HTTPError as e: raise RuntimeError(f'Proxmox HTTP {e.code}: {e.read().decode(errors="replace")[:1000]}')
|
||||
except URLError as e: raise RuntimeError(f'Proxmox nicht erreichbar: {e.reason}')
|
||||
def guest_path(x):
|
||||
typ=x['guest_type'];
|
||||
if typ not in ('qemu','lxc'): raise RuntimeError('guest_type muss qemu oder lxc sein')
|
||||
return f"/nodes/{x['node']}/{typ}/{int(x['vmid'])}"
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='version': return ok(request('GET','/version'),'Proxmox API erreichbar.')
|
||||
if a=='list_nodes':
|
||||
vals=request('GET','/nodes') or []; out=[{'node':v.get('node',''),'status':v.get('status',''),'cpu':v.get('cpu'),'maxcpu':v.get('maxcpu'),'mem':v.get('mem'),'maxmem':v.get('maxmem'),'uptime':v.get('uptime')} for v in vals]
|
||||
return ok({'nodes':out,'count':len(out)},'Proxmox-Nodes geladen.')
|
||||
if a=='list_guests':
|
||||
vals=request('GET','/cluster/resources',query={'type':'vm'}) or []; out=[]
|
||||
for v in vals:
|
||||
typ=v.get('type','')
|
||||
if typ not in ('qemu','lxc'): continue
|
||||
if x.get('node') and v.get('node')!=x['node']: continue
|
||||
if x.get('status') and v.get('status')!=x['status']: continue
|
||||
out.append({'vmid':v.get('vmid'),'name':v.get('name',''),'type':typ,'node':v.get('node',''),'status':v.get('status',''),'cpu':v.get('cpu'),'mem':v.get('mem'),'maxmem':v.get('maxmem'),'uptime':v.get('uptime')})
|
||||
return ok({'guests':out,'count':len(out)},'Proxmox-Guests geladen.')
|
||||
if a=='guest_status': return ok(request('GET',guest_path(x)+'/status/current') or {},'Guest-Status geladen.')
|
||||
if a in ('start_guest','shutdown_guest','reboot_guest'):
|
||||
action={'start_guest':'start','shutdown_guest':'shutdown','reboot_guest':'reboot'}[a]; data=request('POST',guest_path(x)+'/status/'+action)
|
||||
return ok({'upid':data or '', 'action':action},f'Guest-Aktion {action} ausgelöst.',True)
|
||||
if a=='snapshot_guest':
|
||||
form={'snapname':x['name']}
|
||||
if x.get('description'): form['description']=x['description']
|
||||
if x.get('include_ram'): form['vmstate']='1'
|
||||
data=request('POST',guest_path(x)+'/snapshot',form=form); return ok({'upid':data or '', 'snapshot':x['name']},'Snapshot angelegt.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('PROXMOX_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "proxmox.ve",
|
||||
"name": "Proxmox VE",
|
||||
"version": "1.0.0",
|
||||
"description": "Proxmox-VE-Cluster- und Guest-Steuerung über die REST API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 15000,
|
||||
"env_from": [
|
||||
"PROXMOX_BASE_URL",
|
||||
"PROXMOX_TOKEN_ID",
|
||||
"PROXMOX_TOKEN_SECRET",
|
||||
"PROXMOX_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "version",
|
||||
"description": "Prüft die Proxmox-API und liefert die Version.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_nodes",
|
||||
"description": "Listet Proxmox-Nodes samt Status und Ressourcen.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodes",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_guests",
|
||||
"description": "Listet QEMU-VMs und LXC-Container clusterweit.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"guests",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "guest_status",
|
||||
"description": "Liest den aktuellen Status einer QEMU-VM oder eines LXC-Containers.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start_guest",
|
||||
"description": "Startet eine QEMU-VM oder einen LXC-Container.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shutdown_guest",
|
||||
"description": "Fährt eine QEMU-VM oder einen LXC-Container sauber herunter.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reboot_guest",
|
||||
"description": "Startet eine QEMU-VM oder einen LXC-Container neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "snapshot_guest",
|
||||
"description": "Erstellt einen Proxmox-Snapshot eines Guests.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"include_ram": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def cfg():
|
||||
b=os.getenv('UNIFI_NETWORK_URL','').strip().rstrip('/'); k=os.getenv('UNIFI_API_KEY','').strip()
|
||||
if not b or not k: raise RuntimeError('UNIFI_NETWORK_URL und UNIFI_API_KEY müssen gesetzt sein')
|
||||
if not b.startswith(('http://','https://')): b='https://'+b
|
||||
return b,k,truthy(os.getenv('UNIFI_VERIFY_TLS','false'))
|
||||
def request(method,path,body=None,query=None):
|
||||
b,k,verify=cfg(); url=b+path
|
||||
if query:
|
||||
q={a:v for a,v in query.items() if v not in (None,'')};
|
||||
if q: url+='?'+urlencode(q)
|
||||
data=None; h={'Accept':'application/json','X-API-Key':k}
|
||||
if body is not None: data=json.dumps(body).encode(); h['Content-Type']='application/json'
|
||||
ctx=None
|
||||
if url.startswith('https://') and not verify: ctx=ssl._create_unverified_context()
|
||||
try:
|
||||
with urlopen(Request(url,data=data,headers=h,method=method),timeout=8,context=ctx) as r:
|
||||
raw=r.read(); return json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as e: raise RuntimeError(f'UniFi Network HTTP {e.code}: {e.read().decode(errors="replace")[:1000]}')
|
||||
except URLError as e: raise RuntimeError(f'UniFi Network nicht erreichbar: {e.reason}')
|
||||
def items(raw):
|
||||
if isinstance(raw,list): return raw
|
||||
if isinstance(raw,dict):
|
||||
d=raw.get('data'); return d if isinstance(d,list) else []
|
||||
return []
|
||||
def normalize_site(v): return {'id':v.get('id') or v.get('siteId') or '', 'name':v.get('name') or (v.get('meta') or {}).get('name') or '', 'description':v.get('description') or (v.get('meta') or {}).get('desc') or ''}
|
||||
def normalize_device(v): return {'id':v.get('id',''),'name':v.get('name',''),'model':v.get('model',''),'state':v.get('state',''),'ip':v.get('ipAddress') or v.get('ip',''),'mac':v.get('macAddress') or v.get('mac',''),'firmware':v.get('firmwareVersion',''),'updatable':v.get('firmwareUpdatable')}
|
||||
def normalize_client(v):
|
||||
return {'id':v.get('id',''),'name':v.get('name') or v.get('hostname') or v.get('displayName') or '', 'type':v.get('type',''),'ip':v.get('ipAddress') or v.get('ip',''),'mac':v.get('macAddress') or v.get('mac',''),'connected_at':v.get('connectedAt'),'access':v.get('access')}
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='info': return ok(request('GET','/v1/info'),'UniFi Network API erreichbar.')
|
||||
if a=='list_sites':
|
||||
vals=[normalize_site(v) for v in items(request('GET','/v1/sites'))]; return ok({'sites':vals,'count':len(vals)},'UniFi-Sites geladen.')
|
||||
if a in ('list_devices','list_clients'):
|
||||
limit=max(1,min(200,int(x.get('limit',50)))); q={'limit':limit,'filter':x.get('filter','')}
|
||||
path=f"/v1/sites/{x['site_id']}/"+('devices' if a=='list_devices' else 'clients')
|
||||
raw=items(request('GET',path,query=q)); vals=[(normalize_device(v) if a=='list_devices' else normalize_client(v)) for v in raw]
|
||||
key='devices' if a=='list_devices' else 'clients'; return ok({key:vals,'count':len(vals)},'UniFi-Daten geladen.')
|
||||
if a=='restart_device':
|
||||
request('POST',f"/v1/sites/{x['site_id']}/devices/{x['device_id']}/actions",{'action':'RESTART'}); return ok({'accepted':True},'Geräte-Neustart ausgelöst.',True)
|
||||
if a=='power_cycle_port':
|
||||
request('POST',f"/v1/sites/{x['site_id']}/devices/{x['device_id']}/interfaces/ports/{int(x['port_idx'])}/actions",{'action':'POWER_CYCLE'}); return ok({'accepted':True},'PoE Power-Cycle ausgelöst.',True)
|
||||
if a in ('authorize_guest','unauthorize_guest'):
|
||||
body={'action':'AUTHORIZE_GUEST_ACCESS' if a=='authorize_guest' else 'UNAUTHORIZE_GUEST_ACCESS'}
|
||||
if a=='authorize_guest' and x.get('time_limit_minutes'): body['timeLimitMinutes']=int(x['time_limit_minutes'])
|
||||
raw=request('POST',f"/v1/sites/{x['site_id']}/clients/{x['client_id']}/actions",body)
|
||||
return ok(raw if isinstance(raw,dict) else {'result':raw},'Gastzugang geändert.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('UNIFI_NETWORK_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "unifi.network",
|
||||
"name": "UniFi Network",
|
||||
"version": "1.0.0",
|
||||
"description": "Lokale UniFi-Network-Integration über die offizielle Integration API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 10000,
|
||||
"env_from": [
|
||||
"UNIFI_NETWORK_URL",
|
||||
"UNIFI_API_KEY",
|
||||
"UNIFI_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "info",
|
||||
"description": "Prüft die UniFi-Network-API und liefert Versionsinformationen.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_sites",
|
||||
"description": "Listet lokale UniFi-Network-Sites.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sites": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sites",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_devices",
|
||||
"description": "Listet adoptierte UniFi-Geräte einer Site.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"devices",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_clients",
|
||||
"description": "Listet verbundene Clients einer UniFi-Site; kann zur Anwesenheitserkennung genutzt werden.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clients": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clients",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "restart_device",
|
||||
"description": "Startet ein adoptiertes UniFi-Gerät neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"device_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "power_cycle_port",
|
||||
"description": "Führt einen PoE Power-Cycle auf einem Switch-Port aus.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"port_idx": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"device_id",
|
||||
"port_idx"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorize_guest",
|
||||
"description": "Autorisiert einen UniFi-Gastclient optional zeitlich begrenzt.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_limit_minutes": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"client_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unauthorize_guest",
|
||||
"description": "Entzieht einem UniFi-Gastclient den Netzwerkzugang.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"client_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def cfg():
|
||||
b=os.getenv('UNIFI_PROTECT_URL','').strip().rstrip('/'); k=os.getenv('UNIFI_API_KEY','').strip()
|
||||
if not b or not k: raise RuntimeError('UNIFI_PROTECT_URL und UNIFI_API_KEY müssen gesetzt sein')
|
||||
if not b.startswith(('http://','https://')): b='https://'+b
|
||||
return b,k,truthy(os.getenv('UNIFI_VERIFY_TLS','false'))
|
||||
def request(method,path,body=None):
|
||||
b,k,verify=cfg(); data=None; h={'Accept':'application/json','X-API-Key':k}
|
||||
if body is not None: data=json.dumps(body).encode(); h['Content-Type']='application/json'
|
||||
ctx=ssl._create_unverified_context() if b.startswith('https://') and not verify else None
|
||||
try:
|
||||
with urlopen(Request(b+path,data=data,headers=h,method=method),timeout=8,context=ctx) as r:
|
||||
raw=r.read(); return json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as e: raise RuntimeError(f'UniFi Protect HTTP {e.code}: {e.read().decode(errors="replace")[:1000]}')
|
||||
except URLError as e: raise RuntimeError(f'UniFi Protect nicht erreichbar: {e.reason}')
|
||||
def name(v):
|
||||
n=v.get('name','') if isinstance(v,dict) else ''
|
||||
if isinstance(n,dict): return n.get('name') or n.get('value') or str(n)
|
||||
return n or ''
|
||||
def list_norm(path,kind):
|
||||
raw=request('GET',path); vals=raw if isinstance(raw,list) else raw.get('data',[]) if isinstance(raw,dict) else []
|
||||
out=[]
|
||||
for v in vals:
|
||||
base={'id':v.get('id',''),'name':name(v),'model':v.get('modelKey',''),'state':v.get('state',''),'mac':v.get('mac','')}
|
||||
if kind=='camera':
|
||||
f=v.get('featureFlags') or {}; base.update({'video_mode':v.get('videoMode',''),'hdr':v.get('hdrType',''),'has_mic':f.get('hasMic'),'has_speaker':f.get('hasSpeaker'),'smart_detect_types':f.get('smartDetectTypes') or []})
|
||||
out.append(base)
|
||||
return out
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='info': return ok(request('GET','/v1/meta/info'),'Protect API erreichbar.')
|
||||
if a in ('list_cameras','list_sensors','list_lights'):
|
||||
typ={'list_cameras':('cameras','camera'),'list_sensors':('sensors','sensor'),'list_lights':('lights','light')}[a]
|
||||
vals=list_norm('/v1/'+typ[0],typ[1]); return ok({typ[0]:vals,'count':len(vals)},'Protect-Geräte geladen.')
|
||||
if a=='get_camera':
|
||||
raw=request('GET',f"/v1/cameras/{x['camera_id']}"); return ok(raw if isinstance(raw,dict) else {'camera':raw},'Kamera geladen.')
|
||||
if a=='ptz_goto': request('POST',f"/v1/cameras/{x['camera_id']}/ptz/goto/{int(x['slot'])}"); return ok({'accepted':True},'PTZ-Preset angefahren.',True)
|
||||
if a=='ptz_patrol_start': request('POST',f"/v1/cameras/{x['camera_id']}/ptz/patrol/start/{int(x['slot'])}"); return ok({'accepted':True},'PTZ-Patrouille gestartet.',True)
|
||||
if a=='ptz_patrol_stop': request('POST',f"/v1/cameras/{x['camera_id']}/ptz/patrol/stop"); return ok({'accepted':True},'PTZ-Patrouille gestoppt.',True)
|
||||
if a=='trigger_alarm_webhook': request('POST',f"/v1/alarm-manager/webhook/{x['trigger_id']}"); return ok({'accepted':True},'Protect-Alarm-Webhook ausgelöst.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('UNIFI_PROTECT_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "unifi.protect",
|
||||
"name": "UniFi Protect",
|
||||
"version": "1.0.0",
|
||||
"description": "Lokale UniFi-Protect-Integration über die offizielle Integration API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 10000,
|
||||
"env_from": [
|
||||
"UNIFI_PROTECT_URL",
|
||||
"UNIFI_API_KEY",
|
||||
"UNIFI_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "info",
|
||||
"description": "Prüft die Protect-API und liefert Versionsinformationen.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_cameras",
|
||||
"description": "Listet Protect-Kameras mit Status und Fähigkeiten.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cameras",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_camera",
|
||||
"description": "Liest Details einer Protect-Kamera.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_sensors",
|
||||
"description": "Listet Protect-Sensoren.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sensors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sensors",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_lights",
|
||||
"description": "Listet Protect-Lights/Floodlights.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lights": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lights",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptz_goto",
|
||||
"description": "Fährt eine PTZ-Kamera auf ein vorhandenes Preset.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id",
|
||||
"slot"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptz_patrol_start",
|
||||
"description": "Startet eine konfigurierte PTZ-Patrouille.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id",
|
||||
"slot"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptz_patrol_stop",
|
||||
"description": "Stoppt die aktive PTZ-Patrouille.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trigger_alarm_webhook",
|
||||
"description": "Triggert einen in Protect konfigurierten Alarm-Manager-Webhook.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trigger_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"trigger_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# JARVIS Runtime Skill Workers
|
||||
|
||||
Alle Runtime-Container verwenden denselben `cmd/skillworker` Host und unterscheiden sich nur durch die vorinstallierte Toolchain.
|
||||
|
||||
| Runtime | Dockerfile | typische Entry-Points |
|
||||
|---|---|---|
|
||||
| Python | `Dockerfile.python` | `python3 main.py` |
|
||||
| Node | `Dockerfile.node` | `node index.js` |
|
||||
| Go | `Dockerfile.golang` | `go run .` oder `./run` |
|
||||
| Rust | `Dockerfile.rust` | `cargo run --release` oder `./run` |
|
||||
| C | `Dockerfile.c` | `gcc`, `make`, `./run` |
|
||||
| C++ | `Dockerfile.cpp` | `g++`, `cmake`, `./run` |
|
||||
| C# | `Dockerfile.csharp` | `dotnet run` oder `./run` |
|
||||
|
||||
Der Host kopiert jeden Skill aus dem read-only `/skills`-Mount in `/work`, bevor er ausgeführt wird. Damit können Compiler/Buildsysteme Artefakte schreiben, ohne die Skill-Dateien auf dem Host zu verändern.
|
||||
|
||||
Pflicht-ENV im Worker:
|
||||
|
||||
```env
|
||||
JARVIS_MASTER_URL=http://jarvis:8080
|
||||
JARVIS_ENROLLMENT_TOKEN=...
|
||||
JARVIS_WORKER_NAME=python-main
|
||||
JARVIS_WORKER_RUNTIME=python
|
||||
JARVIS_WORKER_PUBLIC_URL=http://skill-python:8090
|
||||
```
|
||||
|
||||
Der Worker stellt ausschließlich die Web-Schnittstellen `/health`, `/v1/skills`, `/v1/invoke` und `/v1/reload` bereit. `/v1/invoke` und `/v1/reload` akzeptieren nur das Session Access Token, das der Master beim Enrollment ausgegeben hat.
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gcc make libc6-dev && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl g++ make cmake libc6-dev && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
|
||||
FROM python:3.13-slim-bookworm
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl docker.io docker-compose \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work /opt/stacks
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM golang:1.23-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM python:3.13-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-bookworm AS host-build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd/skillworker ./cmd/skillworker
|
||||
RUN CGO_ENABLED=0 go build -o /out/jarvis-skill-worker ./cmd/skillworker
|
||||
FROM rust:1-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=host-build /out/jarvis-skill-worker /usr/local/bin/jarvis-skill-worker
|
||||
RUN mkdir -p /skills /work
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/local/bin/jarvis-skill-worker"]
|
||||
Reference in New Issue
Block a user