Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 210ce62ffe | |||
| 298b2202a1 | |||
| 845a8b0020 | |||
| 0540c49c66 | |||
| add303970b | |||
| fb71048dfd | |||
| aaaf118cb7 |
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {})
|
||||
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user