Compare commits

..

7 Commits

Author SHA1 Message Date
duffyduck 210ce62ffe feat(brain): Skill-Bypass-Detection + Bypass-Lehre als pinned Memory
Variante 3+ (Lerneffekt-Variante): Variante C scaffolded zwar Skills auto,
aber ARIA lernt nicht — sie wird beim naechsten Mal trotzdem zu Bash
greifen. Stefans Punkt: Lernen geht nur ueber Brain-Memory.

Mechanik:
1. api_heuristic.detect_recent_bypass(skills, since_sec=600):
   schaut letzte 10 Min im agent_stream.jsonl, findet Bash-curl gegen
   Hosts fuer die bereits ein matching Skill existiert. Returnt
   {host, skill_name, count, last_ts}.

2. api_heuristic.build_bypass_section(events):
   Drastischer Markdown-Block "## 🚨 SKILL-BYPASS ERKANNT" mit konkretem
   run_<skill>-Hint pro betroffenem Host. Landet direkt im System-Prompt
   noch VOR dem normalen API-Heuristik-Block.

3. agent.py._upsert_bypass_lesson(ev):
   Schreibt eine pinned type=rule Memory mit source=auto-feedback und
   migration_key=auto/skill-bypass/<skill_name>. Idempotent: bei
   Wiederholung wird die alte Memory ueberschrieben (Counter aktualisiert),
   keine Karteileichen. Content nennt konkret den run-Tool-Namen und
   Performance-Vergleich (3s Tool-Call vs 13-20s Bash-Wrapper).

Diese Memory ist permanent pinned → kommt bei jedem Chat-Turn,
cross-session, cross-restart als Hot-Memory durch. Damit lernt ARIA
es im wortlichen Sinne, nicht nur Reibung in der aktuellen Konversation.

Idempotenz wichtig: bei jedem Bypass-Detection-Lauf wird die Memory
upgedatet (nicht dupliziert). Stefan kann sie via Diagnostic-Gehirn-Tab
loeschen falls sie nervt.

Stefan-Frage beantwortet: 'sie wuerde es aber nur lernen wenn sie es
auch im gehirn speichert oder?' — exakt. Schimpfen im Prompt ist
Reibung dieser Session, pinned Memory ist permanenter Lerneffekt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:37:40 +02:00
duffyduck 298b2202a1 feat(brain): Auto-Scaffold — Brain legt Skills selbst an wenn ARIA driftet
Variante C: ARIA hat selbst mit Heuristik-Block + 11 seed_rules den
expliziten skill_scaffold-Befehl ignoriert (32x Spotify-Bash-Calls in
24h, kein einziger scaffold-Aufruf). Verhaltens-Traegheit ist staerker
als jeder Prompt-Hint.

Loesung: Brain wartet nicht mehr. Bei jedem chat()-Aufruf wird die
Heuristik berechnet. Findet sie einen Host mit bekannter Suggestion
(Spotify, GitHub, OpenAI, OpenWeather, Telegram, Microsoft, Discord,
Notion, Reddit) der noch keinen Skill hat → Brain ruft selbst
`scaffold_skill(name, template, params)` mit author='aria-auto'.

Der frische Skill ist sofort im Prompt sichtbar (Skill-Liste wird nach
Scaffold refreshed, Heuristik-Cache invalidiert, Hints neu gerechnet).
Side-Channel-Event 'skill_created' mit Flag 'auto_scaffolded' geht an
die UI — Stefan sieht im Chat dass Brain einen Skill angelegt hat.

ARIA findet beim Tool-Use-Loop einen passenden `run_<name>`-Skill vor
und nutzt ihn idealerweise statt wieder Bash. Macht sie's nicht und
curlt trotzdem weiter, ist der Counter beim naechsten Mal wieder hoch
und Brain scaffolded weiter — aber dann ist der Skill ja schon da, also
nur ein Pfad.

Toggle: BRAIN_AUTO_SCAFFOLD=false zum Abschalten.

scaffold-reflex Regel angepasst: ARIA wird informiert dass Brain
manchmal selbst scaffolded (author=aria-auto) und sie den Skill via
run_<name> nutzen soll statt zu curlen. Bei Hinweisen OHNE Suggestion
(unbekannter Host) soll sie selbst skill_scaffold rufen.

Stefan-Zitat aus der Diskussion ("ARIA lernt es so nicht"): stimmt
inhaltlich, aber pragmatisch wichtiger ist dass Stefans Wartezeit von
20s auf 3s sinkt. Lernen kann sie spaeter — der Skill ist da, sie sieht
den Pfad jedes Mal beim Tool-Listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:28:15 +02:00
duffyduck 845a8b0020 feat(brain): API-Heuristik — Cross-Session-Counter fuer Skill-Drift
Variante B: scaffold-reflex Regel allein reicht nicht weil jede Chat-
Anfrage eine eigene claude-CLI-Session ist. ARIA sieht in der aktuellen
Session nicht dass sie gestern auch schon 10x dieselbe API gecurled hat.
Beobachtung: 5+ Spotify-Bash-Calls hintereinander, kein Skill angelegt.

Loesung: Brain trackt server-side aus dem persistierten agent_stream.jsonl.
Bei jedem chat() wird der Log gescanned (cache 5min), Bash-curl-Calls
nach Hostname aggregiert. Hosts mit >=3 Calls in 24h ohne passenden
Skill landen als '## API-Heuristik'-Block im System-Prompt mit konkretem
skill_scaffold-Vorschlag.

Neue Module:
- aria-brain/api_heuristic.py:
  - compute_hints(existing_skills, force): Aggregiert + filtert
  - build_section(hints): formatiert als kompakten Markdown-Block
  - Smart suggestions mapping (api.spotify.com → oauth-api template etc.)
  - Ignoriert interne Hosts (aria-brain, localhost, docker-bridge)
  - 5-min Cache damit nicht jeder Turn die JSONL parst

- aria-brain/prompts.py: build_system_prompt nimmt api_heuristic_section
  als optionalen Block direkt nach Skills-Section.

- aria-brain/agent.py: vor build_system_prompt Heuristik berechnen mit
  aktueller Skill-Liste, Block durchreichen.

- 11. seed_rule scaffold-reflex umgeschrieben: kein 'in einer Session'
  mehr (das ergab keinen Sinn — jeder Turn neue Session). Stattdessen:
  '## API-Heuristik'-Block ist Dein Cross-Session-Gedaechtnis. Wenn da
  was steht: scaffolden BEVOR Du Bash machst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:19:06 +02:00
duffyduck 0540c49c66 feat(brain): skill_scaffold — Templates statt Skill aus dem Nichts
Variante C: niedrigere Huerde zum Skill-Bau. Statt einen kompletten
Python-Skill via skill_create zu generieren (~100 Zeilen Code, teuer in
Tokens und fehleranfaellig), waehlt ARIA ein Template + minimale params,
Brain expandiert das Skelett in ~1s zu fertigem Skill.

Beobachtung: ARIA driftet bei Spotify, PDF etc. zu Bash-curl statt
einen Skill zu bauen, weil die Skill-Bau-Huerde zu hoch ist (Code,
README, args, pip_packages, config_schema). Mit Templates ist die
Huerde minimal.

Neue Module:
- aria-brain/skill_templates.py: drei mitgelieferte Templates
  - oauth-api: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, ...).
    Token via BRAIN_INTERNAL_URL/oauth/<s>/token mit Auto-Refresh.
    Args: method/path/body/base_url
  - apikey-api: API mit statischem Key (OpenWeather, OpenAI, Twilio).
    Key liegt im config_schema -> CFG_<NAME> ENV, KEIN hardcoden.
    Konfigurierbar: auth_header (Authorization|X-Api-Key), auth_prefix.
  - file-process: Skelett fuer File-In/File-Out (PDF, Bild, JSON).
    process()-Funktion ist Stub, ARIA fuellt sie via skill_update.
  Templates nutzen Token-Replacement statt f-Strings (sonst Konflikt
  mit dem skill-internen Python-Code).

- aria-brain/skills.py: scaffold_skill(name, template, params, author)
  wrappt create_skill mit den expandierten Feldern.

- aria-brain/agent.py: neues Brain-Tool skill_scaffold mit detaillierter
  Description (Template-Liste + params-Schema). Dispatcher-Handler
  schickt skill_created Side-Channel-Event analog zu skill_create.

- aria-brain/main.py: POST /skills/scaffold + GET /skills/templates
  (letzteres listet alle Templates fuer UI/Diagnostic).

- 11. seed_rule scaffold-reflex: bei 2x derselben API per Bash-curl
  SOFORT skill_scaffold rufen. Belohnung explizit benannt
  ("welches lied" von 20s auf 3s).

README mit Skills-Scaffold-Tabelle ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:02:45 +02:00
duffyduck add303970b feat(brain): 10. seed_rule — runtime-topology (wo ARIA tatsaechlich laeuft)
Beobachtung beim "ueberspringe Lied"-Test (29.05.2026): 47 Sekunden mit
12 fehlgeschlagenen Bash-Versuchen weil ARIA glaubte sie sei im
aria-brain Container. Sie hat probiert:

  - python3/python/jq (Alpine — alle nicht installiert)
  - cd /data/skills/spotify-control (existiert nur im Brain)
  - curl localhost:8080/oauth/... (localhost = aria-proxy, nicht Brain)
  - 8s Timeout auf localhost (kein TCP Reset)

Erst nach 9 Versuchen brain:8080 erraten und dann den Token-Wert
hardcoded in den naechsten curl gepackt.

Die neue Regel beschreibt die echte Topologie explizit:

- Du bist die claude-CLI als Subprocess IM aria-proxy (node:22-alpine)
- KEIN python3/python/jq verfuegbar
- /data/skills/ existiert NUR im aria-brain
- localhost in Deinem Bash heisst aria-proxy; Brain ist aria-brain:8080
- BRAIN_INTERNAL_URL ist NUR in laufenden Skills gesetzt
- Brain-Resources via Brain-Tools (oauth_get_token, memory_search,
  run_<skill_name>), NICHT via Bash
- SSH zur VM-Host: `ssh aria@host` (ed25519-Key liegt im Proxy)
- Externe APIs direkt per curl mit Token aus oauth_get_token

Plus das Anti-Pattern dokumentiert ("47 Sekunden Stefan-Lebenszeit") —
ARIA soll bei jedem Bash-Reflex gegen "lokale" Brain-Resources erst
denken oder die Brain-Tool-Ebene nehmen.

README in Skills-Architektur-Sektion entsprechend ergaenzt (10 Regeln).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:30:50 +02:00
duffyduck fb71048dfd feat(diagnostic): Archiv-Modal mit Pagination fuer ARIA-Stream
- /api/agent-stream akzeptiert jetzt ?page=N&perPage=M zusaetzlich zu
  ?lines=N. page=1 = neueste Eintraege, hoehere Pages = aelter.
  Antwort enthaelt page/perPage/pagesTotal/total fuer Client-Nav.
