From 0540c49c661fbd7253fb3f51569b464df3e5d0e3 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 00:02:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(brain):=20skill=5Fscaffold=20=E2=80=94=20T?= =?UTF-8?q?emplates=20statt=20Skill=20aus=20dem=20Nichts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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_ 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) --- README.md | 20 +- aria-brain/agent.py | 70 ++++++ aria-brain/main.py | 26 ++ aria-brain/seed_rules.py | 31 +++ aria-brain/skill_templates.py | 460 ++++++++++++++++++++++++++++++++++ aria-brain/skills.py | 33 +++ 6 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 aria-brain/skill_templates.py diff --git a/README.md b/README.md index e324e86..5b6db54 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ Chat-Turn an die richtigen Patterns. ### Skill-Regeln (seed_rules) -`aria-brain/seed_rules.py` enthaelt 10 `type=rule, pinned=true, +`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: @@ -402,8 +402,26 @@ jedem Chat-Turn im Hot-Memory-Block auf: - **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_` …), nicht via Bash. SSH zur VM-Host via `ssh aria@host` (Key liegt im Proxy) +- **scaffold-reflex** — wenn dieselbe externe API 2× via Bash-curl angerufen wurde: SOFORT `skill_scaffold` aufrufen. Niedrige Hürde (Template statt vollständiger Skill-Code) → höhere Adoption - **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_` 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, diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 7d81cd7..bf97a0d 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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_ 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": { @@ -945,6 +986,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: diff --git a/aria-brain/main.py b/aria-brain/main.py index c55e56f..4240c2e 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -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: diff --git a/aria-brain/seed_rules.py b/aria-brain/seed_rules.py index 1454684..ba464e4 100644 --- a/aria-brain/seed_rules.py +++ b/aria-brain/seed_rules.py @@ -208,6 +208,37 @@ SEED_RULES: List[dict] = [ "Brain-Resources: erst denken, sonst Brain-Tool nehmen." ), }, + { + "migration_key": "seed/skill-rule/scaffold-reflex", + "type": "rule", + "title": "Skill-Regel: skill_scaffold-Reflex bei wiederholten API-Calls", + "category": "skills", + "content": ( + "Wenn Du dieselbe externe API in einer Session 2x oder oefter " + "via Bash-curl angerufen hast: HOER AUF und rufe SOFORT " + "`skill_scaffold` auf. Drei Spotify-Calls hintereinander, " + "drei Wetter-Abfragen, drei Calendar-Events — JEDES dieser " + "Pattern schreit nach einem Skill.\n" + "\n" + "skill_scaffold ist die niedrige Hürde: Du musst keinen " + "kompletten Python-Skill schreiben. Du waehlst nur:\n" + " - oauth-api fuer OAuth2-Services (Spotify, GitHub, Reddit, " + "Google, Discord) -- Token kommt vom Brain mit Auto-Refresh\n" + " - apikey-api fuer statische Keys (OpenWeather, OpenAI, " + "Twilio) -- Key landet im config_schema, Stefan setzt ihn in " + "Diagnostic\n" + " - file-process fuer Datei-In/Datei-Out (PDF, Bild, Daten)\n" + "\n" + "Brain expandiert das Template in ~1s zu einem fertigen Skill. " + "Falls Du was Spezielles brauchst: erst Scaffold, dann " + "`skill_update` mit Anpassung. NICHT umgekehrt — und schon gar " + "nicht das fuenfte Mal das gleiche Bash-Skript bauen.\n" + "\n" + "Belohnung: ein Spotify-Skill macht 'welches lied laeuft' in " + "1 Tool-Call (~3s) statt 5 Bash-Roundtrips (~20s). Stefan " + "merkt das sofort und ist zufriedener." + ), + }, { "migration_key": "seed/skill-rule/external-api-auth-strategy", "type": "rule", diff --git a/aria-brain/skill_templates.py b/aria-brain/skill_templates.py new file mode 100644 index 0000000..eb4471d --- /dev/null +++ b/aria-brain/skill_templates.py @@ -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_): + 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_ 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: .{{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 | `.{{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 .{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_ 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 {}) diff --git a/aria-brain/skills.py b/aria-brain/skills.py index a2efbd6..eb5d465 100644 --- a/aria-brain/skills.py +++ b/aria-brain/skills.py @@ -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():