- Live-View hat neuen 📜 Archiv-Button neben Leeren/Auto-Scroll.
- Modal mit PerPage-Selector (50/100/500/1000), «‹›» Navigation und
  reload-Button. Pagination-Buttons werden auf den Grenzen disabled.
- renderArchiveLine spiegelt das Live-View-Rendering (Tool-Calls in
  cyan, Results in gruen, Thinking kursiv) im Modal-Container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:11:46 +02:00
duffyduck aaaf118cb7 feat: 2 neue seed_rules + Diagnostic-Persistenz fuer agent_stream + chat-backup API
Befund aus chat_backup.jsonl-Analyse heute: ARIA ist 3x auf oauth_authorize
gefallen statt oauth_get_token (Stefan musste manuell einloggen), und beim
PDF-Skill ist sie nach Stefans "Variante bitte" zu Ad-hoc-Bash-Befehlen
auf der VM gedriftet ("ich lass den Code direkt laufen") — Skill wurde
unbrauchbar. Beides genau die Antipattern die wir mit den seed_rules
abdecken wollten, nur waren die zu schwach formuliert.

seed_rules (jetzt 9 statt 7):
- oauth-reauth-reflex: bei 401 ZUERST oauth_get_token, NUR bei dessen
  Fehler oauth_authorize. Stefan zu Re-Login schicken ist das aergerlichste
  Antipattern (er sitzt im Auto, muss Handy rauskramen).
- no-skill-drift: kaputter Skill -> skill_logs + skill_update, NIEMALS
  zu Ad-hoc-Bash wechseln (Skill wird Karteileiche). Plus: "ich baue
  dir einen Skill" SAGEN ohne skill_create zu rufen ist verboten —
  Stefan checkt die Liste und verliert das Vertrauen.

agent_stream-Persistenz:
- diagnostic/server.js schreibt jeden agent_stream-Event parallel zum
  Broadcast in /shared/logs/agent_stream.jsonl (soft-cap 50 MB mit
  half-truncate beim Ueberlauf).
- Live-View laedt beim Page-Load + Sub-Tab-Switch die letzten 200
  Eintraege via /api/agent-stream. Browser-Reload / Standby verliert
  damit den Verlauf nicht mehr.

Debug-API ohne SSH:
- GET /api/chat-backup?lines=N (Default 200, Max 5000) — geparstes JSON
  der letzten N Zeilen aus chat_backup.jsonl
- GET /api/agent-stream?lines=N — gleiches fuer den persistierten Stream

README:
- Neuer Abschnitt "## Skills — Architektur" mit Skill-Layout,
  Drei-Stufen-Daten-Modell (OAuth / config_schema / Brain-Daten),
  Versionierung, Anti-Friedhof, seed_rules (alle 9 aufgelistet).
- Diagnostic-Sektion um agent_stream-Persistenz + neue Debug-Endpoints
  ergaenzt.
- Roadmap: Phase B "Skill-Architektur P0-P4" abgehakt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:06:56 +02:00
10 changed files with 1527 additions and 2 deletions
+115 -1
View File
@@ -324,6 +324,111 @@ aria-brain → Antwort → Bridge → RVS → App
---
## Skills — Architektur
Skills sind ARIAs wiederverwendbare Faehigkeiten. Jeder Skill ist ein
Python-Programm in seinem eigenen `local-venv`. ARIA legt sie selbst via
`skill_create` an, fixt Bugs mit `skill_update`, rollt zur Not zurueck
mit `skill_rollback`.
### Skill-Layout
```
/data/skills/<name>/
skill.json # Manifest (Metadata + config_schema + version_history)
run.py # Entry-Point (Python via venv-python)
requirements.txt # pip-Pakete fuer die venv
README.md # Beschreibung
venv/ # automatisch erzeugt
logs/<ts>.json # Run-Logs (append-only)
versions/v_<ts>/ # archivierte Vorgaengerstaende (vor jedem update_skill)
```
### Drei-Stufen-Daten-Modell
Skills muessen **niemals** Credentials hardcoden. Drei saubere Wege:
1. **OAuth2-Tokens** (Spotify, Google, GitHub, Reddit, …): Brain haelt
Client-Credentials und macht den Auth-Flow. Skill ruft
`GET {BRAIN_INTERNAL_URL}/oauth/<service>/token` und bekommt einen
frischen access_token (Auto-Refresh < 60 s Restzeit).
2. **Statische Werte** (API-Keys, User-IDs, Default-Geraete): Skill
deklariert ein `config_schema` in `skill.json`, Stefan setzt die
Werte in Diagnostic / App, Skill bekommt sie zur Laufzeit als
`CFG_<UPPER_NAME>` ENV.
3. **Brain-Daten** (Memories, Skills-Liste, Standort etc.): jeder Skill
kann gegen `BRAIN_INTERNAL_URL` Endpoints wie `/memory/search`,
`/memory/pinned`, `/skills/list` rufen — z.B. ein Wetter-Skill kann
Stefans Standort aus Memories holen statt ihn als Arg zu erwarten.
### Versionierung mit Rollback
`update_skill` archiviert den aktuellen Stand vor jeder strukturellen
Aenderung (entry_code, readme, pip_packages, config_schema, args) nach
`versions/v_<ts>/`. ARIA-Tools `skill_list_versions` + `skill_rollback`
(+ HTTP `/skills/{name}/versions` + `/rollback`) erlauben Wiederherstellung.
Vor jedem Rollback wird der aktuelle Stand als „safety-snapshot" gesichert
— der Rollback selbst ist also nicht destruktiv.
UI sowohl in Diagnostic (Skill-Detail → 📦 Versionen) als auch in der App
(SkillBrowser → Detail-Modal).
### Anti-Skill-Friedhof
ARIA hat frueher gerne 9 Spotify-Skills mit Suffixen `-v2`, `-aria`,
`-ctl`, `-fixed` gebaut statt einen sauberen zu pflegen.
`skills.create_skill()` rejected jetzt hart:
- Versions-Suffixe (`-v\d+`, `_v\d+`, `-new`, `-fixed`, `-old`,
`-alt`, `-copy`, `-final`, `-clean`)
- Prefix-Kollisionen (`spotify` existiert → `spotify-aria` rejected)
Plus die Skill-Regeln (siehe naechster Abschnitt) erinnern ARIA bei jedem
Chat-Turn an die richtigen Patterns.
### Skill-Regeln (seed_rules)
`aria-brain/seed_rules.py` enthaelt 11 `type=rule, pinned=true,
source=seed`-Memories, die bei jedem Brain-Start idempotent in die
Vector-DB geschrieben werden (`migration_key`-basiert). Sie tauchen in
jedem Chat-Turn im Hot-Memory-Block auf:
- **list-before-create** — IMMER `skill_list` vor `skill_create`
- **no-version-suffix** — keine `-v2`/`_v3`-Namen, Versionsverwaltung ist intern
- **update-not-recreate** — defekten Skill mit `skill_update` fixen, nicht neu bauen
- **no-hardcoded-credentials** — OAuth-Tokens via `oauth_get_token`, keine client_secrets im Code
- **config-schema-for-settings** — statische Werte via `config_schema`, nicht hardcoded
- **brain-internal-url** — `BRAIN_INTERNAL_URL` Endpoints inkl. `/oauth/<s>/token`, `/memory/search`, `/memory/pinned`, `/skills/list`
- **oauth-reauth-reflex** — bei 401: ZUERST `oauth_get_token` (Auto-Refresh), nur bei dessen Fehler `oauth_authorize`
- **no-skill-drift** — kein Drift vom Skill zu Ad-hoc-Bash-Befehlen. Skill kaputt? `skill_logs` + `skill_update`. Niemals nur SAGEN „ich baue dir einen Skill", wenn `skill_create` nicht wirklich gefeuert wird
- **runtime-topology** (architektur) — ARIA laeuft als `claude`-CLI-Subprocess IM aria-proxy Container (alpine — kein python3/jq), NICHT im aria-brain. `/data/skills/` und `BRAIN_INTERNAL_URL` existieren dort nicht. Brain-Resources via Brain-Tools (`oauth_get_token`, `memory_search`, `run_<skill>` …), nicht via Bash. SSH zur VM-Host via `ssh aria@host` (Key liegt im Proxy)
- **scaffold-reflex** — Brain trackt cross-session welche externen Hosts via Bash-curl wiederholt (≥3× in 24h) ohne passenden Skill aufgerufen wurden. Ergebnis landet als `## API-Heuristik`-Block im System-Prompt. **Auto-Scaffold**: bei bekannten Hosts (Spotify, GitHub, OpenAI etc.) legt Brain den Skill automatisch an — ARIA findet ihn beim nächsten Turn vor (author=`aria-auto`) und nutzt `run_<name>` statt curlen. Toggle via ENV `BRAIN_AUTO_SCAFFOLD=false`. **Bypass-Lehre**: wenn ARIA trotz vorhandenem Skill weiter curlt (Skill-Bypass), erkennt Brain das im agent_stream und (1) injiziert einen drastischen `🚨 SKILL-BYPASS`-Hint im aktuellen System-Prompt und (2) speichert ein pinned `type=rule, source=auto-feedback` Memory mit Skill+Host (idempotent via migration_key `auto/skill-bypass/<skill>`) — damit lernt sie es cross-session, nicht nur in der aktuellen Konversation. Data-Source: `agent_stream.jsonl`, Cache 5 min
- **external-api-auth-strategy** — OAuth2 → `oauth_get_token`, sonst `config_schema`, NIEMALS hardcoden
### Skill-Scaffold (Templates)
Statt jedes Mal einen kompletten Skill aus dem Nichts zu generieren,
ruft ARIA `skill_scaffold(name, template, params)` — Brain expandiert
ein passendes Skelett. Massiv niedrigere Hürde gegen Skill-Drift.
Drei mitgelieferte Templates (`aria-brain/skill_templates.py`):
| Template | Wofür | params |
|---|---|---|
| `oauth-api` | Spotify, GitHub, Reddit, Google, Discord — Token aus Brain mit Auto-Refresh | `{service: "spotify", base_url?}` |
| `apikey-api` | OpenWeather, OpenAI, Twilio — statischer Key in `config_schema``CFG_<NAME>` ENV | `{api_name, key_env, auth_header?, auth_prefix?, base_url}` |
| `file-process` | PDF/Bild/JSON-Wandler — Input aus `/shared/uploads/`, Output zurueck. `process()`-Stub, danach `skill_update` mit echtem Code | `{output_ext}` |
HTTP: `POST /skills/scaffold` + `GET /skills/templates` (Liste mit Param-Doku).
Nach Scaffold optional `skill_update` falls Custom-Logik gebraucht wird.
Im Gegensatz zu `aria-data/brain-import/` (User-Saatgut, gitignored,
manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
Brain neu starten.
---
## Diagnostic — Selbstcheck-UI und Einstellungen
Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
@@ -352,7 +457,10 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
- **Debug-API ohne SSH** (Diagnostic-Server, Port 3001):
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat vier Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, `oauth_revoke`
---
@@ -929,6 +1037,12 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
- [x] **Skill-Architektur P0-P4**:
- `seed_rules` (9 pinned rule-Memories) werden bei jedem Brain-Boot idempotent in die DB geschrieben (`source=seed`, `migration_key`-basiert). Decken Skill-Friedhof, OAuth-Auth-Strategie, no-skill-drift, BRAIN_INTERNAL_URL ab
- Anti-Friedhof-Check in `create_skill`: rejected Versions-Suffixe + Prefix-Kollisionen hart
- Neuer Brain-HTTP-Endpoint `/oauth/<service>/token` + `BRAIN_INTERNAL_URL` ENV-Var fuer Skills — Skill ruft Brain fuer frischen Token statt client_secret hardzucoden
- `config_schema` in skill.json + zentrales `/shared/config/skill_configs.json` + `CFG_<NAME>` ENV beim Run + `skill_set_config` Brain-Tool + UI in Diagnostic & App (TextInput / Switch / password-Felder mit `***SET***`-Masking)
- Versionierung: jeder `skill_update` archiviert vorherigen Stand nach `versions/v_<ts>/` (ohne venv/logs). `skill_list_versions` + `skill_rollback` Brain-Tools (mit Safety-Snapshot + auto venv-Rebuild). UI mit Rollback-Button in Diagnostic & App
- [x] **Bridge-Hang-Schutz + Voice-Speed persistent**: 3-Schichten-Watchdog (TCP-Keepalive + Asyncio-Watchdog + File-Based Liveness mit Self-Kill), TLS-Fallback klebt nicht mehr beim Reconnect. `xttsSpeed` jetzt im voice_config.json persistiert — greift auch bei Diagnostic-Chats und nach Bridge-Restart
- [x] **Bubble-Aktionen in der App**: Long-Press oder ⎘-Icon auf einer Chat-Bubble → Aktions-Menu mit "📋 Ganzen Text teilen" plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option (System-Share-Sheet → Zwischenablage / Apps / Browser)
+212 -1
View File
@@ -186,6 +186,47 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "skill_scaffold",
"description": (
"ERSTE WAHL fuer Skill-Bau wenn das Muster zu einem Template passt — "
"Brain expandiert das Skelett, Du sparst Dir das vollstaendige "
"Python-Programm zu generieren. Wenn Stefan eine externe API "
"mehrmals nutzt: SOFORT `skill_scaffold` statt jedes Mal "
"ad-hoc Bash-curl.\n\n"
"Verfuegbare Templates:\n"
" - **oauth-api**: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, …). "
"Token kommt vom Brain mit Auto-Refresh. params: "
"`{service:'spotify', base_url?:'https://...'}`\n"
" - **apikey-api**: API mit statischem Key (OpenWeather, OpenAI, Twilio). "
"Key liegt im skill.json config_schema → CFG_<NAME> ENV. params: "
"`{api_name:'OpenWeather', key_env:'OWM_API_KEY', auth_header?:'Authorization', auth_prefix?:'Bearer ', base_url:'https://...'}`\n"
" - **file-process**: Skelett fuer Datei-In/Datei-Out (PDF, Bild, JSON umformen). "
"process()-Funktion ist Stub — danach `skill_update` mit echtem Code. params: "
"`{output_ext:'txt'}`\n\n"
"Nach Scaffold kannst Du das Skelett via `skill_update` weiter "
"anpassen falls noetig (mehr pip_packages, andere args, …). "
"Aber meistens reicht das Template direkt.\n\n"
"Wenn kein Template passt: erst pruefen ob Du wirklich ein "
"kustomes brauchst, sonst lieber Template + Update."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string",
"description": "Skill-Name (kebab-case, ohne Versionssuffix)"},
"template": {"type": "string",
"enum": ["oauth-api", "apikey-api", "file-process"],
"description": "Eines der drei Templates"},
"params": {"type": "object",
"description": "Template-spezifische Parameter (siehe description)"},
},
"required": ["name", "template"],
},
},
},
{
"type": "function",
"function": {
@@ -788,6 +829,65 @@ class Agent:
self._pending_events = []
return events
def _upsert_bypass_lesson(self, ev: dict) -> None:
"""Speichert die Lehre aus einem Skill-Bypass als pinned Memory.
Idempotent ueber migration_key — bei Wiederholung wird der vorhandene
Punkt aktualisiert (Counter hoeher). So lernt ARIA cross-session,
nicht nur in der aktuellen Konversation."""
from datetime import datetime, timezone
import uuid as _uuid
from memory.vector_store import COLLECTION
from qdrant_client.http import models as _qm
skill_name = ev["skill_name"]
host = ev["host"]
count = ev["count"]
migration_key = f"auto/skill-bypass/{skill_name}"
title = f"Skill '{skill_name}' nutzen, nicht curl"
run_tool = f"run_{skill_name.replace('-', '_')}"
content = (
f"WICHTIG fuer Performance + Stefans Wartezeit: "
f"Skill '{skill_name}' existiert und deckt {host} ab. "
f"Nutze `{run_tool}(...)` als Brain-Tool, NICHT Bash-curl gegen {host}. "
f"Brain hat {count}× erkannt dass dieser Skill umgangen wurde "
f"(letzter Vorfall: heute). Ein Skill-Aufruf = 1 Tool-Call (~3s) "
f"vs. Bash-Wrapper = 3-5 Tool-Calls (~13-20s)."
)
# Alte Version mit gleicher migration_key entfernen (Counter-Update)
try:
self.store.client.delete(
collection_name=COLLECTION,
points_selector=_qm.FilterSelector(filter=_qm.Filter(must=[
_qm.FieldCondition(key="migration_key",
match=_qm.MatchValue(value=migration_key))
])),
)
except Exception:
pass
vec = self.embedder.embed(content)
now = datetime.now(timezone.utc).isoformat()
payload = {
"type": "rule",
"title": title,
"content": content,
"pinned": True,
"category": "skills",
"source": "auto-feedback",
"tags": [],
"created_at": now,
"updated_at": now,
"migration_key": migration_key,
"attachments": [],
}
self.store.client.upsert(
collection_name=COLLECTION,
points=[_qm.PointStruct(id=str(_uuid.uuid4()), vector=vec, payload=payload)],
)
logger.info("bypass-lesson upserted: skill=%s host=%s count=%d",
skill_name, host, count)
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
@@ -838,6 +938,87 @@ class Agent:
oauth_host = os.environ.get("RVS_HOST", "").strip()
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
# API-Heuristik: wenn ARIA gegen externe APIs wiederholt via Bash
# gecurled hat (cross-session, aus persistiertem agent_stream.jsonl),
# injiziert das einen Hinweis-Block der ihr scaffolden empfiehlt.
api_heuristic_section = ""
try:
import api_heuristic as _ah
hints = _ah.compute_hints(existing_skills=all_skills)
# AUTO-SCAFFOLD (Variante C): wenn ein Hinweis ein konkretes
# (name, template, params) hat UND der Skill noch nicht existiert,
# legt Brain ihn JETZT an — bevor ARIA wieder Bash-curl macht.
# ARIA findet den Skill in den naechsten Tool-Listen vor und
# nutzt ihn direkt via `run_<name>`. Toggle via ENV.
auto_scaffold = os.environ.get("BRAIN_AUTO_SCAFFOLD", "true").strip().lower() != "false"
if auto_scaffold and hints:
existing_names = {s.get("name") for s in all_skills}
scaffolded_any = False
for hint in hints:
sug = hint.get("suggestion")
if not sug:
continue
sname, stpl, sparams = sug
if sname in existing_names:
continue
try:
new_manifest = skills_mod.scaffold_skill(
name=sname, template=stpl, params=sparams, author="aria-auto",
)
logger.info("auto_scaffold: '%s' aus '%s' angelegt (trigger: %s mit %d Calls)",
sname, stpl, hint["host"], hint["count"])
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": new_manifest["name"],
"description": new_manifest.get("description", ""),
"execution": new_manifest.get("execution", ""),
"active": new_manifest.get("active", True),
"setup_error": new_manifest.get("setup_error"),
"auto_scaffolded": True,
"from_template": stpl,
"trigger_host": hint["host"],
"trigger_count": hint["count"],
},
})
scaffolded_any = True
except Exception as exc:
logger.warning("auto_scaffold '%s' fehlgeschlagen: %s", sname, exc)
if scaffolded_any:
# Skills-Liste refresh damit der frische Skill im Prompt sichtbar ist
all_skills = skills_mod.list_skills(active_only=False)
active_skills = [s for s in all_skills if s.get("active", True)]
_ah.invalidate_cache()
# Heuristik neu rechnen — die scaffold-targets sind jetzt weg
hints = _ah.compute_hints(existing_skills=all_skills, force=True)
api_heuristic_section = _ah.build_section(hints)
# BYPASS-DETECTION (Variante 3 / Lerneffekt):
# Hat ARIA in den letzten ~10min Bash-curl gegen einen Host
# gemacht OBWOHL der Skill existiert? → drastischer Hint im
# Prompt JETZT + pinned Memory speichern, damit's beim
# naechsten Turn / naechster Session weiter sichtbar ist
# ("echtes Lernen via Brain-Memory").
bypass_events = _ah.detect_recent_bypass(all_skills, since_sec=600)
if bypass_events:
bypass_section = _ah.build_bypass_section(bypass_events)
if bypass_section:
api_heuristic_section = (
(bypass_section + "\n\n" + api_heuristic_section)
if api_heuristic_section else bypass_section
)
# Pinned-Memory pro Skill speichern, idempotent ueber migration_key
for ev in bypass_events:
try:
self._upsert_bypass_lesson(ev)
except Exception as exc:
logger.warning("bypass-lesson upsert fehlgeschlagen: %s", exc)
except Exception as exc:
logger.warning("api_heuristic fehlgeschlagen: %s", exc)
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars,
@@ -846,7 +1027,8 @@ class Agent:
oauth_services=oauth_services,
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
oauth_callback_tls=oauth_tls,
api_heuristic_section=api_heuristic_section)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
messages.append(ProxyMessage(role=t.role, content=t.content))
@@ -945,6 +1127,35 @@ class Agent:
},
})
return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})."
if name == "skill_scaffold":
skill_name = (arguments.get("name") or "").strip()
template = (arguments.get("template") or "").strip()
params = arguments.get("params") or {}
if not skill_name or not template:
return "FEHLER: name + template erforderlich."
try:
manifest = skills_mod.scaffold_skill(
name=skill_name, template=template, params=params, author="aria",
)
except ValueError as exc:
return f"FEHLER: {exc}"
# Side-Channel-Event analog zu skill_create
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": manifest["name"],
"description": manifest.get("description", ""),
"execution": manifest.get("execution", ""),
"active": manifest.get("active", True),
"setup_error": manifest.get("setup_error"),
"scaffolded_from": template,
},
})
return (
f"OK — Skill '{manifest['name']}' aus Template '{template}' angelegt. "
f"active={manifest['active']}. "
f"Falls noetig: skill_update fuer custom Code, skill_set_config fuer secrets."
)
if name == "skill_list":
items = skills_mod.list_skills(active_only=False)
if not items:
+281
View File
@@ -0,0 +1,281 @@
"""
API-Heuristik — Cross-Session-Tracker fuer wiederkehrende externe API-Calls.
Problem: ARIA driftet bei trivialen API-Calls zu Bash-curl statt Skills
zu bauen. Die seed_rule "scaffold-reflex" greift nicht zuverlaessig weil
jede Chat-Anfrage eine eigene Claude-CLI-Session ist — in der aktuellen
Session sieht sie nicht dass dieselbe API gestern auch schon 10x via
curl angerufen wurde.
Loesung: Brain trackt server-side. Beim Bauen des System-Prompts wird
`agent_stream.jsonl` der letzten 24h gescannt, Bash-curl-Calls werden
nach Hostname aggregiert. Hosts ueber Schwelle bei denen noch kein
matching Skill existiert landen als Hinweis-Block im System-Prompt —
ARIA sieht "du machst 17x curl gegen api.spotify.com, scaffold bitte".
Caching: Ergebnis 5 min gehalten, sonst grep wir bei jedem Turn die
log-Datei. Bei <1 MB log file ist das eh schnell.
"""
from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
logger = logging.getLogger(__name__)
AGENT_STREAM_LOG = Path("/shared/logs/agent_stream.jsonl")
# Schwellen / Lookback — bewusst niedrig gehalten weil "2x ist Pattern" stimmt
LOOKBACK_HOURS = 24
THRESHOLD = 3
CACHE_TTL_SEC = 300
# Hosts die wir IGNORIEREN — interne Endpoints / Defaults
_IGNORED_HOSTS = {
"aria-brain", "brain", "localhost", "127.0.0.1", "0.0.0.0",
"api.example.com", # template-default in skill_templates
"aria-bridge", "aria-rvs", "aria-qdrant", "aria-proxy", "aria-diagnostic",
"172.17.0.1", # docker-host-bridge
}
# Bekannte Hosts → Template-Vorschlag fuer scaffold
_SUGGESTIONS: dict[str, tuple[str, str, dict]] = {
"api.spotify.com": ("spotify", "oauth-api", {"service": "spotify"}),
"api.github.com": ("github", "oauth-api", {"service": "github", "base_url": "https://api.github.com"}),
"api.openai.com": ("openai", "apikey-api",
{"api_name": "OpenAI", "key_env": "OPENAI_API_KEY",
"base_url": "https://api.openai.com"}),
"api.openweathermap.org": ("openweather", "apikey-api",
{"api_name": "OpenWeather", "key_env": "OWM_API_KEY",
"base_url": "https://api.openweathermap.org"}),
"api.telegram.org": ("telegram", "apikey-api",
{"api_name": "Telegram-Bot", "key_env": "TELEGRAM_BOT_TOKEN",
"auth_header": "", "auth_prefix": "",
"base_url": "https://api.telegram.org"}),
"graph.microsoft.com": ("microsoft", "oauth-api",
{"service": "microsoft", "base_url": "https://graph.microsoft.com"}),
"discord.com": ("discord", "oauth-api",
{"service": "discord", "base_url": "https://discord.com/api"}),
"api.notion.com": ("notion", "oauth-api",
{"service": "notion", "base_url": "https://api.notion.com"}),
"reddit.com": ("reddit", "oauth-api",
{"service": "reddit", "base_url": "https://oauth.reddit.com"}),
"oauth.reddit.com": ("reddit", "oauth-api",
{"service": "reddit", "base_url": "https://oauth.reddit.com"}),
}
_cache: dict = {"computed_at": 0.0, "hints": []}
def invalidate_cache() -> None:
"""Cache leeren — sinnvoll nach skill_create / scaffold damit der neue
Skill sofort beim naechsten Aufruf erkannt wird."""
_cache.update(computed_at=0.0, hints=[])
def detect_recent_bypass(
existing_skills: list[dict],
since_sec: int = 600,
) -> list[dict]:
"""Findet Skill-Bypass-Vorfaelle: Bash-curl gegen einen Host fuer den
bereits ein matching Skill existiert. ARIA haette `run_<skill>` nutzen
sollen, hat aber gecurled. Das ist Drift — wir wollen es Brain merken.
Returns: liste {host, skill_name, count, last_ts} fuer Hosts wo ein
Bypass in den letzten `since_sec` Sekunden vorkam.
"""
if not AGENT_STREAM_LOG.exists() or not existing_skills:
return []
cutoff_ms = (time.time() - since_sec) * 1000
# Map host → matching skill_name
host_to_skill = {}
for s in existing_skills:
sname = (s.get("name") or "").lower()
if not sname:
continue
# Heuristik wie in _host_already_has_skill: stem des Skill-Namens
# mit Hostnamen verglichen. Fuer scaffolded skills nehmen wir den
# Skill-Namen als stem (z.B. "spotify" -> matched api.spotify.com)
host_to_skill[sname] = sname
bypass_events: dict[str, dict] = {}
try:
with AGENT_STREAM_LOG.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
try:
e = json.loads(line)
except Exception:
continue
if e.get("kind") != "tool_use":
continue
if (e.get("name") or "") != "Bash":
continue
ts = e.get("ts") or 0
if ts < cutoff_ms:
continue
for host in _extract_hosts_from_bash_input(e.get("input") or ""):
h = host.lower()
if h in _IGNORED_HOSTS:
continue
# Welcher Skill-Name matched diesen Host?
matched_skill = None
for skill_stem in host_to_skill:
if skill_stem in h:
matched_skill = host_to_skill[skill_stem]
break
if not matched_skill:
continue
entry = bypass_events.setdefault(h, {
"host": h, "skill_name": matched_skill,
"count": 0, "last_ts": 0,
})
entry["count"] += 1
if ts > entry["last_ts"]:
entry["last_ts"] = ts
except Exception as exc:
logger.warning("detect_recent_bypass: konnte log nicht lesen: %s", exc)
return []
return list(bypass_events.values())
def build_bypass_section(bypass_events: list[dict]) -> str:
"""Drastischer Block fuer den System-Prompt wenn ARIA gerade gegen einen
Host gecurled hat OBWOHL der Skill existiert. Inhalt soll sie spuerbar
ermahnen — wirkt nur in der aktuellen Session."""
if not bypass_events:
return ""
lines = [
"## 🚨 SKILL-BYPASS ERKANNT",
"",
"Du hast gerade — IN DEN LETZTEN MINUTEN — Bash-curl gegen Hosts "
"gemacht obwohl ein passender Skill existiert. Das ist Verschwendung: "
"5 Bash-Roundtrips à 3s statt 1 Tool-Call à 3s. Stefan wartet doppelt. "
"AB JETZT in diesem Chat:",
"",
]
for ev in bypass_events:
sname = ev["skill_name"]
host = ev["host"]
count = ev["count"]
lines.append(f"- gegen **{host}** ({count}x kuerzlich) → nutze "
f"`run_{sname.replace('-', '_')}(...)` statt curl. "
f"Der Skill ist da. Nutze ihn.")
lines.append("")
return "\n".join(lines)
def _extract_hosts_from_bash_input(input_str: str) -> list[str]:
"""Hostnames aus URLs in einem Bash-Command. Sehr robust — sucht `https?://host`."""
if not input_str:
return []
return re.findall(r'https?://([a-zA-Z0-9.\-]+)', input_str)
def _host_already_has_skill(host: str, skills: list[dict]) -> bool:
"""Heuristik: Skill-Name enthaelt den 'wesentlichen' Teil des Hosts.
'api.spotify.com' → Stem 'spotify'. Wenn ein Skill 'spotify*' existiert: ja.
"""
parts = [p for p in host.split(".") if p and p not in ("api", "www", "oauth")]
if not parts:
return False
stem = parts[0].lower()
for s in skills:
sname = (s.get("name") or "").lower()
if stem and stem in sname:
return True
return False
def compute_hints(existing_skills: list[dict] | None = None, force: bool = False) -> list[dict]:
"""Aggregiert Bash-curl-Calls der letzten LOOKBACK_HOURS aus dem
agent_stream.jsonl. Returns Liste von Hinweisen, geordnet nach Count
absteigend; nur Hosts ohne matching Skill, nur >= THRESHOLD Calls.
Hint-Format: {host, count, lookback_hours, suggestion: (name, template, params) | None}
"""
skills = existing_skills or []
now = time.time()
if not force and (now - _cache["computed_at"]) < CACHE_TTL_SEC:
return _cache["hints"]
if not AGENT_STREAM_LOG.exists():
_cache.update(computed_at=now, hints=[])
return []
cutoff_ms = (now - LOOKBACK_HOURS * 3600) * 1000
counts: dict[str, int] = {}
try:
# Stream-Read damit grosse Files (50 MB cap) nicht in den Speicher kippen
with AGENT_STREAM_LOG.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
try:
e = json.loads(line)
except Exception:
continue
if e.get("kind") != "tool_use":
continue
if (e.get("name") or "") != "Bash":
continue
if (e.get("ts") or 0) < cutoff_ms:
continue
for host in _extract_hosts_from_bash_input(e.get("input") or ""):
h = host.lower()
if h in _IGNORED_HOSTS:
continue
counts[h] = counts.get(h, 0) + 1
except Exception as exc:
logger.warning("api_heuristic: konnte agent_stream nicht lesen: %s", exc)
return []
hints = []
for host, count in counts.items():
if count < THRESHOLD:
continue
if _host_already_has_skill(host, skills):
continue
hints.append({
"host": host,
"count": count,
"lookback_hours": LOOKBACK_HOURS,
"suggestion": _SUGGESTIONS.get(host),
})
hints.sort(key=lambda x: -x["count"])
_cache.update(computed_at=now, hints=hints)
return hints
def build_section(hints: list[dict]) -> str:
"""Formatiert einen kompakten System-Prompt-Block. Leer wenn nichts."""
if not hints:
return ""
lines = [
"## API-Heuristik (Cross-Session-Counter)",
"",
"Du hast in den letzten 24h diese externe(n) API(s) per Bash-curl "
"wiederholt angerufen, OHNE dass ein Skill dafuer existiert. Beim "
"naechsten Aufruf gegen einen dieser Hosts: BAUE ZUERST den Skill "
"via `skill_scaffold`, dann nutze ihn. Spart Stefan Wartezeit "
"und Dir Tool-Roundtrips.",
"",
]
for h in hints[:5]: # max 5 Eintraege damit Prompt nicht explodiert
sug = h.get("suggestion")
if sug:
name, tpl, params = sug
params_json = json.dumps(params, ensure_ascii=False)
sug_str = f"`skill_scaffold('{name}', '{tpl}', {params_json})`"
else:
sug_str = "`skill_scaffold` mit passendem Template (oauth-api / apikey-api)"
lines.append(f"- **{h['host']}** ({h['count']}x in 24h) → {sug_str}")
lines.append("")
return "\n".join(lines)
+26
View File
@@ -798,6 +798,32 @@ def skills_get(name: str):
return {"manifest": m, "readme": readme}
class SkillScaffold(BaseModel):
name: str
template: str # oauth-api | apikey-api | file-process
params: dict = Field(default_factory=dict)
author: str = "stefan"
@app.get("/skills/templates")
def skills_templates_list():
"""Liste der verfuegbaren Templates — fuer UI und Dokumentation."""
import skill_templates as st
return {"templates": st.list_templates()}
@app.post("/skills/scaffold")
def skills_scaffold(body: SkillScaffold):
"""Baut einen Skill aus einem Template (oauth-api / apikey-api / file-process)."""
try:
return skills_mod.scaffold_skill(
name=body.name, template=body.template,
params=body.params, author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/skills/create")
def skills_create(body: SkillCreate):
try:
+6
View File
@@ -340,12 +340,18 @@ def build_system_prompt(
oauth_callback_host: str = "",
oauth_callback_port: str = "443",
oauth_callback_tls: bool = True,
api_heuristic_section: str = "",
) -> str:
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
parts = [build_hot_memory_section(pinned), "", build_time_section()]
if skills:
parts.append("")
parts.append(build_skills_section(skills))
if api_heuristic_section:
# Direkt nach Skills weil thematisch verwandt ("welche Skills gibt's, "
# welche Skills FEHLEN")
parts.append("")
parts.append(api_heuristic_section)
if condition_vars:
parts.append("")
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
+138
View File
@@ -114,6 +114,144 @@ SEED_RULES: List[dict] = [
"Standort per /memory/search holen statt ihn als Arg zu erwarten."
),
},
{
"migration_key": "seed/skill-rule/oauth-reauth-reflex",
"type": "rule",
"title": "Skill-Regel: OAuth-Re-Auth-Reflex (Refresh statt Re-Login)",
"category": "skills",
"content": (
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
"'token expired' zurueckgibt: RUFE ZUERST "
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
"gueltigen Token oder refresht ihn automatisch ueber den "
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
"\n"
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
"('refresh failed', 'no refresh_token', 'service nicht "
"konfiguriert'): DANN `oauth_authorize` und Stefan zum Login "
"schicken. Vorher NIEMALS.\n"
"\n"
"Anti-Pattern (Stefan musste so 3x manuell einloggen weil ich "
"das falsch gemacht hatte): bei jedem 401 reflexartig "
"oauth_authorize zu rufen. Das ist das aergerlichste was Du "
"ihm antun kannst — er muss aus dem Auto raus, Handy "
"rauskramen, klicken. Refresh haendelt das Brain transparent, "
"nutze es."
),
},
{
"migration_key": "seed/skill-rule/no-skill-drift",
"type": "rule",
"title": "Skill-Regel: kein Drift vom Skill zu Ad-hoc-Bash",
"category": "skills",
"content": (
"Wenn ein bestehender Skill ein Problem hat (kaputter Output, "
"fehlender Feature-Wunsch, Setup-Error): lies `skill_logs` und "
"`skill_get`, finde das Problem, fixe es mit `skill_update`. "
"\n"
"ABSOLUT VERBOTEN: 'ich lass den Code jetzt einfach direkt auf "
"der VM laufen' / direkt Bash-curl-Befehle ausfuehren statt "
"den Skill anzufassen. Das macht den Skill zur Karteileiche "
"und beim naechsten Mal hast Du wieder nichts. Stefan kann "
"dann auch nichts wiederverwenden (Triggers, App-UI, Logs).\n"
"\n"
"Auch nicht: 'ich baue dir einen Skill' SAGEN ohne tatsaechlich "
"`skill_create` zu rufen. Stefan checkt die Skill-Liste, und "
"wenn er nichts findet, glaubt er dir nie wieder. Wenn Du es "
"sagst, MACH es. Wenn es Probleme gibt (anti-Friedhof-Check, "
"Setup-Error): sag das ehrlich statt zu halluzinieren."
),
},
{
"migration_key": "seed/architecture/runtime-topology",
"type": "rule",
"title": "Architektur: wo Du als ARIA tatsaechlich laufst",
"category": "architektur",
"content": (
"WICHTIG fuer jeden Bash-Reflex: Du bist die `claude` CLI als "
"Subprocess IM `aria-proxy` Container (node:22-alpine). NICHT "
"im aria-brain. Konsequenzen:\n"
"\n"
" - `python3` / `python` / `jq` sind NICHT installiert. Alpine "
"ist minimal. Nutze nur: curl, sed, grep, awk, sh — oder das "
"richtige Tool statt Bash.\n"
" - `/data/skills/` existiert NUR im aria-brain Container. "
"Du kannst Skills NICHT ueber Bash inspizieren oder starten. "
"Skills laeufst Du als Brain-Tool: `run_<skill_name>` "
"(z.B. `run_yt_dlp_download`). `skill_list` zeigt verfuegbare.\n"
" - `localhost` in Deinem Bash heisst aria-proxy, NICHT "
"aria-brain. Brain ist via Docker-Net erreichbar als "
"`http://aria-brain:8080` (oder Alias `http://brain:8080`). "
"ABER: in 99% der Faelle willst Du das gar nicht — nutze die "
"Brain-Tools direkt (`oauth_get_token`, `memory_search`, …), "
"die sind eine Tool-Call-Ebene hoeher und schneller.\n"
" - `BRAIN_INTERNAL_URL` ist NUR in laufenden Skills gesetzt, "
"NICHT in Deinem Bash-Env. Wenn Du `env | grep BRAIN` machst "
"und nichts findest: das ist normal, Du bist hier nicht in "
"einem Skill.\n"
"\n"
"Was Du DOCH von hier aus kannst:\n"
" - Per `ssh aria@host` zur VM-Host wechseln — der ed25519-"
"Key liegt unter /root/.ssh/. Dort bist Du `aria` mit sudo "
"und voller Linux-Power. Fuer Pentest, Admin, komplexe Tasks "
"der richtige Weg.\n"
" - Externe APIs direkt anpingen (Spotify, GitHub etc.) — "
"curl reicht. Token holst Du Dir per Brain-Tool "
"`oauth_get_token('<service>')` und packst ihn in den curl-"
"Header. Aber: das ist Ad-hoc. Fuer wiederkehrendes baust Du "
"einen Skill (siehe no-skill-drift Regel).\n"
"\n"
"Anti-Pattern (47 Sekunden Stefan-Lebenszeit, am 29.05.2026): "
"12 Bash-Versuche mit python3/python/jq/lokales /data/skills "
"→ alles fehlte. Erst nach 9 Tries kapiert dass `localhost` "
"der falsche Host ist. Bei jedem Bash-Call gegen 'lokale' "
"Brain-Resources: erst denken, sonst Brain-Tool nehmen."
),
},
{
"migration_key": "seed/skill-rule/scaffold-reflex",
"type": "rule",
"title": "Skill-Regel: skill_scaffold-Reflex (mit Cross-Session-Counter)",
"category": "skills",
"content": (
"Brain trackt server-side wie oft Du in den letzten 24h dieselbe "
"externe API per Bash-curl angerufen hast (Cross-Session-Counter, "
"siehe '## API-Heuristik'-Block im System-Prompt). \n"
"\n"
"AUTO-SCAFFOLD: Brain legt fuer wiederkehrende Hosts mit "
"bekanntem Template (Spotify, GitHub, OpenAI, OpenWeather, …) "
"automatisch einen Skill an — Du siehst ihn dann in `## Skills` "
"ohne dass Du ihn selbst gebaut hast (Markierung "
"`author=aria-auto`). NUTZE diesen Skill via `run_<name>` "
"direkt, NICHT mehr Bash-curl gegen den Host. Beispiel: wenn "
"`spotify` plotzlich in der Skill-Liste auftaucht → "
"`run_spotify({method:'GET', path:'/v1/me/player'})` statt "
"Token holen + curl.\n"
"\n"
"Wenn die API-Heuristik einen Eintrag OHNE Suggestion zeigt "
"(unbekannter Host): rufe selbst `skill_scaffold` mit dem "
"passenden Template (oauth-api / apikey-api / file-process), "
"BEVOR Du wieder Bash-curl machst.\n"
"\n"
"Warum: jede Chat-Anfrage ist eine eigene Claude-CLI-Session — "
"Du siehst nicht dass Du gestern auch schon 10x Spotify gecurled "
"hast. Der API-Heuristik-Block ist Dein Cross-Session-Gedaechtnis. "
"Wenn er leer ist: alles OK, weitermachen. Wenn nicht: scaffolden.\n"
"\n"
"Templates (ausfuehrliche Doku siehe skill_scaffold-Tool):\n"
" - **oauth-api**: Spotify/GitHub/Reddit/Google/Discord. Token "
"kommt vom Brain mit Auto-Refresh.\n"
" - **apikey-api**: OpenWeather/OpenAI/Twilio. Key landet im "
"config_schema → CFG_<NAME> ENV. Stefan setzt ihn in Diagnostic.\n"
" - **file-process**: PDF/Bild/JSON-Wandler. process()-Stub, "
"danach `skill_update` mit echtem Code.\n"
"\n"
"Belohnung konkret: ein Spotify-Skill macht 'welches lied laeuft' "
"in 1 Tool-Call (~3s) statt 3-5 Bash-Roundtrips (~13-20s). Stefan "
"merkt das sofort. Ein einmaliger Scaffold-Aufwand spart hunderte "
"Bash-Roundtrips."
),
},
{
"migration_key": "seed/skill-rule/external-api-auth-strategy",
"type": "rule",
+460
View File
@@ -0,0 +1,460 @@
"""
Skill-Templates — Boilerplate fuer haeufige Skill-Pattern.
ARIA muss nicht jedes Mal einen kompletten Python-Skill aus dem Nichts
generieren. Sie ruft `skill_scaffold(name, template, params)`, Brain
expandiert das Template und legt den Skill an. Hoehere Skill-Adoption
weil niedrigere Bauh-Huerde.
Templates sind ueber Token-Replacement parametrisiert (kein f-String —
das wuerde mit dem skill-internen Python-Code kollidieren).
"""
from __future__ import annotations
import re
from typing import Callable
# ── Hilfsfunktion ────────────────────────────────────────────────────
def _replace_tokens(s: str, tokens: dict) -> str:
"""Ersetzt {{TOKEN}}-Platzhalter durch Werte. Robust gegen f-String-
Konflikte im Python-Code des Skills."""
out = s
for k, v in tokens.items():
out = out.replace("{{" + k + "}}", str(v))
return out
# ── Template 1: oauth-api ────────────────────────────────────────────
# Wrappt eine OAuth2-API. Token kommt aus dem Brain (Auto-Refresh).
_OAUTH_API_CODE = '''"""
{{NAME}} — OAuth2-API-Wrapper fuer {{SERVICE}}.
Holt Token vom Brain (Auto-Refresh) und ruft HTTP-Endpoints der {{SERVICE}}-API.
Keine hardcoded Credentials — alles ueber das zentrale OAuth-System.
Args (alle als ENV ARG_<NAME>):
ARG_METHOD = GET | POST | PUT | DELETE | PATCH (Default GET)
ARG_PATH = API-Pfad inkl. Query-String (z.B. /v1/me/player)
ARG_BODY = JSON-Body als String (optional, fuer POST/PUT/PATCH)
ARG_BASE_URL = Override der Default-Base-URL (optional)
Exit-Codes: 0 ok, 1 Fehler, 2 nicht autorisiert (Re-Login noetig)
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BRAIN_URL = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
SERVICE = "{{SERVICE}}"
def get_token() -> str:
try:
with urllib.request.urlopen(
f"{BRAIN_URL}/oauth/{SERVICE}/token", timeout=10,
) as r:
return json.loads(r.read())["access_token"]
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")[:400]
if e.code == 401:
print(f"NICHT AUTORISIERT: {SERVICE}-Token abgelaufen oder nie gesetzt. "
f"ARIA-Tool 'oauth_authorize' nutzen. Details: {body}", file=sys.stderr)
sys.exit(2)
print(f"Token-Holen fehlgeschlagen: HTTP {e.code} - {body}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Token-Holen fehlgeschlagen: {e}", file=sys.stderr)
sys.exit(1)
def main() -> int:
method = (os.environ.get("ARG_METHOD") or "GET").upper()
path = (os.environ.get("ARG_PATH") or "").strip()
body_raw = (os.environ.get("ARG_BODY") or "").strip()
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not path:
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
return 1
if not path.startswith("/"):
path = "/" + path
url = base_url + path
headers = {"Authorization": f"Bearer {get_token()}"}
data = None
if body_raw and method in ("POST", "PUT", "PATCH"):
data = body_raw.encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
txt = r.read().decode("utf-8")
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
ensure_ascii=False, indent=2))
return 0
except urllib.error.HTTPError as e:
txt = e.read().decode("utf-8", "replace")
try: parsed = json.loads(txt)
except Exception: parsed = txt[:800]
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
ensure_ascii=False, indent=2))
return 1
if __name__ == "__main__":
sys.exit(main())
'''
_OAUTH_API_README = '''# {{NAME}}
OAuth2-API-Wrapper fuer **{{SERVICE}}**. Generiert via `skill_scaffold(template="oauth-api")`.
Holt den Token vom Brain (Auto-Refresh) und macht beliebige HTTP-Calls gegen
die {{SERVICE}}-API. Keine hardcoded Credentials — die Auth-Pipeline laeuft
zentral ueber das Brain-OAuth-System.
## Voraussetzung
- OAuth-App fuer **{{SERVICE}}** im Brain registriert (Diagnostic → OAuth-Apps → client_id + client_secret eintragen)
- Einmaliges `oauth_authorize {{SERVICE}}` zum Initial-Login
## Args
| Name | Default | Beschreibung |
|------|---------|--------------|
| method | GET | HTTP-Methode (GET/POST/PUT/DELETE/PATCH) |
| path | - | API-Pfad mit Query-String (z.B. `/v1/me/player`) |
| body | - | JSON-Body fuer POST/PUT/PATCH |
| base_url | {{DEFAULT_BASE_URL}} | Override der Base-URL falls Sub-API |
## Beispiele
```
method=GET path=/v1/me/player # Was laeuft?
method=POST path=/v1/me/player/next # Skip
method=PUT path=/v1/me/player/volume?volume_percent=40 # Volume 40
```
Antwort: `{ok, status, data}` als JSON. Bei Fehler `ok=false`.
'''
def _oauth_api(name: str, params: dict) -> dict:
service = (params.get("service") or name).strip().lower()
default_base_url = params.get("base_url") or f"https://api.{service}.com"
tokens = {
"NAME": name,
"SERVICE": service,
"DEFAULT_BASE_URL": default_base_url,
}
return {
"entry_code": _replace_tokens(_OAUTH_API_CODE, tokens),
"readme": _replace_tokens(_OAUTH_API_README, tokens),
"pip_packages": [],
"args": [
{"name": "method", "type": "string", "required": False,
"description": "HTTP-Methode (Default GET)"},
{"name": "path", "type": "string", "required": True,
"description": "API-Pfad inkl. Query-String, z.B. /v1/me/player"},
{"name": "body", "type": "string", "required": False,
"description": "JSON-Body fuer POST/PUT/PATCH"},
{"name": "base_url", "type": "string", "required": False,
"description": f"Override der Base-URL (Default {default_base_url})"},
],
"config_schema": [],
"description": f"OAuth2-API-Wrapper fuer {service}. Token kommt vom Brain (Auto-Refresh).",
}
# ── Template 2: apikey-api ───────────────────────────────────────────
# Wrappt eine API die mit statischem API-Key/Bearer-Token arbeitet.
# Key liegt in skill.json::config_schema und wird via CFG_<KEY> ENV
# durchgereicht — kein hardcoden, Stefan setzt's in Diagnostic.
_APIKEY_API_CODE = '''"""
{{NAME}} — API-Wrapper fuer {{API_NAME}} mit statischem Key.
Schluessel kommt aus dem Skill-Config (CFG_{{KEY_ENV}}) — Stefan setzt
ihn im Diagnostic-UI bzw. App, NICHT hardcoded.
Args:
ARG_METHOD = GET | POST | PUT | DELETE (Default GET)
ARG_PATH = API-Pfad inkl. Query-String
ARG_BODY = JSON-Body (optional)
ARG_BASE_URL = Override der Default-Base-URL
Exit-Codes: 0 ok, 1 Fehler, 2 Key nicht gesetzt
"""
import json
import os
import sys
import urllib.error
import urllib.request
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
AUTH_HEADER = "{{AUTH_HEADER}}" # z.B. "Authorization" oder "X-Api-Key"
AUTH_PREFIX = "{{AUTH_PREFIX}}" # z.B. "Bearer " oder leer
def main() -> int:
key = os.environ.get("CFG_{{KEY_ENV}}", "").strip()
if not key:
print(json.dumps({"ok": False,
"error": "API-Key nicht gesetzt — in Diagnostic Skill-Config '{{KEY_ENV}}' eintragen"}),
file=sys.stderr)
return 2
method = (os.environ.get("ARG_METHOD") or "GET").upper()
path = (os.environ.get("ARG_PATH") or "").strip()
body_raw = (os.environ.get("ARG_BODY") or "").strip()
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not path:
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
return 1
if not path.startswith("/"):
path = "/" + path
url = base_url + path
headers = {AUTH_HEADER: f"{AUTH_PREFIX}{key}"}
data = None
if body_raw and method in ("POST", "PUT", "PATCH"):
data = body_raw.encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
txt = r.read().decode("utf-8")
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
ensure_ascii=False, indent=2))
return 0
except urllib.error.HTTPError as e:
txt = e.read().decode("utf-8", "replace")
try: parsed = json.loads(txt)
except Exception: parsed = txt[:800]
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
ensure_ascii=False, indent=2))
return 1
if __name__ == "__main__":
sys.exit(main())
'''
_APIKEY_API_README = '''# {{NAME}}
API-Wrapper fuer **{{API_NAME}}** mit statischem API-Key. Generiert via
`skill_scaffold(template="apikey-api")`.
Schluessel ist NICHT im Code, sondern im Skill-Config (`CFG_{{KEY_ENV}}`).
Stefan setzt ihn in Diagnostic → Skills → Detail → Konfiguration.
## Args
| Name | Default | Beschreibung |
|------|---------|--------------|
| method | GET | HTTP-Methode |
| path | - | API-Pfad mit Query-String |
| body | - | JSON-Body |
| base_url | {{DEFAULT_BASE_URL}} | Override |
## Config (in Diagnostic einstellen)
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| {{KEY_ENV}} | password | API-Key fuer {{API_NAME}} |
'''
def _apikey_api(name: str, params: dict) -> dict:
api_name = params.get("api_name") or name
key_env = (params.get("key_env") or "API_KEY").upper()
# safe: nur Buchstaben/Zahlen/Underscore
key_env = re.sub(r"[^A-Z0-9_]", "_", key_env)
auth_header = params.get("auth_header") or "Authorization"
auth_prefix = params.get("auth_prefix") if "auth_prefix" in params else "Bearer "
default_base_url = params.get("base_url") or "https://api.example.com"
tokens = {
"NAME": name,
"API_NAME": api_name,
"KEY_ENV": key_env,
"AUTH_HEADER": auth_header,
"AUTH_PREFIX": auth_prefix,
"DEFAULT_BASE_URL": default_base_url,
}
return {
"entry_code": _replace_tokens(_APIKEY_API_CODE, tokens),
"readme": _replace_tokens(_APIKEY_API_README, tokens),
"pip_packages": [],
"args": [
{"name": "method", "type": "string", "required": False,
"description": "HTTP-Methode (Default GET)"},
{"name": "path", "type": "string", "required": True,
"description": "API-Pfad inkl. Query-String"},
{"name": "body", "type": "string", "required": False,
"description": "JSON-Body fuer POST/PUT"},
{"name": "base_url", "type": "string", "required": False,
"description": "Override der Base-URL"},
],
"config_schema": [
{"name": key_env, "type": "password", "label": f"{api_name} API-Key",
"secret": True, "description": f"Persoenlicher API-Key fuer {api_name}"},
],
"description": f"API-Wrapper fuer {api_name} (Key aus CFG_{key_env}).",
}
# ── Template 3: file-process ─────────────────────────────────────────
# Nimmt eine Datei aus /shared/uploads/, ruft eine User-Funktion drauf
# auf, schreibt das Resultat nach /shared/uploads/. Skelett — ARIA fuellt
# die `process()`-Funktion danach via skill_update mit dem echten Code.
_FILE_PROCESS_CODE = '''"""
{{NAME}} — File-Processing-Skelett.
Liest eine Eingabe-Datei aus /shared/uploads/, ruft process() auf,
schreibt Output zurueck nach /shared/uploads/.
Args:
ARG_INPUT = Pfad zur Eingabedatei (z.B. /shared/uploads/foo.pdf)
ARG_OUTPUT = Optional Pfad fuer Output (Default: <input>.{{OUTPUT_EXT}})
ARIA-Hinweis: die process()-Funktion ist ein Stub — passe sie via
skill_update an deine Aufgabe an. pip_packages bei Bedarf via
skill_update ergaenzen (z.B. pypdf, Pillow, reportlab).
"""
import os
import shutil
import sys
def process(input_path: str, output_path: str) -> None:
"""Eigentlicher Verarbeitungs-Schritt. Hier kommt der Code rein."""
# STUB: kopiert die Datei einfach. ARIA: ueberschreibe diese Funktion.
shutil.copy(input_path, output_path)
def main() -> int:
inp = (os.environ.get("ARG_INPUT") or "").strip()
if not inp:
print("FEHLER: ARG_INPUT erforderlich", file=sys.stderr)
return 1
if not os.path.exists(inp):
print(f"FEHLER: Eingabe nicht gefunden: {inp}", file=sys.stderr)
return 1
out = (os.environ.get("ARG_OUTPUT") or "").strip()
if not out:
base, _ = os.path.splitext(inp)
out = f"{base}.{{OUTPUT_EXT}}"
try:
process(inp, out)
except Exception as e:
print(f"FEHLER bei process(): {e}", file=sys.stderr)
return 1
print(out) # stdout = Pfad zur Ausgabe-Datei, ARIA kann den dem User zurueckgeben
return 0
if __name__ == "__main__":
sys.exit(main())
'''
_FILE_PROCESS_README = '''# {{NAME}}
File-Processing-Skelett (`skill_scaffold(template="file-process")`).
Liest eine Datei aus `/shared/uploads/`, ruft die `process()`-Funktion auf,
schreibt das Resultat zurueck. Die `process()`-Funktion ist initial ein
Stub (kopiert nur) — ARIA passt sie via `skill_update` an die konkrete
Aufgabe an.
## Args
| Name | Default | Beschreibung |
|------|---------|--------------|
| input | - | Eingabedatei (z.B. /shared/uploads/foo.pdf) |
| output | `<input>.{{OUTPUT_EXT}}` | Ausgabepfad (optional) |
stdout = Pfad zur erzeugten Datei → ARIA kann ihn dem User zurueckgeben.
'''
def _file_process(name: str, params: dict) -> dict:
output_ext = (params.get("output_ext") or "out").strip().lstrip(".")
output_ext = re.sub(r"[^a-zA-Z0-9]", "", output_ext) or "out"
tokens = {
"NAME": name,
"OUTPUT_EXT": output_ext,
}
return {
"entry_code": _replace_tokens(_FILE_PROCESS_CODE, tokens),
"readme": _replace_tokens(_FILE_PROCESS_README, tokens),
"pip_packages": [],
"args": [
{"name": "input", "type": "string", "required": True,
"description": "Eingabedatei (z.B. /shared/uploads/foo.pdf)"},
{"name": "output", "type": "string", "required": False,
"description": f"Output-Pfad (Default <input>.{output_ext})"},
],
"config_schema": [],
"description": f"File-Processing-Skelett (Input → process() → Output.{output_ext}).",
}
# ── Registry ────────────────────────────────────────────────────────
TEMPLATES: dict[str, Callable[[str, dict], dict]] = {
"oauth-api": _oauth_api,
"apikey-api": _apikey_api,
"file-process": _file_process,
}
def list_templates() -> list[dict]:
"""Liste aller verfuegbaren Templates mit Kurzbeschreibung — fuer UI/Tool-Doku."""
return [
{
"name": "oauth-api",
"description": "OAuth2-API-Wrapper (Spotify, GitHub, Reddit, Google, …). "
"Token kommt vom Brain mit Auto-Refresh. Args: method/path/body.",
"params": ["service (str, OAuth-Service-Name)", "base_url (str, optional)"],
},
{
"name": "apikey-api",
"description": "API-Wrapper fuer Services mit statischem API-Key "
"(OpenWeather, OpenAI, Twilio, …). Key liegt im Skill-Config "
"und kommt als CFG_<NAME> ENV — kein hardcode.",
"params": ["api_name (str)", "key_env (str, ENV-Name fuer den Key)",
"auth_header (str, default 'Authorization')",
"auth_prefix (str, default 'Bearer ')",
"base_url (str)"],
},
{
"name": "file-process",
"description": "Skelett fuer File-In/File-Out-Operationen "
"(PDF konvertieren, Bild bearbeiten, JSON umformen). "
"process()-Funktion ist Stub, ARIA fuellt sie via skill_update.",
"params": ["output_ext (str, Datei-Endung des Outputs)"],
},
]
def expand(name: str, template: str, params: dict | None = None) -> dict:
"""Expandiert ein Template zu einem fertigen Skill-Spec.
Returns: dict mit entry_code / readme / pip_packages / args /
config_schema / description — direkt an create_skill weitergebbar.
Wirft ValueError wenn das Template nicht existiert.
"""
fn = TEMPLATES.get(template)
if not fn:
raise ValueError(
f"Template '{template}' unbekannt. Verfuegbar: {sorted(TEMPLATES.keys())}"
)
return fn(name, params or {})
+33
View File
@@ -347,6 +347,39 @@ def update_skill(name: str, patch: dict) -> dict:
return manifest
def scaffold_skill(
name: str,
template: str,
params: Optional[dict] = None,
author: str = "aria",
) -> dict:
"""Baut einen Skill aus einem Template-Skelett. ARIA muss nicht jedes Mal
einen kompletten Python-Skill schreiben — sie waehlt ein Template und
optionale Parameter, Brain expandiert das zu fertigem Code.
Templates siehe `skill_templates.TEMPLATES`. Konkret:
- 'oauth-api' : params={service, base_url?}
- 'apikey-api': params={api_name, key_env, auth_header?, auth_prefix?, base_url?}
- 'file-process': params={output_ext?}
Wirft ValueError wenn Template unbekannt oder Name kollidiert.
Sonst: ruft intern create_skill mit den expandierten Feldern auf.
"""
import skill_templates as _st
spec = _st.expand(name, template, params or {})
return create_skill(
name=name,
description=spec["description"],
execution="local-venv",
entry_code=spec["entry_code"],
readme=spec["readme"],
args=spec["args"],
pip_packages=spec["pip_packages"],
config_schema=spec["config_schema"],
author=author,
)
def delete_skill(name: str) -> None:
d = _skill_dir(name)
if not d.exists():
+157
View File
@@ -357,6 +357,37 @@
</div>
</div>
<!-- ARIA-Stream Archiv-Modal: paginierter Browser fuer den
persistierten agent_stream.jsonl. Page 1 = juengste Eintraege. -->
<div id="aria-archive-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.75);z-index:1100;align-items:center;justify-content:center;padding:24px;" onclick="if(event.target===this) closeAriaStreamModal();">
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:12px;width:100%;max-width:1100px;height:85vh;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;padding:12px 14px;border-bottom:1px solid #1E1E2E;gap:8px;flex-wrap:wrap;">
<h2 style="margin:0;color:#FFD60A;font-size:15px;flex:1;">📜 ARIA-Stream Archiv <span id="aria-archive-total" style="color:#8888AA;font-weight:normal;"></span></h2>
<label style="color:#8888AA;font-size:11px;">Pro Seite:</label>
<select id="aria-archive-perpage" onchange="loadAriaArchivePage(1)" style="background:#1A1A2E;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:4px;padding:3px 6px;font-size:11px;">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<button class="btn secondary" onclick="loadAriaArchivePage(_ariaArchivePage)" style="padding:3px 10px;font-size:11px;" title="Aktuelle Seite neu laden"></button>
<button class="btn secondary" onclick="closeAriaStreamModal()" style="padding:3px 12px;font-size:11px;">Schliessen</button>
</div>
<div style="display:flex;align-items:center;gap:6px;padding:8px 14px;border-bottom:1px solid #1E1E2E;flex-wrap:wrap;">
<button class="btn secondary" onclick="loadAriaArchivePage(1)" id="aria-arch-first" style="padding:3px 8px;font-size:11px;" title="Juengste Seite">«</button>
<button class="btn secondary" onclick="loadAriaArchivePage(_ariaArchivePage-1)" id="aria-arch-prev" style="padding:3px 8px;font-size:11px;" title="Eine Seite juenger"></button>
<span id="aria-arch-pageinfo" style="color:#8888AA;font-size:11px;min-width:140px;text-align:center;">Seite ? / ?</span>
<button class="btn secondary" onclick="loadAriaArchivePage(_ariaArchivePage+1)" id="aria-arch-next" style="padding:3px 8px;font-size:11px;" title="Eine Seite aelter"></button>
<button class="btn secondary" onclick="loadAriaArchivePage(_ariaArchivePagesTotal)" id="aria-arch-last" style="padding:3px 8px;font-size:11px;" title="Aelteste Seite">»</button>
<span style="flex:1;"></span>
<span style="color:#555570;font-size:10px;">Seite 1 = neueste · höhere Pages = älter</span>
</div>
<div id="aria-archive-list" style="flex:1;overflow-y:auto;background:#040408;font-family:'Courier New',monospace;font-size:11px;line-height:1.4;color:#C0C0D0;padding:6px 12px;">
<div style="color:#555570;font-style:italic;padding:20px;text-align:center;">Lade...</div>
</div>
</div>
</div>
<!-- Sessions + alter Brain-Viewer entfernt — Memories laufen jetzt
komplett ueber den Gehirn-Tab gegen die Vector-DB im aria-brain. -->
@@ -405,6 +436,7 @@
<div id="live-aria-bar" style="display:flex;gap:6px;align-items:center;padding:4px 4px 6px;flex-shrink:0;">
<span id="live-aria-status" style="font-size:11px;color:#8888AA;flex:1;">Idle — warte auf ARIA-Aktivitaet</span>
<button class="btn" onclick="clearAriaLive()" style="padding:4px 12px;font-size:11px;" title="Live-Mitschrift leeren">Leeren</button>
<button class="btn" onclick="openAriaStreamModal()" style="padding:4px 12px;font-size:11px;" title="Komplettes Archiv durchblaettern">📜 Archiv</button>
<label style="font-size:11px;color:#8888AA;display:flex;align-items:center;gap:4px;cursor:pointer;" title="Bei jeder neuen Zeile ans Ende scrollen">
<input type="checkbox" id="live-aria-autoscroll" checked style="margin:0;"> Auto-Scroll
</label>
@@ -3035,6 +3067,7 @@
document.getElementById('live-desktop').style.display = tab === 'desktop' ? 'block' : 'none';
document.getElementById('live-tab-aria').className = 'tab-btn' + (tab === 'aria' ? ' active' : '');
document.getElementById('live-tab-desktop').className = 'tab-btn' + (tab === 'desktop' ? ' active' : '');
if (tab === 'aria') loadAriaStreamHistory();
}
// ── ARIA Live (read-only Mirror der Claude-Code-Session) ──────
@@ -3150,6 +3183,127 @@
const el = _ariaStreamEl();
if (el) el.innerHTML = '<div style="color:#555570;font-style:italic;">Geleert.</div>';
}
// Beim ersten Tab-Oeffnen / Page-Reload: letzte 200 persistierte Events
// aus dem Diagnostic-Server holen. So sind die Live-Bash-Eintraege auch
// dann da wenn der Browser im Standby war.
let _ariaHistoryLoaded = false;
async function loadAriaStreamHistory(lines = 200) {
if (_ariaHistoryLoaded) return;
_ariaHistoryLoaded = true;
try {
const r = await fetch('/api/agent-stream?lines=' + lines);
if (!r.ok) return;
const d = await r.json();
const events = d.lines || [];
if (!events.length) return;
const el = _ariaStreamEl();
if (el) {
// Placeholder ('Sobald ARIA aktiv...') wegwerfen wenn vorhanden
const placeholder = el.querySelector('div[style*="italic"]');
if (placeholder) el.removeChild(placeholder);
}
_ariaPushLine(
`<span style="color:#444460;">━━━ ${events.length} fruehere Events (aus ${d.total || '?'} gespeicherten) ━━━</span>`,
'#444460',
);
for (const ev of events) {
try { appendAriaStreamEvent(ev); } catch {}
}
_ariaPushLine(
`<span style="color:#444460;">━━━ Ende History — Live ab hier ━━━</span>`,
'#444460',
);
_ariaMaybeScroll();
} catch (_) {}
}
// ── ARIA-Stream Archiv-Modal (Pagination) ────────────────
let _ariaArchivePage = 1;
let _ariaArchivePagesTotal = 1;
function openAriaStreamModal() {
const m = document.getElementById('aria-archive-modal');
if (!m) return;
m.style.display = 'flex';
loadAriaArchivePage(1);
}
function closeAriaStreamModal() {
const m = document.getElementById('aria-archive-modal');
if (m) m.style.display = 'none';
}
async function loadAriaArchivePage(page) {
const listEl = document.getElementById('aria-archive-list');
const infoEl = document.getElementById('aria-arch-pageinfo');
const totalEl = document.getElementById('aria-archive-total');
if (!listEl) return;
const perPage = parseInt(document.getElementById('aria-archive-perpage').value, 10) || 100;
page = Math.max(1, page || 1);
listEl.innerHTML = '<div style="color:#555570;font-style:italic;padding:20px;text-align:center;">Lade...</div>';
try {
const r = await fetch(`/api/agent-stream?page=${page}&perPage=${perPage}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const events = d.lines || [];
_ariaArchivePage = d.page || page;
_ariaArchivePagesTotal = d.pagesTotal || 1;
if (totalEl) totalEl.textContent = `(${d.total || 0} Eintraege gesamt)`;
if (infoEl) infoEl.textContent = `Seite ${_ariaArchivePage} / ${_ariaArchivePagesTotal}`;
// Nav-Buttons enablen/disablen
document.getElementById('aria-arch-first').disabled = (_ariaArchivePage <= 1);
document.getElementById('aria-arch-prev').disabled = (_ariaArchivePage <= 1);
document.getElementById('aria-arch-next').disabled = (_ariaArchivePage >= _ariaArchivePagesTotal);
document.getElementById('aria-arch-last').disabled = (_ariaArchivePage >= _ariaArchivePagesTotal);
if (!events.length) {
listEl.innerHTML = '<div style="color:#555570;font-style:italic;padding:20px;text-align:center;">Keine Eintraege auf dieser Seite.</div>';
return;
}
// Eintraege rendern — wir teilen sie in HTML-Snippets analog zu
// appendAriaStreamEvent, schreiben aber direkt in den Modal-Container.
const html = events.map(p => renderArchiveLine(p)).join('');
listEl.innerHTML = html;
listEl.scrollTop = listEl.scrollHeight;
} catch (e) {
listEl.innerHTML = `<div style="color:#FF6B6B;padding:20px;">Fehler beim Laden: ${_ariaEsc(e.message)}</div>`;
}
}
function renderArchiveLine(p) {
const t = _ariaTimePrefix(p.ts);
const kind = p.kind || '';
const time = `<span style="color:#777799;">[${t}]</span>`;
if (kind === 'start') {
return `<div style="color:#444460;">━━━ ${t} session start (${_ariaEsc(p.model||'unknown')}) ━━━</div>`;
}
if (kind === 'end') {
const reason = p.reason || '?';
const codePart = (p.code != null) ? ` code=${_ariaEsc(p.code)}` : '';
const errPart = p.error ? ` err=${_ariaEsc(String(p.error).slice(0,120))}` : '';
return `<div style="color:#444460;">━━━ ${t} session end (${_ariaEsc(reason)}${codePart}${errPart}) ━━━</div>`;
}
if (kind === 'text') {
return `<div style="color:#D0D0E0;white-space:pre-wrap;word-break:break-word;">${time} ${_ariaEsc(p.text || '')}</div>`;
}
if (kind === 'thinking') {
return `<div style="color:#888866;font-style:italic;white-space:pre-wrap;word-break:break-word;">${time} 💭 ${_ariaEsc(p.text || '')}</div>`;
}
if (kind === 'tool_use') {
const name = _ariaEsc(p.name || '?');
const inp = _ariaEsc(p.input || '');
const tail = p.inputTruncatedBytes ? `<span style="color:#777799;"> ...(+${p.inputTruncatedBytes} bytes)</span>` : '';
return `<div style="color:#C0C0D0;white-space:pre-wrap;word-break:break-word;">${time} <span style="color:#0096FF;">▶ ${name}</span> <span style="color:#8888AA;">${inp}${tail}</span></div>`;
}
if (kind === 'tool_result') {
const isError = p.isError === true;
const head = isError ? '<span style="color:#FF6B6B;">✗ result (ERROR)</span>' : '<span style="color:#34C759;">✓ result</span>';
const tail = p.truncatedBytes ? `<span style="color:#777799;"> ...(+${p.truncatedBytes} bytes)</span>` : '';
return `<div style="color:#9090A0;">${time} ${head}<div style="white-space:pre-wrap;padding-left:14px;border-left:2px solid #2A2A3E;margin-top:2px;">${_ariaEsc(p.content || '')}${tail}</div></div>`;
}
return `<div style="color:#AAAACC;">${time} <span>${_ariaEsc(kind)}: ${_ariaEsc(JSON.stringify(p).slice(0, 500))}</span></div>`;
}
function ariaPanicStop() {
if (!confirm('Wirklich NOT-AUS? Alle aktiven Claude-Subprocesses werden sofort gekillt.')) return;
send({ action: 'aria_panic_stop' });
@@ -5454,6 +5608,9 @@
loadThoughtStream();
connectWS();
// ARIA-Live ist beim Page-Load schon der aktive Sub-Tab.
// History gleich nach Seitenstart laden damit Browser-Reload nichts verliert.
loadAriaStreamHistory();
</script>
</body>
</html>
+99
View File
@@ -29,6 +29,40 @@ const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true";
const RVS_TOKEN = process.env.RVS_TOKEN || "";
const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456";
// ── Persistenz fuer agent_stream-Events ──────────────────
// Jeder agent_stream-Event wird parallel zum Broadcast in eine .jsonl
// geschrieben. Live-View laedt beim Tab-Oeffnen die letzten ~200 Zeilen,
// damit Browser-Reload / Standby den Verlauf nicht wegwerfen. Rotation
// haendelt logrotate / manual cleanup — wir cappen hier nur weichweich.
const AGENT_STREAM_LOG = process.env.AGENT_STREAM_LOG || "/shared/logs/agent_stream.jsonl";
const AGENT_STREAM_MAX_BYTES = 50 * 1024 * 1024; // 50 MB → halten den File handlebar
function appendAgentStream(payload) {
if (!payload || typeof payload !== "object") return;
try {
const line = JSON.stringify({ ts: Date.now(), ...payload }) + "\n";
// Soft-Cap: bei >50 MB ein Truncate auf den letzten ~25 MB Inhalt
try {
const st = fs.statSync(AGENT_STREAM_LOG);
if (st.size > AGENT_STREAM_MAX_BYTES) {
const half = Math.floor(AGENT_STREAM_MAX_BYTES / 2);
const fd = fs.openSync(AGENT_STREAM_LOG, "r");
const buf = Buffer.alloc(half);
fs.readSync(fd, buf, 0, half, st.size - half);
fs.closeSync(fd);
// bis zum naechsten Newline springen damit wir keine halbe Zeile haben
const firstNl = buf.indexOf(0x0a);
const start = firstNl >= 0 ? firstNl + 1 : 0;
fs.writeFileSync(AGENT_STREAM_LOG, buf.slice(start));
}
} catch {}
// Verzeichnis sicherstellen
try { fs.mkdirSync(path.dirname(AGENT_STREAM_LOG), { recursive: true }); } catch {}
fs.appendFileSync(AGENT_STREAM_LOG, line);
} catch (e) {
// Schweigend ignorieren — Persistence darf den Stream nicht blockieren
}
}
// ── State ───────────────────────────────────────────────
const state = {
gateway: { status: "disconnected", lastError: null, handshakeOk: false },
@@ -637,6 +671,9 @@ function connectRVS(forcePlain) {
// Voller Live-Stream der Claude-Code-Session (assistant_text +
// tool_use mit Input + tool_result mit truncated Output). Geht
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
// Zusaetzlich persistieren damit Browser-Reload / Standby den
// History-Verlauf nicht wegwirft.
try { appendAgentStream(msg.payload); } catch {}
broadcast({ type: "agent_stream", payload: msg.payload });
} else if (msg.type === "memory_saved") {
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
@@ -1714,6 +1751,68 @@ const server = http.createServer((req, res) => {
});
req.pipe(proxyReq);
return;
} else if (req.url.startsWith("/api/chat-backup") && req.method === "GET") {
// Tail des chat_backup.jsonl — fuer Debug-Sessions (was hat ARIA wirklich
// gesagt/getan). ?lines=N (Default 200, Max 5000).
try {
const u = new URL(req.url, "http://localhost");
const lines = Math.max(1, Math.min(5000, parseInt(u.searchParams.get("lines") || "200", 10) || 200));
const file = "/shared/config/chat_backup.jsonl";
let raw = "";
try { raw = fs.readFileSync(file, "utf-8"); } catch {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, file, lines: [] }));
}
const all = raw.split("\n").filter(l => l.trim());
const tail = all.slice(-lines);
const parsed = tail.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, file, count: parsed.length, total: all.length, lines: parsed }));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: false, error: e.message }));
}
} else if (req.url.startsWith("/api/agent-stream") && req.method === "GET") {
// Tail / paginierter Slice des persistierten agent_stream.jsonl.
// Modi:
// ?lines=N → letzte N Zeilen (Live-View Initial-Load)
// ?page=P&perPage=M → 1-indexed Pagination (Modal-Browser);
// page=1 = neueste Seite, hoehere Pages = aelter
try {
const u = new URL(req.url, "http://localhost");
const linesParam = u.searchParams.get("lines");
const pageParam = u.searchParams.get("page");
const perPageParam = u.searchParams.get("perPage");
const file = AGENT_STREAM_LOG;
let raw = "";
try { raw = fs.readFileSync(file, "utf-8"); } catch {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, file, total: 0, lines: [] }));
}
const all = raw.split("\n").filter(l => l.trim());
let slice, page = 1, perPage = 0, pagesTotal = 1;
if (pageParam || perPageParam) {
perPage = Math.max(10, Math.min(5000, parseInt(perPageParam || "100", 10) || 100));
pagesTotal = Math.max(1, Math.ceil(all.length / perPage));
page = Math.max(1, Math.min(pagesTotal, parseInt(pageParam || "1", 10) || 1));
// page=1 = juengste Seite → vom Ende her slicen
const end = all.length - (page - 1) * perPage;
const start = Math.max(0, end - perPage);
slice = all.slice(start, end);
} else {
const lines = Math.max(1, Math.min(5000, parseInt(linesParam || "200", 10) || 200));
slice = all.slice(-lines);
}
const parsed = slice.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({
ok: true, file, total: all.length, count: parsed.length,
page, perPage, pagesTotal, lines: parsed,
}));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: false, error: e.message }));
}
} else if (req.url === "/api/brain-export" && req.method === "GET") {
// Komplettes Gehirn als tar.gz streamen.
// Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten.