"""Web UI for HiveMind — Chat, Admin, Training, Sessions."""
from __future__ import annotations

import asyncio
import json
import logging
import base64
from pathlib import Path
from typing import Any

from fastapi import FastAPI, WebSocket, WebSocketDisconnect, UploadFile, File, Form
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse

log = logging.getLogger(__name__)

# ── Community: anonymisiertes Query-Tracking (fire-and-forget) ──────────────
def _community_track_query_bg(node: Any, meta: dict) -> None:
    """Sendet das erste Thema aus `meta` asynchron ans Community-API."""
    topics = meta.get("topics") if isinstance(meta, dict) else None
    topic  = (topics[0] if topics else None) or meta.get("intent", "") if meta else ""
    topic  = str(topic).strip().lower()[:100]
    if not topic or topic in {"", "none", "unknown"}:
        return

    async def _post():
        try:
            import httpx
            relay = (getattr(node.config.network, 'relay_servers', None) or ['https://hive.1seele.de'])[0]
            url   = relay.rstrip('/') + '/community.php?action=track-query'
            async with httpx.AsyncClient(timeout=5.0) as client:
                await client.post(url, json={"topic": topic})
        except Exception:
            pass

    try:
        loop = asyncio.get_event_loop()
        if loop.is_running():
            asyncio.create_task(_post())
    except Exception:
        pass

# Load HTML from file or fallback
_HTML_PATH = Path(__file__).parent / "dashboard.html"

# ── Global SSE event bus ─────────────────────────────────────────────
# Jeder /api/events-Client hält eine asyncio.Queue. broadcast_event() legt
# Ereignisse in alle Queues — wird von telegram.py aufgerufen.
_sse_clients: set[asyncio.Queue] = set()


async def broadcast_event(event: dict) -> None:
    """Sende ein Ereignis an alle verbundenen Dashboard-Clients."""
    payload = "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
    dead: list[asyncio.Queue] = []
    for q in list(_sse_clients):
        try:
            q.put_nowait(payload)
        except asyncio.QueueFull:
            dead.append(q)
    for q in dead:
        _sse_clients.discard(q)


def _marketplace_url(node) -> str:
    """Marketplace-Basis-URL: zuerst config.network.marketplace_url, dann relay_servers[0]."""
    cfg_url = getattr(node.config.network, 'marketplace_url', '') or ''
    if cfg_url:
        return cfg_url.rstrip('/')
    relay = (getattr(node.config.network, 'relay_servers', None) or ['https://hive.1seele.de'])[0]
    return relay.rstrip('/')


def _safe_r_json(r, fallback=None):
    """Parst die JSON-Antwort eines httpx-Response sicher.
    Gibt ``fallback`` zurück, wenn der Body leer oder kein gültiges JSON ist.
    """
    text = (r.text or "").strip()
    if not text:
        return fallback if fallback is not None else {}
    try:
        return r.json()
    except Exception:
        return fallback if fallback is not None else {}


def create_web_app(node: Any, private_key_path: Path | None = None) -> FastAPI:
    """Create the web UI application."""
    app = FastAPI(title=f"HiveMind: {node.name}")

    @app.get("/", response_class=HTMLResponse)
    async def index():
        if _HTML_PATH.exists():
            return _HTML_PATH.read_text(encoding="utf-8")
        return "<h1>dashboard.html not found</h1>"

    @app.get("/assets/{filename}")
    async def serve_asset(filename: str):
        from fastapi.responses import FileResponse
        import mimetypes
        asset_dir = Path(__file__).parent / "assets"
        path = asset_dir / filename
        if not path.exists() or not path.is_file():
            from fastapi import HTTPException
            raise HTTPException(status_code=404, detail="Asset not found")
        mt, _ = mimetypes.guess_type(str(path))
        return FileResponse(str(path), media_type=mt or "application/octet-stream")

    # ─── API: Status ─────────────────────────────────────────────
    @app.get("/api/status")
    async def status():
        return node.status

    @app.get("/api/activity")
    async def activity_status():
        """Live-Aktivitätsstatus für die Dashboard-Statusseite."""
        from pathlib import Path as _Path
        act = dict(getattr(node, "_activity", {}))
        large_path = node.config.model.path or ""
        gw = getattr(node, "_telegram_gateway", None)
        return {
            "activity": act,
            "models": {
                "large": {
                    "name": _Path(large_path).name if large_path else "",
                    "loaded": node.model.loaded,
                },
                "small": {
                    "name": "",
                    "loaded": False,
                    "configured": False,
                },
            },
            "telegram": {
                "configured": bool(node.config.gateway.telegram.token),
                "enabled": node.config.gateway.telegram.enabled,
                "running": bool(gw and gw.running),
            },
        }

    @app.get("/api/events")
    async def events():
        """Server-Sent Events stream — liefert Live-Updates an das Dashboard.

        Ereignis-Typen:
          {"type": "session_update", "session_id": "tg-..."}  ← neue Telegram-Nachricht
          {"type": "ping"}                                    ← Keepalive alle 25 s
        """
        q: asyncio.Queue = asyncio.Queue(maxsize=32)
        _sse_clients.add(q)

        async def _stream():
            try:
                # Keepalive-Task: verhindert Browser-Timeout
                async def _ping():
                    while True:
                        await asyncio.sleep(25)
                        try:
                            q.put_nowait("data: {\"type\":\"ping\"}\n\n")
                        except asyncio.QueueFull:
                            break
                ping_task = asyncio.create_task(_ping())
                try:
                    while True:
                        payload = await q.get()
                        yield payload
                finally:
                    ping_task.cancel()
            finally:
                _sse_clients.discard(q)

        return StreamingResponse(_stream(), media_type="text/event-stream",
                                  headers={"Cache-Control": "no-cache",
                                           "X-Accel-Buffering": "no"})

    @app.post("/api/chat")
    async def chat(data: dict):
        msg = data.get("message", "")
        if not msg:
            return {"response": ""}
        try:
            node.set_activity(
                last="Web-Chat-Anfrage erhalten",
                current="Modell generiert Antwort\u2026",
                next_="Antwort an Browser senden",
                source="web",
            )
        except Exception:
            pass
        response_text, meta = await node.chat(msg)
        try:
            node.set_activity(last="Antwort an Browser gesendet", current=None, next_=None)
        except Exception:
            pass
        # ── Anonymisiertes Query-Tracking ──────────────────────────────────
        try:
            _community_track_query_bg(node, meta)
        except Exception:
            pass
        # ── Tagebuch: Chat-Aktivität mitschreiben ──────────────────────────
        try:
            _topic = ""
            if isinstance(meta, dict):
                _topics = meta.get("topics")
                _topic = (_topics[0] if _topics else None) or meta.get("intent") or ""
            _diary_append(
                "chat",
                node.config.node.name or "Assistent",
                f"Web-Chat beantwortet{(': ' + str(_topic)[:60]) if _topic else ''}.",
            )
        except Exception:
            pass
        return {"response": response_text, "meta": meta}

    @app.post("/api/chat/stream")
    async def chat_stream(data: dict):
        """Two-phase streaming chat endpoint (SSE).

        Yields Server-Sent Events:
          data: {"type": "fast", "text": "...", "meta": {...}}   ← immediate
          data: {"type": "full", "text": "...", "meta": {...}}   ← detailed
          data: {"type": "done"}
        """
        msg = data.get("message", "")
        session_id = data.get("session_id", "")
        if not msg:
            async def _empty():
                yield "data: {\"type\": \"done\"}\n\n"
            return StreamingResponse(_empty(), media_type="text/event-stream")

        return StreamingResponse(
            node.chat_streaming(msg, session_id=session_id),
            media_type="text/event-stream",
            headers={
                "Cache-Control": "no-cache",
                "X-Accel-Buffering": "no",   # disable nginx buffering if proxied
            },
        )

    @app.post("/api/command")
    async def command(data: dict):
        cmd = data.get("command", "")
        if cmd == "clear":
            # Clear active session history
            s = node.sessions.active
            s.messages.clear()
            node.sessions.save_active()
            return {"result": "Chat geleert."}
        elif cmd == "status":
            return {"result": json.dumps(node.status, indent=2, ensure_ascii=False)}
        elif cmd == "plugins":
            caps = [{"plugin": c.plugin, "name": c.name, "desc": c.description}
                    for c in node.plugins.all_capabilities()]
            return {"result": json.dumps(caps, indent=2, ensure_ascii=False)}
        elif cmd == "stop":
            import asyncio as _asyncio
            async def _request_stop():
                await _asyncio.sleep(0.3)  # HTTP-Antwort zuerst ausliefern
                log.info("/stop-Befehl empfangen – beende HiveMind sauber.")
                stop_ev = getattr(node, "_stop_event", None)
                if stop_ev is not None:
                    stop_ev.set()
                else:
                    # Fallback falls cli.py kein _stop_event gesetzt hat
                    import os, signal as _signal
                    os.kill(os.getpid(), _signal.SIGINT)
            _asyncio.create_task(_request_stop())
            return {"result": "HiveMind wird sauber beendet..."}
        elif cmd == "help":
            return {"result": (
                "Befehle:\\n"
                "  /help      - Diese Hilfe\\n"
                "  /status    - Node-Status\\n"
                "  /plugins   - Geladene Plugins\\n"
                "  /peers     - Verbundene Peers\\n"
                "  /addpeer host:port - Peer hinzufuegen\\n"
                "  /clear     - Chat leeren\\n"
                "  /memory    - Gespeicherte Fakten\\n"
                "  /stop      - HiveMind sauber beenden"
            )}
        elif cmd == "memory":
            facts = node.memory.all_facts
            custom = node.memory.all_custom
            if not facts and not custom:
                return {"result": "Kein Memory gespeichert."}
            lines = ["Gespeicherte Fakten:"]
            for k, v in facts.items():
                lines.append(f"  {k}: {v}")
            if custom:
                lines.append("Notizen:")
                for n in custom:
                    lines.append(f"  - {n}")
            return {"result": "\\n".join(lines)}
        elif cmd == "peers":
            if not node.network:
                return {"result": "Netzwerk nicht aktiviert."}
            peers = [{"name": p.name or p.node_id, "address": p.address,
                       "online": p.online, "hours_ago": round(p.hours_since_seen, 1)}
                     for p in node.network.peers.all_peers]
            return {"result": json.dumps(peers, indent=2, ensure_ascii=False)}
        elif cmd.startswith("addpeer "):
            addr = cmd.split(" ", 1)[1].strip()
            try:
                from hivemind.network.protocol import PeerInfo as _PI
                host, port = _PI.parse_address(addr)
                if node.network:
                    node.network.peers.add_manual(host, port)
                    import asyncio
                    asyncio.create_task(node.network.connect_to(host, int(port)))
                    return {"result": f"Peer hinzugefuegt: {addr}"}
                return {"result": "Netzwerk nicht aktiviert."}
            except Exception as e:
                return {"result": f"Fehler: {e}"}
        return {"result": f"Unbekannter Befehl: {cmd}"}

    # ─── API: Peers (strukturiert) ─────────────────────────────
    @app.get("/api/peers")
    async def list_peers():
        if not node.network:
            return {"peers": []}
        peers = [
            {
                "id": p.address,           # interner Schlüssel, nicht angezeigt
                "name": p.name or p.node_id or p.address,
                "specialization": getattr(p, "specialization", "") or "",
                "expertise_tags": getattr(p, "expertise_tags", []) or [],
                "online": p.online,
                "hours_ago": round(p.hours_since_seen, 1),
            }
            for p in node.network.peers.all_peers
        ]
        # Relay-Nodes ergänzen (nur anzeigen, nicht doppelt wenn schon per P2P verbunden)
        p2p_names = {p["name"] for p in peers}
        if node.relay and node.relay.connected:
            for rn in node.relay.relay_nodes:
                rname = rn.get("name", rn.get("node_id", ""))
                if rname and rname not in p2p_names:
                    peers.append({
                        "id": None,   # Relay-Node: kein manuelles Entfernen möglich
                        "name": rname,
                        "specialization": rn.get("info", {}).get("specialization", ""),
                        "expertise_tags": rn.get("info", {}).get("expertise_tags", []),
                        "online": True,
                        "hours_ago": 0,
                        "relay_only": True,
                    })
        return {"peers": peers}

    @app.post("/api/peers/remove")
    async def remove_peer(data: dict):
        addr = data.get("id", "")
        if not addr or not node.network:
            return {"success": False, "error": "Ungültige Anfrage"}
        ok = node.network.peers.remove(addr)
        return {"success": ok}

    # ─── API: Plugins ─────────────────────────────────────────────

    def _plugin_dir() -> Path:
        raw = node.config.plugins.get("directory", "./plugins")
        raw = raw.lstrip("./")
        return node.base_dir / (raw or "plugins")

    @app.get("/api/plugins")
    async def list_plugins():
        enabled_names: list = node.config.plugins.get("enabled") or []
        builtin = [node.plugins.get_status(n, enabled_names) for n in node.plugins.BUILTIN]
        community = node.plugins.installed_community(str(_plugin_dir()))
        for p in community:
            p["enabled"] = p["name"] in enabled_names
        return {"builtin": builtin, "community": community}

    @app.post("/api/plugins/toggle")
    async def toggle_plugin(data: dict):
        import yaml as _yaml
        name = data.get("name", "")
        enabled = bool(data.get("enabled", False))
        if not name:
            return JSONResponse({"error": "Name fehlt"}, 400)
        config_path = node.base_dir / "config.yaml"
        cfg: dict = {}
        if config_path.exists():
            cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        cfg.setdefault("plugins", {})
        enabled_list: list = list(cfg["plugins"].get("enabled") or
                                  node.config.plugins.get("enabled") or [])
        pdir = str(_plugin_dir())
        if enabled:
            if name not in enabled_list:
                enabled_list.append(name)
            await node.plugins.enable_live(name, pdir)
        else:
            enabled_list = [n for n in enabled_list if n != name]
            await node.plugins.disable_live(name)
        cfg["plugins"]["enabled"] = enabled_list
        node.config.plugins["enabled"] = enabled_list
        config_path.write_text(_yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        # Tagebuch: Plugin-Statusänderung
        try:
            action = "aktiviert" if enabled else "deaktiviert"
            _diary_append("system", node.config.node.name or "System",
                          f"Plugin '{name}' {action}.")
        except Exception:
            pass
        return {"success": True, "loaded": node.plugins.loaded}

    @app.post("/api/plugins/install")
    async def install_plugin(data: dict):
        import yaml as _yaml
        slug = data.get("slug", "")
        download_url = data.get("download_url", "")
        if not slug or not download_url:
            return JSONResponse({"error": "slug und download_url erforderlich"}, 400)
        pdir = str(_plugin_dir())
        ok, error = await node.plugins.install_from_url(slug, download_url, pdir)
        if not ok:
            return JSONResponse({"error": error}, 400)
        # Persist and enable live
        config_path = node.base_dir / "config.yaml"
        cfg: dict = {}
        if config_path.exists():
            cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        cfg.setdefault("plugins", {})
        enabled_list: list = list(cfg["plugins"].get("enabled",
                                  node.config.plugins.get("enabled", [])))
        if slug not in enabled_list:
            enabled_list.append(slug)
        cfg["plugins"]["enabled"] = enabled_list
        node.config.plugins["enabled"] = enabled_list
        config_path.write_text(_yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        await node.plugins.enable_live(slug, pdir)
        # Notify marketplace of download (best-effort)
        try:
            import json as _json
            from urllib.request import urlopen, Request as _Req
            relay = _marketplace_url(node)
            _req = _Req(f"{relay}/marketplace/api.php",
                        data=_json.dumps({"action": "download", "slug": slug}).encode(),
                        headers={"Content-Type": "application/json", "User-Agent": "HiveMind"},
                        method="POST")
            urlopen(_req, timeout=5)
        except Exception:
            pass
        # Tagebuch: Plugin installiert
        try:
            _diary_append("system", node.config.node.name or "System",
                          f"Plugin '{slug}' installiert und aktiviert.")
        except Exception:
            pass
        return {"success": True}

    @app.post("/api/plugins/uninstall")
    async def uninstall_plugin(data: dict):
        import yaml as _yaml
        name = data.get("name", "")
        if not name:
            return JSONResponse({"error": "Name fehlt"}, 400)
        if name in node.plugins.BUILTIN:
            return JSONResponse({"error": "Built-in Plugins können nicht deinstalliert werden"}, 400)
        await node.plugins.disable_live(name)
        config_path = node.base_dir / "config.yaml"
        cfg: dict = {}
        if config_path.exists():
            cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        cfg.setdefault("plugins", {})
        cfg["plugins"]["enabled"] = [n for n in cfg["plugins"].get("enabled", []) if n != name]
        node.config.plugins["enabled"] = cfg["plugins"]["enabled"]
        config_path.write_text(_yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        node.plugins.uninstall_community(name, str(_plugin_dir()))
        # Tagebuch: Plugin deinstalliert
        try:
            _diary_append("system", node.config.node.name or "System",
                          f"Plugin '{name}' deinstalliert.")
        except Exception:
            pass
        return {"success": True}

    @app.get("/api/marketplace")
    async def marketplace_list(q: str = "", tag: str = "", page: int = 1):
        relay = _marketplace_url(node)
        params = {"action": "list", "q": q, "tag": tag, "page": page, "per_page": 12}
        try:
            import httpx
            async with httpx.AsyncClient(timeout=12.0, headers={"User-Agent": "HiveMind"}) as client:
                r = await client.get(f"{relay}/marketplace/api.php", params=params)
                return JSONResponse(_safe_r_json(r, {"plugins": [], "total": 0, "tags": []}))
        except Exception as e:
            return JSONResponse({"error": str(e), "plugins": [], "total": 0, "tags": []})

    @app.post("/api/marketplace/submit")
    async def marketplace_submit(data: dict):
        relay = _marketplace_url(node)
        timeout = 60.0 if "zip_b64" in data else 15.0
        try:
            import httpx
            async with httpx.AsyncClient(timeout=timeout, headers={"User-Agent": "HiveMind"}) as client:
                r = await client.post(f"{relay}/marketplace/api.php",
                                      json={**data, "action": "submit"})
                return JSONResponse(_safe_r_json(r, {}))
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    # ─── API: Sessions ─────────────────────────────────────
    @app.get("/api/sessions")
    async def list_sessions():
        return {"sessions": node.sessions.list_sessions(), "active": node.sessions.active_id}

    @app.post("/api/sessions/create")
    async def create_session(data: dict = None):
        name = (data or {}).get("name", "")
        s = node.sessions.create(name)
        return {"session": s.summary_info}

    @app.post("/api/sessions/switch")
    async def switch_session(data: dict):
        s = node.sessions.switch(data.get("id", ""))
        if s:
            return {"session": s.summary_info, "messages": s.messages}
        return JSONResponse({"error": "Session nicht gefunden"}, 404)

    @app.post("/api/sessions/rename")
    async def rename_session(data: dict):
        ok = node.sessions.rename(data.get("id", ""), data.get("name", ""))
        return {"success": ok}

    @app.delete("/api/sessions/{session_id}")
    async def delete_session(session_id: str):
        ok = node.sessions.delete(session_id)
        return {"success": ok}

    @app.get("/api/sessions/history")
    async def session_history():
        return {"messages": node.sessions.get_history(), "session": node.sessions.active.summary_info}

    # ─── API: Memory ─────────────────────────────────────────────
    @app.get("/api/memory")
    async def get_memory():
        return {"facts": node.memory.all_facts, "custom": node.memory.all_custom, "stats": node.memory.stats}

    @app.post("/api/memory/fact")
    async def add_memory_fact(data: dict):
        node.memory.add_fact(data.get("category", ""), data.get("value", ""))
        return {"success": True}

    @app.delete("/api/memory/fact/{category}")
    async def del_memory_fact(category: str):
        return {"success": node.memory.remove_fact(category)}

    @app.post("/api/memory/custom")
    async def add_memory_custom(data: dict):
        node.memory.add_custom(data.get("note", ""))
        return {"success": True}

    @app.delete("/api/memory/custom/{index}")
    async def del_memory_custom(index: int):
        return {"success": node.memory.remove_custom(index)}

    # ─── API: Model Info & Ressourcen ────────────────────────────
    @app.get("/api/model-info")
    async def model_info():
        import os, math
        from pathlib import Path as _P

        result: dict = {}

        # ── Modelldatei ─────────────────────────────────────────
        model_path = node.config.model.path or ""
        model_name = _P(model_path).name if model_path else ""
        model_size_bytes = 0
        try:
            p = _P(model_path)
            if not p.is_absolute():
                p = node.base_dir / p
            model_size_bytes = p.stat().st_size if p.exists() else 0
        except Exception:
            pass

        result["model"] = {
            "name": model_name,
            "path": model_path,
            "size_bytes": model_size_bytes,
            "loaded": node.model.loaded,
            "n_ctx": node.config.model.n_ctx,
            "n_gpu_layers": node.config.model.n_gpu_layers,
            "n_threads": node.config.model.n_threads,
        }

        # ── GGUF-Metadaten (n_layers, n_heads usw.) ──────────────
        meta_raw: dict = {}
        n_layers = 0; n_heads = 0; n_kv_heads = 0; head_dim = 0
        try:
            llm = node.model._llm
            if llm is not None:
                meta_raw = dict(llm.metadata or {})
                n_layers   = int(meta_raw.get("llama.block_count", 0))
                n_heads    = int(meta_raw.get("llama.attention.head_count", 0))
                n_kv_heads = int(meta_raw.get("llama.attention.head_count_kv", 0) or n_heads)
                emb_len    = int(meta_raw.get("llama.embedding_length", 0))
                head_dim   = emb_len // n_heads if n_heads else 0
        except Exception:
            pass

        # Schätzung falls Metadaten nicht verfügbar (bekannte Qwen-Werte)
        if not n_layers and "Qwen2.5-7B" in model_name:
            n_layers = 28; n_heads = 28; n_kv_heads = 4; head_dim = 128

        result["gguf"] = {
            "n_layers": n_layers,
            "n_heads": n_heads,
            "n_kv_heads": n_kv_heads,
            "head_dim": head_dim,
            "metadata": {k: v for k, v in meta_raw.items()
                         if any(k.startswith(p) for p in
                                ("llama.", "general.", "tokenizer."))
                         and "data" not in k},
        }

        # ── KV-Cache-Formel ──────────────────────────────────────
        # kv_bytes = n_ctx × n_layers × 2 × n_kv_heads × head_dim × 2 (float16)
        def kv_bytes(ctx: int) -> int:
            if not (n_layers and n_kv_heads and head_dim):
                return 0
            return ctx * n_layers * 2 * n_kv_heads * head_dim * 2

        result["kv_cache"] = {
            "formula": "n_ctx × n_layers × 2 × n_kv_heads × head_dim × 2 Bytes",
            "current": kv_bytes(node.config.model.n_ctx),
            "presets": {
                str(ctx): kv_bytes(ctx)
                for ctx in (1024, 2048, 4096, 8192, 16384, 32768)
            },
        }

        # ── System-Ressourcen ────────────────────────────────────
        ram_total = 0; ram_avail = 0; ram_used_proc = 0
        try:
            import psutil
            vm = psutil.virtual_memory()
            ram_total = vm.total
            ram_avail = vm.available
            proc = psutil.Process(os.getpid())
            ram_used_proc = proc.memory_info().rss
        except Exception:
            pass

        vram_total = 0; vram_used = 0; vram_name = ""; vram_note = ""
        # ── 1) NVIDIA via pynvml ──────────────────────────────────────────────
        try:
            import pynvml
            pynvml.nvmlInit()
            h = pynvml.nvmlDeviceGetHandleByIndex(0)
            mi = pynvml.nvmlDeviceGetMemoryInfo(h)
            vram_total = mi.total; vram_used = mi.used
            vram_name  = pynvml.nvmlDeviceGetName(h)
            if isinstance(vram_name, bytes):
                vram_name = vram_name.decode()
        except Exception:
            pass
        # ── 2) NVIDIA via nvidia-smi (Fallback) ──────────────────────────────
        if not vram_name:
            try:
                import subprocess
                out = subprocess.check_output(
                    ["nvidia-smi", "--query-gpu=name,memory.total,memory.used",
                     "--format=csv,noheader,nounits"], timeout=3,
                    stderr=subprocess.DEVNULL).decode()
                parts = [x.strip() for x in out.strip().split(",")]
                if len(parts) >= 3:
                    vram_name  = parts[0]
                    vram_total = int(parts[1]) * 1024 * 1024
                    vram_used  = int(parts[2]) * 1024 * 1024
            except Exception:
                pass
        # ── 3) AMD via rocm-smi (Linux & Windows mit ROCm-Treiber) ───────────
        if not vram_name:
            try:
                import subprocess, json as _json
                out = subprocess.check_output(
                    ["rocm-smi", "--showmeminfo", "vram", "--json"], timeout=5,
                    stderr=subprocess.DEVNULL).decode()
                data = _json.loads(out)
                # Format: {"card0": {"VRAM Total Memory (B)": "...", "VRAM Total Used Memory (B)": "..."}, ...}
                card = next(iter(data.values()))
                vram_total = int(card.get("VRAM Total Memory (B)", 0))
                vram_used  = int(card.get("VRAM Total Used Memory (B)", 0))
                # GPU-Name per separatem Aufruf
                try:
                    name_raw = subprocess.check_output(
                        ["rocm-smi", "--showproductname", "--json"], timeout=3,
                        stderr=subprocess.DEVNULL).decode()
                    nd = _json.loads(name_raw)
                    nc = next(iter(nd.values()))
                    vram_name = nc.get("Card Series", "") or nc.get("Card Model", "") or "AMD GPU (ROCm)"
                except Exception:
                    vram_name = "AMD GPU (ROCm)"
            except Exception:
                pass
        # ── 4) AMD via Linux sysfs (kein ROCm nötig) ─────────────────────────
        if not vram_name:
            try:
                import glob
                for card_path in sorted(glob.glob("/sys/class/drm/card*/device")):
                    t_path = card_path + "/mem_info_vram_total"
                    u_path = card_path + "/mem_info_vram_used"
                    if not (os.path.exists(t_path) and os.path.exists(u_path)):
                        continue
                    vram_total = int(open(t_path).read().strip())
                    vram_used  = int(open(u_path).read().strip())
                    # GPU-Name aus sysfs
                    pn = card_path + "/product_name"
                    if os.path.exists(pn):
                        vram_name = open(pn).read().strip()
                    else:
                        vram_name = "AMD GPU (" + os.path.basename(os.path.dirname(card_path)) + ")"
                    break
            except Exception:
                pass
        # ── 5) Windows WMI – alle Hersteller (nur Total; Used = 0) ───────────
        if not vram_name:
            try:
                import subprocess
                out = subprocess.check_output(
                    ["wmic", "path", "Win32_VideoController",
                     "get", "Name,AdapterRAM", "/format:csv"],
                    timeout=5, stderr=subprocess.DEVNULL).decode(errors="replace")
                for line in out.splitlines():
                    parts = [p.strip() for p in line.split(",")]
                    # csv-Header: Node,AdapterRAM,Name  oder  Node,Name,AdapterRAM
                    if len(parts) < 3 or parts[0].lower() in ("", "node"):
                        continue
                    # Felder je nach WMI-Ausgabereihenfolge
                    try:
                        ram_raw = int(parts[1])
                        name    = parts[2]
                    except (ValueError, IndexError):
                        continue
                    if ram_raw <= 0 or not name:
                        continue
                    vram_name  = name
                    vram_total = ram_raw          # ⚠ bei >4 GB oft nur 4294967296 (32-bit-Limit)
                    vram_used  = 0                # WMI liefert kein „Used"
                    # Wenn WMI-Wert am 4-GB-Limit klebt → Hinweis setzen
                    if ram_raw == 4294967296:
                        vram_note = "4 GB (WMI-Limit – tatsächlicher VRAM höher)"
                        vram_total = 4294967296
                    break
            except Exception:
                pass

        result["system"] = {
            "ram_total": ram_total,
            "ram_available": ram_avail,
            "ram_used_by_hivemind": ram_used_proc,
            "vram_total": vram_total,
            "vram_used": vram_used,
            "vram_name": vram_name,
            "vram_note": vram_note,
        }

        return JSONResponse(result)

    @app.post("/api/model-config")
    async def save_model_config(data: dict):
        """Speichert n_ctx und n_gpu_layers in config.yaml.
        Änderungen werden erst nach einem Neustart des Nodes aktiv."""
        import yaml
        n_ctx       = int(data.get("n_ctx", node.config.model.n_ctx))
        n_gpu_layers = int(data.get("n_gpu_layers", node.config.model.n_gpu_layers))
        if n_ctx < 512 or n_ctx > 131072:
            return JSONResponse({"error": "n_ctx muss zwischen 512 und 131072 liegen."}, status_code=400)
        cfg_path = node.base_dir / "config.yaml"
        try:
            raw = cfg_path.read_text(encoding="utf-8")
            cfg = yaml.safe_load(raw) or {}
            if "model" not in cfg:
                cfg["model"] = {}
            cfg["model"]["n_ctx"] = n_ctx
            cfg["model"]["n_gpu_layers"] = n_gpu_layers
            cfg_path.write_text(
                yaml.dump(cfg, allow_unicode=True, default_flow_style=False,
                          sort_keys=True),
                encoding="utf-8"
            )
            node.config.model.n_ctx = n_ctx
            node.config.model.n_gpu_layers = n_gpu_layers
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)
        return JSONResponse({"ok": True, "n_ctx": n_ctx, "n_gpu_layers": n_gpu_layers,
                              "restart_required": True})

    # ─── Favicon ──────────────────────────────────────────────────
    @app.get("/favicon.ico", include_in_schema=False)
    async def favicon():
        # Simple brain emoji SVG as favicon
        svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
               '<text y=".9em" font-size="90">\U0001f9e0</text></svg>')
        return HTMLResponse(content=svg, media_type="image/svg+xml")

    # ─── API: Network Mode (Online/Offline) ────────────────────
    @app.get("/api/network/mode")
    async def network_mode():
        online = bool(node.network or getattr(node, 'relay', None))
        enabled = getattr(getattr(node.config, 'network', None), 'enabled', True)
        return {"online": online, "network_enabled": enabled}

    @app.post("/api/network/mode")
    async def set_network_mode(data: dict):
        import yaml
        go_online = data.get("online", True)
        if go_online and not node.network and not getattr(node, 'relay', None):
            node.config.network.enabled = True
            try:
                from hivemind.network.peerlist import PeerList
                from hivemind.network.peer import P2PNetwork
                from hivemind.network.updater import AutoUpdater
                peers = PeerList(node.base_dir / "data" / "peers.json")
                for addr in (node.config.network.bootstrap_nodes or []):
                    from hivemind.network.protocol import PeerInfo as _PI
                    host, port = _PI.parse_address(addr)
                    peers.add_manual(host, port)
                network = P2PNetwork(
                    node=node, peer_list=peers,
                    listen_port=node.config.network.listen_port,
                )
                updater = AutoUpdater(node, node.base_dir)
                node.updater = updater
                updater.register_handlers(network)
                from hivemind.network.protocol import MsgType
                network.on_message(MsgType.QUERY, node._handle_network_query)
                network.on_message(MsgType.RESPONSE, node._handle_network_response)
                await network.start()
                node.network = network
            except Exception as e:
                log.warning("Failed to start P2P: %s", e)
            relay_urls = getattr(node.config.network, 'relay_servers', []) or []
            if relay_urls and not getattr(node, 'relay', None):
                try:
                    from hivemind.network.relay_client import RelayConnection
                    node.relay = RelayConnection(
                        relay_url=relay_urls[0], node=node,
                        on_message=node._handle_relay_message,
                        on_peers=node._handle_relay_peers,
                    )
                    await node.relay.start()
                except Exception as e:
                    log.warning("Failed to start relay: %s", e)
        elif not go_online:
            node.config.network.enabled = False
            relay = getattr(node, 'relay', None)
            if relay:
                await relay.stop()
                node.relay = None
            if node.network:
                await node.network.stop()
                node.network = None
        # Persist
        config_path = node.base_dir / "config.yaml"
        cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
        cfg = cfg or {}
        cfg.setdefault("network", {})["enabled"] = go_online
        config_path.write_text(yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        return {"online": go_online, "success": True}

    # ─── API: Public IP ──────────────────────────────────────────
    @app.get("/api/network/myaddress")
    async def my_address():
        import asyncio
        from urllib.request import urlopen, Request
        port = node.config.network.listen_port if hasattr(node.config, 'network') else 9420

        def _fetch_ip(url, timeout=5):
            try:
                return urlopen(Request(url, headers={"User-Agent": "HiveMind"}), timeout=timeout).read().decode().strip()
            except Exception:
                return None

        loop = asyncio.get_event_loop()
        # Fetch IPv4 and IPv6 in parallel
        ipv4_task = loop.run_in_executor(None, lambda: _fetch_ip("https://api4.ipify.org"))
        ipv6_task = loop.run_in_executor(None, lambda: _fetch_ip("https://api6.ipify.org"))
        ipv4 = await ipv4_task
        ipv6 = await ipv6_task

        # Format addresses
        from hivemind.network.protocol import PeerInfo as _PI
        result = {"port": port, "ipv4": ipv4 or None, "ipv6": ipv6 or None}
        if ipv4:
            result["address_v4"] = f"{ipv4}:{port}"
        if ipv6:
            result["address_v6"] = _PI.format_address(ipv6, port)
        # Primary address: prefer IPv6 if available (DS-Lite compatibility)
        if ipv6:
            result["address"] = result["address_v6"]
            result["ip"] = ipv6
        elif ipv4:
            result["address"] = result["address_v4"]
            result["ip"] = ipv4
        else:
            result["address"] = "unbekannt"
            result["ip"] = "unbekannt"
        # DS-Lite detection: if IPv4 looks like CGN (100.64.x.x-range or shared)
        if ipv4 and ipv6 and ipv4 != ipv6:
            result["ds_lite"] = True
            result["hint"] = "DS-Lite erkannt: IPv4 ist geteilt (CGN). Nutze die IPv6-Adresse!"
        return result

    # ─── API: Profile ────────────────────────────────────────────
    @app.get("/api/profile")
    async def get_profile():
        return {
            "name": node.name,
            "specialization": node.config.node.specialization,
            "expertise_tags": node.config.node.expertise_tags,
            "persona": node.config.node.persona,
            "model": Path(node.config.model.path).stem if node.config.model.path else "",
        }

    @app.post("/api/profile")
    async def save_profile(data: dict):
        import yaml
        node.config.node.specialization = data.get("specialization", "")
        node.config.node.expertise_tags = data.get("expertise_tags", [])
        node.config.node.persona = data.get("persona", node.config.node.persona)
        from hivemind.confidence import ConfidenceScorer
        node.scorer = ConfidenceScorer(
            expertise_tags=node.config.node.expertise_tags,
            specialization=node.config.node.specialization,
        )
        config_path = node.base_dir / "config.yaml"
        cfg = {}
        if config_path.exists():
            cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        if "node" not in cfg:
            cfg["node"] = {}
        cfg["node"]["specialization"] = node.config.node.specialization
        cfg["node"]["expertise_tags"] = node.config.node.expertise_tags
        cfg["node"]["persona"] = node.config.node.persona
        config_path.write_text(yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        # Relay sofort mit neuen Profildaten aktualisieren
        if node.relay and node.relay.connected:
            try:
                await node.relay.reregister()
            except Exception as _re:
                log.warning("Relay re-register nach Profiländerung fehlgeschlagen: %s", _re)
        return {"success": True}

    # ─── Soul ────────────────────────────────────────────────────
    def _soul_path() -> Path:
        return node.base_dir / "soul.md"

    @app.get("/api/soul")
    async def soul_get():
        import datetime as _dt_soul
        p = _soul_path()
        text = ""
        mtime = ""
        if p.exists():
            text = p.read_text(encoding="utf-8")
            mtime = _dt_soul.datetime.fromtimestamp(p.stat().st_mtime).strftime("%d.%m.%Y %H:%M")
        return JSONResponse({"soul": text, "mtime": mtime})

    @app.post("/api/soul")
    async def soul_save(data: dict):
        text = data.get("soul", "")
        _soul_path().write_text(text, encoding="utf-8")
        _diary_append("system", node.config.node.name or "System",
                      f"Seele manuell bearbeitet ({len(text)} Zeichen).")
        return JSONResponse({"ok": True})

    @app.delete("/api/soul")
    async def soul_reset():
        p = _soul_path()
        if p.exists():
            p.unlink()
        _diary_append("system", node.config.node.name or "System", "Seele zurückgesetzt (geleert).")
        return JSONResponse({"ok": True})

    # ─── API: Training ───────────────────────────────────────────
    @app.get("/api/training/stats")
    async def training_stats():
        return node.training.stats

    @app.post("/api/training/export")
    async def training_export(data: dict = None):
        fmt = (data or {}).get("format", "chatml")
        path = node.training.export_jsonl(format_type=fmt)
        return {"path": str(path), "format": fmt}

    @app.get("/api/training/topics")
    async def training_topics():
        return node.training.topic_analysis()

    @app.get("/api/training/lora")
    async def list_lora():
        return {"adapters": node.training.get_lora_adapters()}

    @app.post("/api/training/lora/upload")
    async def upload_lora(file: UploadFile = File(...)):
        data = await file.read()
        path = node.training.save_lora(file.filename or "adapter.gguf", data)
        return {"success": True, "path": str(path), "size_mb": round(len(data) / 1048576, 1)}

    @app.post("/api/training/ingest-url")
    async def training_ingest_url(data: dict):
        """Fetch an article URL, generate Q&A pairs with the local model,
        save them as training data, and optionally add the article to RAG."""
        url = (data.get("url") or "").strip()
        if not url.startswith("http"):
            return JSONResponse(
                {"error": "Ungültige URL — bitte eine http/https-Adresse eingeben."},
                status_code=400,
            )
        n_pairs = max(3, min(int(data.get("n_pairs") or 10), 30))
        also_rag = bool(data.get("also_rag", True))
        try:
            result = await node.training.ingest_url(url, node.model, n_pairs)
            raw_text = result.pop("raw_text", "")
            if also_rag and raw_text:
                title = result.get("title", "Artikel")
                rag_chunks = node.rag.add_document(
                    f"[Artikel] {title}", content=raw_text
                )
                result["rag_chunks"] = rag_chunks
            return result
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, status_code=400)

    # ─── API: RAG ────────────────────────────────────────────────
    @app.get("/api/rag/documents")
    async def rag_documents():
        return {"documents": node.rag.document_list, "stats": node.rag.stats}

    @app.post("/api/rag/upload")
    async def rag_upload(file: UploadFile = File(...)):
        content = (await file.read()).decode("utf-8", errors="replace")
        name = file.filename or "document.txt"
        chunks = node.rag.add_document(name, content=content)
        return {"success": True, "name": name, "chunks": chunks}

    @app.delete("/api/rag/document/{name}")
    async def rag_delete(name: str):
        return {"success": node.rag.remove_document(name)}

    @app.post("/api/rag/import/huggingface")
    async def rag_import_hf(data: dict):
        url = data.get("url", "")
        if not url:
            return JSONResponse({"error": "URL fehlt"}, 400)
        max_rows = data.get("max_rows", 5000)
        text_fields = data.get("text_fields", None)
        split = data.get("split", "train")
        import asyncio
        result = await asyncio.get_event_loop().run_in_executor(
            None, lambda: node.rag.import_huggingface(url, max_rows, text_fields, split))
        if "error" in result:
            return JSONResponse(result, 400)
        return result

    @app.post("/api/rag/search")
    async def rag_search(data: dict):
        query = data.get("query", "")
        return {"results": node.rag.search(query, top_k=data.get("top_k", 5))}

    # ─── API: Keys & Updates ─────────────────────────────────────
    @app.get("/api/keys")
    async def get_keys():
        from hivemind.network.updater import PUBLISHER_PUBLIC_KEY
        priv = ""
        if private_key_path and private_key_path.exists():
            priv = private_key_path.read_text().strip()
        return {"public_key": PUBLISHER_PUBLIC_KEY, "private_key_available": bool(priv),
                "private_key_path": str(private_key_path) if private_key_path else ""}

    @app.post("/api/update/publish")
    async def publish_update(file: UploadFile = File(...), version: str = Form(...), changelog: str = Form("")):
        from hivemind.network.updater import sign_update
        from hivemind.network.protocol import Message, MsgType
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Private Key nicht gefunden."}, 400)
        private_key = private_key_path.read_text().strip()
        updates_dir = node.base_dir / "updates"
        updates_dir.mkdir(exist_ok=True)
        zip_path = updates_dir / f"hivemind-{version}.zip"
        content = await file.read()
        zip_path.write_bytes(content)
        try:
            manifest = sign_update(zip_path, private_key, version, changelog)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)
        (updates_dir / f"hivemind-{version}.json").write_text(json.dumps(manifest, indent=2))
        sent = 0
        update_payload = {"manifest": manifest, "data_b64": base64.b64encode(content).decode()}
        # Send via direct P2P
        if node.network:
            sent += await node.network.broadcast(Message(type=MsgType.UPDATE_DATA, sender_id=node.id,
                payload=update_payload))
        # Send via Relay
        if node.relay and node.relay.connected:
            relay_ok = await node.relay.broadcast({
                "type": "UPDATE_DATA",
                "manifest": manifest,
                "data_b64": update_payload["data_b64"],
            })
            if relay_ok:
                sent += node.relay.status.get("relay_nodes", 0) or 1
        return {"success": True, "version": version, "sha256": manifest["sha256"],
                "signature": manifest["signature"][:40] + "...", "peers_notified": sent}

    # ─── API: Pending Update ───────────────────────────────────────
    @app.get("/api/update/pending")
    async def update_pending():
        if node.updater and node.updater.pending_update:
            return {"pending": True, **node.updater.pending_update}
        return {"pending": False}

    @app.post("/api/update/apply")
    async def update_apply():
        if not node.updater:
            return JSONResponse({"error": "Updater nicht verfuegbar"}, 400)
        result = await node.updater.apply_pending()
        if "error" in result:
            return JSONResponse(result, 400)
        return result

    @app.post("/api/update/dismiss")
    async def update_dismiss():
        if node.updater:
            node.updater.dismiss_pending()
        return {"success": True}

    # ─── API: PDF Download ─────────────────────────────────────────
    @app.get("/api/pdf/{filename}")
    async def pdf_download(filename: str):
        from fastapi.responses import FileResponse
        pdf_dir = node.base_dir / "data" / "pdf"
        path = pdf_dir / filename
        if not path.exists() or not filename.endswith(".pdf"):
            return JSONResponse({"error": "PDF nicht gefunden"}, 404)
        return FileResponse(path, media_type="application/pdf", filename=filename)

    # ─── API: Relay Status ───────────────────────────────────────
    @app.get("/api/relay/info")
    async def relay_info():
        result = {"relay_client": None}
        if node.relay:
            result["relay_client"] = node.relay.status
        return result

    # ─── API: Telegram Bridge ────────────────────────────────────
    @app.get("/api/telegram/status")
    async def telegram_status():
        import asyncio
        from urllib.request import urlopen, Request
        import json as _json
        token = node.config.gateway.telegram.token
        enabled = node.config.gateway.telegram.enabled
        allowed_users = node.config.gateway.telegram.allowed_users
        gw = getattr(node, "_telegram_gateway", None)
        bot_running = bool(gw and gw.running)
        result: dict = {
            "enabled": enabled,
            "token_set": bool(token),
            "token_masked": ("***" + token[-6:]) if len(token) > 6 else ("*" * len(token)) if token else "",
            "running": bot_running,
            "bot_info": None,
            "bot_error": None,
            "allowed_users": allowed_users,
        }
        if token:
            def _getme():
                try:
                    req = Request(f"https://api.telegram.org/bot{token}/getMe",
                                  headers={"User-Agent": "HiveMind"})
                    return _json.loads(urlopen(req, timeout=5).read())
                except Exception as exc:
                    return {"ok": False, "error": str(exc)}
            data = await asyncio.get_event_loop().run_in_executor(None, _getme)
            if data.get("ok"):
                result["bot_info"] = data["result"]
            else:
                result["bot_error"] = data.get("description") or data.get("error", "Unbekannter Fehler")
        return result

    @app.post("/api/telegram/test")
    async def telegram_test_token(data: dict):
        import asyncio
        from urllib.request import urlopen, Request
        import json as _json
        token = (data.get("token") or "").strip()
        if not token:
            return JSONResponse({"error": "Kein Token angegeben."}, 400)
        def _getme():
            try:
                req = Request(f"https://api.telegram.org/bot{token}/getMe",
                              headers={"User-Agent": "HiveMind"})
                return _json.loads(urlopen(req, timeout=8).read())
            except Exception as exc:
                return {"ok": False, "error": str(exc)}
        resp = await asyncio.get_event_loop().run_in_executor(None, _getme)
        if resp.get("ok"):
            return {"valid": True, "bot": resp["result"]}
        return {"valid": False, "error": resp.get("description") or resp.get("error", "Token ungültig.")}

    @app.post("/api/telegram/save")
    async def telegram_save_config(data: dict):
        import yaml as _yaml
        token = (data.get("token") or "").strip()
        enabled = data.get("enabled")
        allowed_users = data.get("allowed_users", None)
        config_path = node.base_dir / "config.yaml"
        cfg: dict = {}
        if config_path.exists():
            cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        cfg.setdefault("gateway", {}).setdefault("telegram", {})
        if token:
            cfg["gateway"]["telegram"]["token"] = token
            node.config.gateway.telegram.token = token
        if enabled is not None:
            cfg["gateway"]["telegram"]["enabled"] = bool(enabled)
            node.config.gateway.telegram.enabled = bool(enabled)
        if allowed_users is not None:
            clean = [int(u) for u in allowed_users if str(u).strip().lstrip("-").isdigit()]
            cfg["gateway"]["telegram"]["allowed_users"] = clean
            node.config.gateway.telegram.allowed_users = clean
        config_path.write_text(_yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        return {"success": True}

    # Lock verhindert gleichzeitige concurrent Aufrufe von /api/telegram/start,
    # die das "two getUpdates"-Konflikt-Problem verursachen würden.
    _tg_start_lock = asyncio.Lock()

    @app.post("/api/telegram/start")
    async def telegram_start():
        token = node.config.gateway.telegram.token
        if not token:
            return JSONResponse({"error": "Kein Token konfiguriert. Bitte zuerst Token speichern."}, 400)

        async with _tg_start_lock:
            gw = getattr(node, "_telegram_gateway", None)
            if gw and gw.running:
                return {"success": True, "already_running": True}

            # Alten Gateway signalisieren zu stoppen (setzt _stop_event).
            # start()'s finally-Block übernimmt den vollständigen PTB-Cleanup
            # inklusive updater.stop() — das wartet auf den laufenden Long-Poll.
            if gw:
                try:
                    await gw.stop()
                except Exception:
                    pass
                node._telegram_gateway = None

            # Auf alten Task warten (max. 15 s): stellt sicher, dass PTB vollständig
            # heruntergefahren ist und keine offene getUpdates-Verbindung bei Telegram
            # mehr existiert, bevor die neue Instanz startet.
            old_task = getattr(node, "_telegram_gateway_task", None)
            if old_task and not old_task.done():
                try:
                    await asyncio.wait_for(asyncio.shield(old_task), timeout=15.0)
                except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
                    old_task.cancel()
                    try:
                        await old_task
                    except (asyncio.CancelledError, Exception):
                        pass
            node._telegram_gateway_task = None

            try:
                from hivemind.gateway.telegram import TelegramGateway

                gw = TelegramGateway(token=token, node=node)
                node._telegram_gateway = gw

                async def _run():
                    try:
                        await gw.start()
                    except Exception as exc:
                        log.error("Telegram gateway task failed: %s", exc, exc_info=True)
                    finally:
                        # Gateway-Referenz immer aufräumen wenn Task endet.
                        if node._telegram_gateway is gw:
                            node._telegram_gateway = None

                node._telegram_gateway_task = asyncio.create_task(_run())
                node.config.gateway.telegram.enabled = True

                # Auf vollständigen Start warten (max. 5 s): polling aktiv = running True.
                # Verhindert, dass status-Polling im Fenster zwischen create_task und
                # _started=True ein "running: false" zurückgibt und einen Neustart triggert.
                for _ in range(50):
                    if gw.running:
                        break
                    await asyncio.sleep(0.1)

                return {"success": True}
            except Exception as exc:
                node._telegram_gateway = None
                return JSONResponse({"error": str(exc)}, 500)

    @app.post("/api/telegram/stop")
    async def telegram_stop():
        gw = getattr(node, "_telegram_gateway", None)
        task = getattr(node, "_telegram_gateway_task", None)
        if gw:
            try:
                await gw.stop()  # setzt _stop_event; start()'s finally räumt PTB auf
            except Exception:
                pass
            node._telegram_gateway = None
        # Auf Task warten statt ihn sofort canceln — sonst wird der PTB-Cleanup
        # (updater.stop / app.shutdown) durch CancelledError unterbrochen.
        if task and not task.done():
            try:
                await asyncio.wait_for(asyncio.shield(task), timeout=15.0)
            except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
                task.cancel()
                try:
                    await task
                except (asyncio.CancelledError, Exception):
                    pass
        node._telegram_gateway_task = None
        node.config.gateway.telegram.enabled = False
        return {"success": True}

    # ─── WebSocket ───────────────────────────────────────────────
    @app.websocket("/ws/chat")
    async def ws_chat(ws: WebSocket):
        await ws.accept()
        try:
            while True:
                msg = await ws.receive_text()
                response_text, meta = await node.chat(msg)
                await ws.send_text(json.dumps({"response": response_text, "meta": meta}, ensure_ascii=False))
        except WebSocketDisconnect:
            pass

    # ─── Crash-Detection ─────────────────────────────────────────
    @app.get("/api/crash-detected")
    async def crash_detected():
        """Gibt zurück, ob HiveMind beim letzten Start gecrasht oder kurzfristig beendet wurde."""
        crashed = getattr(node, "_crashed_on_startup", False)
        log_path = getattr(node, "_last_log_path", "")
        short_exit = getattr(node, "_short_exit_info", None)
        return {
            "crashed": bool(crashed),
            "log_path": log_path,
            "short_exit": short_exit,  # dict mit runtime_seconds, log_path, timestamp oder None
        }

    @app.post("/api/crash-detected/dismiss")
    async def crash_dismiss():
        """Crash-Flag / Short-Exit-Info zurücksetzen (nach Modal-Schließen)."""
        node._crashed_on_startup = False
        node._short_exit_info = None
        return {"success": True}

    # ─── Moltbook Integration ─────────────────────────────────────────
    _MB_BASE = "https://www.moltbook.com/api/v1"

    def _mb_headers(api_key: str) -> dict:
        return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}

    def _mb_key() -> str:
        return node.config.moltbook.api_key.strip()

    async def _mb_solve_challenge(text: str) -> str:
        """Obfuscated Moltbook challenge solver.

        Phase 1 (Python): Entrauschen
          - Alles Kleinschrift
          - Aufeinanderfolgende duplizierte Buchstaben dedupizieren:
            "lOoOoBbSsTtEeR" → "lobster",  "tWeNnTtY" → "twenty"
          - Sonderzeichen → Leerzeichen (Wortgrenzen erhalten!)
          - Whitespace normalisieren

        Phase 2 (LLM): Aufgereinigten Text als Mathe-Aufgabe lösen lassen
        """
        import re as _re

        # ── Phase 1: Entrauschen ──────────────────────────────────────────
        s = text.lower()
        # Aufeinanderfolgende gleiche Zeichen deduplizieren
        # "oooo" → "o",  "tt" → "t",  etc.
        s = _re.sub(r'(.)\1+', r'\1', s)
        # Nicht-dezimale Punkte entfernen (thir.ty → thirty, aber 3.14 bleibt)
        s = _re.sub(r'(?<!\d)\.(?!\d)', '', s)
        # Sonderzeichen → Leerzeichen; Dezimalpunkt explizit erlaubt
        s = _re.sub(r'[^a-z0-9\s.]', ' ', s)
        # Whitespace normalisieren
        s = _re.sub(r'\s+', ' ', s).strip()

        log.info("MB challenge → denoised: %r", s[:300])

        # ── Phase 2: LLM löst den entrauschten Text ───────────────────────
        prompt = [
            {"role": "system", "content": (
                "You are a precise math solver. "
                "The user gives you a word problem. "
                "Reply with ONLY the numeric answer as a decimal with exactly two decimal places "
                "(e.g. '46.00'). No words, no units, no explanation — just the number."
            )},
            {"role": "user", "content":
                f"Solve this math problem and reply with only the numeric result "
                f"(format: XX.XX):\n\n{s}"},
        ]
        try:
            raw = (await node._generate_local(prompt, source="mb_challenge", priority=3)).strip()
            log.info("MB challenge → LLM raw answer: %r", raw[:80])
            # Erste Zahl aus der Antwort extrahieren (inkl. negativer Zahlen)
            m = _re.search(r'-?\d+(?:\.\d+)?', raw)
            if m:
                val = float(m.group())
                return f"{val:.2f}"
        except Exception as exc:
            log.warning("MB challenge LLM solve failed: %s", exc)

        return "0.00"


    async def _mb_post_and_verify(client, url: str, payload: dict, api_key: str) -> dict:
        """POST to Moltbook, auto-solve verification challenge if required."""
        r = await client.post(url, json=payload, headers=_mb_headers(api_key))
        data = r.json()
        # Check if verification required
        content_obj = data.get("post") or data.get("comment") or data.get("submolt") or {}
        verification = content_obj.get("verification") or data.get("verification")
        if not verification:
            return data
        code = verification.get("verification_code", "")
        challenge = verification.get("challenge_text", "")
        if not code or not challenge:
            return data
        answer_str = await _mb_solve_challenge(challenge)
        # API erwartet einen String (z.B. "46.00"), keine Zahl
        log.info(
            "MB VERIFY  Aufgabe : %s\n"
            "           Lösung  : %r",
            challenge, answer_str,
        )
        vr = await client.post(
            f"{_MB_BASE}/verify",
            json={"verification_code": code, "answer": answer_str},
            headers=_mb_headers(api_key),
        )
        vr_data = vr.json()
        success = vr_data.get("success")
        if success:
            log.info("MB VERIFY ✅  Lösung %r akzeptiert", answer_str)
        else:
            api_msg = vr_data.get("message") or vr_data.get("error") or str(vr_data)
            log.warning(
                "MB VERIFY ❌  Lösung %r ABGELEHNT\n"
                "           API-Antwort  : %r\n"
                "           Volltext-Aufgabe: %r",
                answer_str, api_msg, challenge,
            )
        return {**data, "_verification": vr_data, "_answer": answer_str, "_challenge": challenge}

    @app.get("/api/moltbook/status")
    async def mb_status():
        import httpx
        key = _mb_key()
        if not key:
            return {"configured": False, "agent_name": node.config.moltbook.agent_name}
        try:
            async with httpx.AsyncClient(timeout=8.0) as client:
                me = await client.get(f"{_MB_BASE}/agents/me", headers=_mb_headers(key))
                me_data = me.json() if me.status_code == 200 else {}
                st = await client.get(f"{_MB_BASE}/agents/status", headers=_mb_headers(key))
                st_data = st.json() if st.status_code == 200 else {}
                return {
                    "configured": True,
                    "api_key": key,
                    "claim_url": st_data.get("claim_url") or node.config.moltbook.claim_url,
                    "agent": me_data.get("agent") or me_data,
                    "claim_status": st_data.get("status", "unknown"),
                    "agent_name": node.config.moltbook.agent_name,
                }
        except Exception as e:
            return {"configured": True, "api_key": key, "claim_url": node.config.moltbook.claim_url, "error": str(e)}

    @app.post("/api/moltbook/register")
    async def mb_register(data: dict):
        import httpx
        name = (data.get("name") or "").strip()
        desc = (data.get("description") or "").strip()
        if not name:
            return JSONResponse({"error": "Name erforderlich."}, 400)
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.post(f"{_MB_BASE}/agents/register",
                                      json={"name": name, "description": desc},
                                      headers={"Content-Type": "application/json"})
                body = r.json()
                if r.status_code >= 400:
                    # Normalisiere Moltbook-Fehlerformate auf {error, hint}
                    err = body.get("error") or body.get("message") or f"HTTP {r.status_code}"
                    hint = body.get("hint") or body.get("details") or ""
                    return JSONResponse({"error": err, "hint": hint, "raw": body}, r.status_code)
                # Erfolg: api_key und claim_url automatisch in config.yaml speichern
                try:
                    import yaml as _yaml
                    ag = body.get("agent") or {}
                    api_key = ag.get("api_key") or ""
                    claim_url = ag.get("claim_url") or ""
                    if api_key:
                        config_path = node.base_dir / "config.yaml"
                        cfg: dict = _yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
                        cfg = cfg or {}
                        cfg.setdefault("moltbook", {})
                        cfg["moltbook"]["api_key"] = api_key
                        cfg["moltbook"]["agent_name"] = name
                        if claim_url:
                            cfg["moltbook"]["claim_url"] = claim_url
                        config_path.write_text(_yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
                        node.config.moltbook.api_key = api_key
                        node.config.moltbook.agent_name = name
                        if claim_url:
                            node.config.moltbook.claim_url = claim_url
                except Exception:
                    pass  # Speichern fehlgeschlagen, Benutzer muss manuell speichern
                return body
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/config")
    async def mb_save_config(data: dict):
        import yaml as _yaml
        api_key    = (data.get("api_key")    or "").strip()
        agent_name = (data.get("agent_name") or "").strip()
        claim_url  = (data.get("claim_url")  or "").strip()
        config_path = node.base_dir / "config.yaml"
        cfg: dict = {}
        if config_path.exists():
            cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        cfg.setdefault("moltbook", {})
        if api_key:
            cfg["moltbook"]["api_key"] = api_key
            node.config.moltbook.api_key = api_key
        if agent_name:
            cfg["moltbook"]["agent_name"] = agent_name
            node.config.moltbook.agent_name = agent_name
        if claim_url:
            cfg["moltbook"]["claim_url"] = claim_url
            node.config.moltbook.claim_url = claim_url
        config_path.write_text(_yaml.dump(cfg, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        return {"success": True}

    @app.get("/api/moltbook/home")
    async def mb_home():
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.get(f"{_MB_BASE}/home", headers=_mb_headers(key))
                return JSONResponse(r.json())
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.get("/api/moltbook/feed")
    async def mb_feed(sort: str = "hot", limit: int = 25, submolt: str = "", cursor: str = "", filter: str = ""):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                params: dict = {"sort": sort, "limit": min(limit, 50)}
                if submolt:
                    params["submolt"] = submolt
                if cursor:
                    params["cursor"] = cursor
                if filter:
                    params["filter"] = filter
                endpoint = f"{_MB_BASE}/feed" if filter or not submolt else f"{_MB_BASE}/submolts/{submolt}/feed"
                r = await client.get(endpoint, params=params, headers=_mb_headers(key))
                return JSONResponse(r.json())
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.get("/api/moltbook/post/{post_id}")
    async def mb_get_post(post_id: str, sort: str = "best"):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                post_r, cmt_r = await asyncio.gather(
                    client.get(f"{_MB_BASE}/posts/{post_id}", headers=_mb_headers(key)),
                    client.get(f"{_MB_BASE}/posts/{post_id}/comments", params={"sort": sort}, headers=_mb_headers(key)),
                )
                return JSONResponse({"post": post_r.json(), "comments": cmt_r.json()})
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/post")
    async def mb_create_post(data: dict):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        payload = {
            "submolt_name": data.get("submolt_name") or "general",
            "title":        (data.get("title") or "").strip(),
        }
        if data.get("content"):
            payload["content"] = data["content"].strip()
        if data.get("url"):
            payload["url"] = data["url"].strip()
            payload["type"] = "link"
        if not payload["title"]:
            return JSONResponse({"error": "Titel erforderlich."}, 400)
        try:
            async with httpx.AsyncClient(timeout=15.0) as client:
                result = await _mb_post_and_verify(client, f"{_MB_BASE}/posts", payload, key)
                return JSONResponse(result)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/comment")
    async def mb_create_comment(data: dict):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        post_id = (data.get("post_id") or "").strip()
        content = (data.get("content") or "").strip()
        if not post_id or not content:
            return JSONResponse({"error": "post_id und content erforderlich."}, 400)
        payload: dict = {"content": content}
        if data.get("parent_id"):
            payload["parent_id"] = data["parent_id"]
        try:
            async with httpx.AsyncClient(timeout=15.0) as client:
                result = await _mb_post_and_verify(
                    client, f"{_MB_BASE}/posts/{post_id}/comments", payload, key)
                return JSONResponse(result)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/upvote/{post_id}")
    async def mb_upvote(post_id: str):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.post(f"{_MB_BASE}/posts/{post_id}/upvote", headers=_mb_headers(key))
                return JSONResponse(r.json())
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/notifications/read")
    async def mb_notifications_read(data: dict):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        post_id = data.get("post_id")
        try:
            async with httpx.AsyncClient(timeout=8.0) as client:
                if post_id:
                    r = await client.post(f"{_MB_BASE}/notifications/read-by-post/{post_id}", headers=_mb_headers(key))
                else:
                    r = await client.post(f"{_MB_BASE}/notifications/read-all", headers=_mb_headers(key))
                return JSONResponse(r.json())
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.get("/api/moltbook/notifications")
    async def mb_notifications_list(limit: int = 25):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.get(f"{_MB_BASE}/notifications?limit={min(limit,50)}",
                                     headers=_mb_headers(key))
                return JSONResponse(r.json())
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.patch("/api/moltbook/profile")
    async def mb_profile_update(data: dict):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        payload = {}
        if data.get("description") is not None:
            payload["description"] = data["description"]
        if data.get("metadata") is not None:
            payload["metadata"] = data["metadata"]
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.patch(f"{_MB_BASE}/agents/me",
                                       json=payload, headers=_mb_headers(key))
                body = r.json()
                if r.status_code >= 400:
                    err = body.get("error") or body.get("message") or f"HTTP {r.status_code}"
                    return JSONResponse({"error": err}, r.status_code)
                return JSONResponse(body)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/follow/{name}")
    async def mb_follow(name: str):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.post(f"{_MB_BASE}/agents/{name}/follow",
                                      headers=_mb_headers(key))
                body = r.json()
                if r.status_code >= 400:
                    err = body.get("error") or body.get("message") or f"HTTP {r.status_code}"
                    return JSONResponse({"error": err}, r.status_code)
                return JSONResponse(body)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.delete("/api/moltbook/follow/{name}")
    async def mb_unfollow(name: str):
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.delete(f"{_MB_BASE}/agents/{name}/follow",
                                        headers=_mb_headers(key))
                body = r.json()
                if r.status_code >= 400:
                    err = body.get("error") or body.get("message") or f"HTTP {r.status_code}"
                    return JSONResponse({"error": err}, r.status_code)
                return JSONResponse(body)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.get("/api/moltbook/follows")
    async def mb_follows_overview():
        """Liefert: Following-Liste, Follower-Liste, Follow-Gedächtnis des Bots."""
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        agent_name = (node.config.moltbook.agent_name or "").strip()
        if not agent_name:
            return JSONResponse({"error": "Kein agent_name konfiguriert."}, 400)
        try:
            async with httpx.AsyncClient(timeout=12.0) as client:
                following_r, followers_r = await asyncio.gather(
                    client.get(f"{_MB_BASE}/agents/{agent_name}/following",
                               headers=_mb_headers(key)),
                    client.get(f"{_MB_BASE}/agents/{agent_name}/followers",
                               headers=_mb_headers(key)),
                    return_exceptions=True,
                )
            def _parse_list(r):
                if isinstance(r, Exception):
                    return []
                try:
                    d = r.json()
                    return d if isinstance(d, list) else (d.get("agents") or d.get("data") or d.get("following") or d.get("followers") or [])
                except Exception:
                    return []
            # Follow-Gedächtnis laden (enthält bot-automatisch-gefolgte Agenten)
            p = node.base_dir / "mb_follow_memory.json"
            memory = {}
            if p.exists():
                import json as _json
                try:
                    memory = _json.loads(p.read_text(encoding="utf-8"))
                except Exception:
                    pass
            return JSONResponse({
                "following": _parse_list(following_r),
                "followers": _parse_list(followers_r),
                "bot_followed": memory.get("followed", []),
                "bot_skipped": memory.get("skipped", []),
            })
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.delete("/api/moltbook/follows/memory/{name}")
    async def mb_follows_memory_remove(name: str):
        """Entfernt einen Agenten aus dem Follow-Gedächtnis (damit er beim nächsten Durchlauf neu bewertet wird)."""
        p = node.base_dir / "mb_follow_memory.json"
        import json as _json
        mem = {"followed": [], "skipped": []}
        if p.exists():
            try:
                mem = _json.loads(p.read_text(encoding="utf-8"))
            except Exception:
                pass
        mem["followed"] = [a for a in mem.get("followed", []) if a != name]
        mem["skipped"]  = [a for a in mem.get("skipped",  []) if a != name]
        p.write_text(_json.dumps(mem, ensure_ascii=False, indent=2), encoding="utf-8")
        return JSONResponse({"success": True, "name": name})

    # ─── Moltbook-Assistent ───────────────────────────────────────

    # Laufzustand des Assistenten im Speicher
    _mb_bot_task: asyncio.Task | None = None
    _mb_bot_log: list[dict] = []    # letzte Aktionen (max. 50)
    _mb_bot_trace: list[dict] = []  # detaillierter Live-Trace (max. 200)
    _mb_bot_running_flag: dict = {"running": False, "current_action": ""}

    # ── Seen-Tracking: verhindert Doppelt-Kommentieren / Doppelt-Upvoten ─────
    _mb_seen: dict = {"commented": [], "upvoted": [], "diary_posted": []}
    _mb_seen_max = 500

    def _mb_seen_file() -> Path:
        return node.base_dir / "data" / "mb_seen.json"

    def _mb_seen_load():
        p = _mb_seen_file()
        if p.exists():
            try:
                d = json.loads(p.read_text(encoding="utf-8"))
                for k in ("commented", "upvoted", "diary_posted"):
                    _mb_seen[k] = list(d.get(k) or [])[-_mb_seen_max:]
            except Exception:
                pass

    def _mb_seen_save():
        p = _mb_seen_file()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(_mb_seen, ensure_ascii=False), encoding="utf-8")

    def _mb_seen_has(key: str, item_id: str) -> bool:
        return str(item_id) in _mb_seen.get(key, [])

    def _mb_seen_add(key: str, item_id: str):
        lst = _mb_seen.setdefault(key, [])
        sid = str(item_id)
        if sid not in lst:
            lst.append(sid)
        if len(lst) > _mb_seen_max:
            del lst[:len(lst) - _mb_seen_max]
        _mb_seen_save()

    # Seen-Daten beim Start laden
    _mb_seen_load()

    # ── Tagebuch ─────────────────────────────────────────────────────────────
    def _diary_dir() -> Path:
        return node.base_dir / "data" / "diary"

    def _diary_today_path() -> Path:
        import datetime as _dt2
        return _diary_dir() / f"diary_{_dt2.date.today().isoformat()}.json"

    def _diary_append(source: str, agent: str, entry: str):
        """Hängt einen kurzen Tagebucheintrag (current day) an die Tagesdatei an."""
        import datetime as _dt2
        if not (entry or "").strip():
            return  # Leere Einträge nicht speichern
        path = _diary_today_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        entries: list = []
        if path.exists():
            try:
                entries = json.loads(path.read_text(encoding="utf-8")) or []
            except Exception:
                entries = []
        entries.append({
            "ts": _dt2.datetime.now().strftime("%H:%M:%S"),
            "source": source,
            "agent": agent,
            "entry": entry,
        })
        path.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")

    def _diary_read(date_str: str = "") -> list[dict]:
        import datetime as _dt2
        if not date_str:
            date_str = _dt2.date.today().isoformat()
        p = _diary_dir() / f"diary_{date_str}.json"
        if not p.exists():
            return []
        try:
            return json.loads(p.read_text(encoding="utf-8")) or []
        except Exception:
            return []

    def _diary_list_dates() -> list[str]:
        import re as _re
        d = _diary_dir()
        if not d.exists():
            return []
        result = []
        for f in sorted(d.glob("diary_*.json"), reverse=True):
            date_part = f.stem[6:]  # strip "diary_"
            if _re.match(r'^\d{4}-\d{2}-\d{2}$', date_part):
                result.append(date_part)
        return result[:60]

    # ── Tagebuch-Zusammenfassung ──────────────────────────────────────────────

    def _diary_summary_path(date_str: str) -> Path:
        return _diary_dir() / f"diary_{date_str}_summary.json"

    def _diary_summary_read(date_str: str) -> dict | None:
        p = _diary_summary_path(date_str)
        if not p.exists():
            return None
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            return None

    def _diary_summary_write(date_str: str, text: str) -> dict:
        import datetime as _dt2
        data = {
            "text": text,
            "generated_at": _dt2.datetime.now().isoformat(timespec="seconds"),
        }
        p = _diary_summary_path(date_str)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        return data

    async def _diary_generate_summary(date_str: str) -> dict:
        """Generiert eine KI-Tageszusammenfassung aus den Tagebucheinträgen."""
        import datetime as _dt2
        entries = _diary_read(date_str)
        if not entries:
            return {"error": "Keine Einträge für diesen Tag vorhanden."}
        if not node.model or not node.model.loaded:
            return {"error": "Kein Modell geladen."}

        # Einträge nach Quelle gruppieren
        sections: dict[str, list[dict]] = {}
        for e in entries:
            src = e.get("source", "sonstige")
            sections.setdefault(src, []).append(e)

        src_label = {
            "chat": "Chat-Gespräche",
            "moltbook_bot": "Moltbook-Aktivitäten",
            "telegram": "Telegram-Nachrichten",
            "assistant": "Assistent & Scheduler-Jobs",
            "system": "System-Ereignisse",
            "network": "Netzwerk-Ereignisse",
            "plugin": "Plugin-Ereignisse",
        }

        ctx_parts: list[str] = []
        for src, label in src_label.items():
            if src not in sections:
                continue
            elist = sections[src]
            # Maximal 20 Einträge pro Quelle, jeden Eintrag auf 120 Zeichen kürzen
            ctx_parts.append(f"### {label} ({len(elist)} Einträge)")
            for e in elist[:20]:
                agent = f"[{e['agent']}] " if e.get("agent") else ""
                entry_txt = (e.get("entry") or "")[:120]
                ctx_parts.append(f"  {e.get('ts','')} {agent}{entry_txt}")
        # Unbekannte Quellen
        for src, elist in sections.items():
            if src not in src_label:
                ctx_parts.append(f"### {src.capitalize()} ({len(elist)} Einträge)")
                for e in elist[:10]:
                    entry_txt = (e.get("entry") or "")[:120]
                    ctx_parts.append(f"  {e.get('ts','')} {entry_txt}")

        # Gesamten Kontext auf ~3000 Zeichen begrenzen damit er ins Kontextfenster passt
        ctx_text = "\n".join(ctx_parts)
        if len(ctx_text) > 3000:
            ctx_text = ctx_text[:3000] + "\n  … (weitere Einträge gekürzt)"

        # Netzwerk
        peer_info = ""
        if node.network:
            try:
                peer_count = len(node.network.peers.all_peers)
                if peer_count > 0:
                    peer_info = f"\nMein Netzwerk hat aktuell {peer_count} bekannte Nodes."
            except Exception:
                pass

        node_name = node.name or "KI-Assistent"
        persona = (node.config.node.persona or "").strip() or f"Du bist {node_name}."

        system_msg = (
            f"{persona}\n\n"
            "Du schreibst deinen persönlichen Tagesbericht. "
            "Verfasse eine Zusammenfassung in der ersten Person Singular (Ich-Perspektive). "
            "Sei konkret, nenne echte Aktivitäten, Namen und Mengenangaben aus den Einträgen. "
            "Schreibe 3–6 flüssige Sätze ohne Aufzählungen oder Überschriften — "
            "im Stil eines persönlichen Tagebucheintrags."
        )
        user_msg = (
            f"Hier sind alle Aktivitäten meines heutigen Tages ({date_str}):\n\n"
            + ctx_text
            + peer_info
            + "\n\nSchreibe jetzt meine Tageszusammenfassung in der Ich-Perspektive."
        )

        messages = [
            {"role": "system", "content": system_msg},
            {"role": "user",   "content": user_msg},
        ]
        try:
            text = await node._generate_local(
                messages, source="diary_summary", priority=1, max_tokens=512
            )
        except Exception as exc:
            return {"error": f"Modell-Fehler: {exc}"}

        return _diary_summary_write(date_str, text.strip())

    def _mb_asst_cfg() -> "MoltbookAssistantConfig":  # type: ignore[name-defined]
        return node.config.moltbook.assistant

    def _mb_asst_log(action: str, detail: str):
        import datetime
        _mb_bot_log.append({
            "ts": datetime.datetime.now().strftime("%H:%M:%S"),
            "action": action,
            "detail": detail,
        })
        if len(_mb_bot_log) > 50:
            _mb_bot_log.pop(0)
        log.info("[MB-Bot] %s — %s", action, detail)

    def _mb_asst_trace(
        step: str,
        subject: str,
        verdict: str | None = None,
        reason: str | None = None,
        generated: str | None = None,
        action_type: str = "check",
    ):
        """Detaillierter Trace-Eintrag für den Live-View.

        action_type: 'check' | 'decide_yes' | 'decide_no' | 'generate' | 'submit' | 'phase' | 'error'
        """
        import datetime
        _mb_bot_trace.append({
            "ts": datetime.datetime.now().strftime("%H:%M:%S"),
            "step": step,
            "subject": subject[:120] if subject else "",
            "verdict": verdict,
            "reason": (reason or "")[:400],
            "generated": (generated or "")[:600],
            "action_type": action_type,
        })
        if len(_mb_bot_trace) > 200:
            _mb_bot_trace.pop(0)

    def _mb_persona_system() -> str:
        persona = (node.config.node.persona or "").strip()
        return persona if persona else "Du bist ein hilfreicher KI-Assistent."

    def _mb_author(post: dict) -> str:
        """Extrahiert den Autorennamen aus einem Moltbook-Post robust als String."""
        agent = post.get("agent")
        if isinstance(agent, dict):
            name = agent.get("name") or agent.get("username") or ""
            if name:
                return str(name)
        elif isinstance(agent, str) and agent:
            return agent
        for field in ("agent_name", "author_name", "author", "username"):
            val = post.get(field)
            if isinstance(val, str) and val:
                return val
            if isinstance(val, dict):
                n = val.get("name") or val.get("username") or ""
                if n:
                    return str(n)
        return "?"

    async def _mb_bot_upvote_posts(client, key: str):
        """Upvotet interessante Beiträge im Feed."""
        _mb_bot_running_flag["current_action"] = "Beiträge upvoten"
        _mb_asst_trace("Beiträge upvoten", "Lade Hot-Feed…", action_type="phase")
        try:
            r = await client.get(f"{_MB_BASE}/feed?sort=hot&limit=20", headers=_mb_headers(key))
            posts = (r.json() or {}).get("posts") or []
            upvoted = 0
            for post in posts[:10]:
                pid = post.get("id") or post.get("post_id")
                title = post.get("title", "")
                content = (post.get("content") or "")[:300]
                author = _mb_author(post)
                if not pid:
                    continue
                if _mb_seen_has("upvoted", pid):
                    _mb_asst_trace("upvote_posts", f"⏭ Bereits upgevoted: {title}",
                                   action_type="decide_no")
                    continue
                _mb_asst_trace("upvote_posts", f"{title}",
                               reason=f"von {author}" + (f" — {content[:80]}" if content else ""),
                               action_type="check")
                prompt = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Beurteile diesen Moltbook-Beitrag kurz:\nTitel: {title}\nInhalt: {content}\n\n"
                        "Antworte NUR mit 'ja' wenn er interessant/wertvoll ist, sonst 'nein'."},
                ]
                verdict_raw = (await node._generate_local(prompt, source="mb_upvote_decide", priority=2)).strip()
                verdict = verdict_raw.lower()
                if verdict.startswith("ja"):
                    await client.post(f"{_MB_BASE}/posts/{pid}/upvote", headers=_mb_headers(key))
                    upvoted += 1
                    _mb_seen_add("upvoted", pid)
                    _diary_append("moltbook_bot", node.config.moltbook.agent_name or "bot",
                                  f"Upvote auf '{title[:60]}' von {author}.")
                    _mb_asst_trace("upvote_posts", f"✅ Upvote: {title}",
                                   verdict="ja", reason=verdict_raw[:120], action_type="decide_yes")
                else:
                    _mb_asst_trace("upvote_posts", f"⬜ Kein Upvote: {title}",
                                   verdict="nein", reason=verdict_raw[:120], action_type="decide_no")
            _mb_asst_log("upvote_posts", f"{upvoted}/{min(len(posts),10)} Beiträge upgevoted")
        except Exception as exc:
            _mb_asst_trace("upvote_posts", "Fehler", reason=str(exc), action_type="error")
            _mb_asst_log("upvote_posts", f"Fehler: {exc}")

    # ── Follow-Gedächtnis ─────────────────────────────────────────────────────
    def _mb_follow_memory_path():
        return node.base_dir / "mb_follow_memory.json"

    def _mb_follow_memory_load() -> dict:
        """Lädt das Follow-Gedächtnis. Rückgabe: {'followed': [...], 'skipped': [...]}"""
        p = _mb_follow_memory_path()
        if p.exists():
            try:
                import json as _json
                return _json.loads(p.read_text(encoding="utf-8"))
            except Exception:
                pass
        return {"followed": [], "skipped": []}

    def _mb_follow_memory_save(mem: dict):
        import json as _json
        _mb_follow_memory_path().write_text(
            _json.dumps(mem, ensure_ascii=False, indent=2), encoding="utf-8"
        )

    async def _mb_bot_follow_agents(client, key: str):
        """Folgt interessanten Agenten aus dem Feed (mit Gedächtnis, Limit und Profil-Abfrage)."""
        _mb_bot_running_flag["current_action"] = "Agenten folgen"
        _mb_asst_trace("Agenten folgen", "Lade Top-Feed…", action_type="phase")
        cfg = _mb_asst_cfg()
        follow_limit = int(cfg.follow_limit) if cfg.follow_limit else 0
        mem = _mb_follow_memory_load()
        already_known: set = set(mem.get("followed", [])) | set(mem.get("skipped", []))
        try:
            r = await client.get(f"{_MB_BASE}/feed?sort=top&limit=25", headers=_mb_headers(key))
            posts = (r.json() or {}).get("posts") or []
            seen: set = set()
            followed = 0
            for post in posts:
                if follow_limit and followed >= follow_limit:
                    _mb_asst_trace("follow_agents", f"Limit {follow_limit} erreicht — Stop", action_type="phase")
                    break
                author = _mb_author(post)
                if not author or author == "?" or author in seen:
                    continue
                seen.add(author)
                own = (node.config.moltbook.agent_name or "").lower()
                if author.lower() == own:
                    continue
                if author in already_known:
                    _mb_asst_trace("follow_agents", f"⏭ Übersprungen (bekannt): {author}", action_type="check")
                    continue
                # Profil abrufen für bessere Entscheidungsgrundlage
                profile_info = ""
                try:
                    pr = await client.get(f"{_MB_BASE}/agents/{author}", headers=_mb_headers(key), timeout=5)
                    pd = pr.json() or {}
                    bio = (pd.get("bio") or pd.get("description") or "")[:300]
                    tags = ", ".join((pd.get("tags") or [])[:10])
                    post_count = pd.get("post_count") or pd.get("posts_count") or ""
                    follower_count = pd.get("follower_count") or pd.get("followers_count") or ""
                    parts = []
                    if bio: parts.append(f"Bio: {bio}")
                    if tags: parts.append(f"Tags: {tags}")
                    if post_count: parts.append(f"Posts: {post_count}")
                    if follower_count: parts.append(f"Follower: {follower_count}")
                    profile_info = " | ".join(parts) if parts else "(kein Profil verfügbar)"
                except Exception:
                    profile_info = "(Profil nicht abrufbar)"
                title = post.get("title", "")
                _mb_asst_trace("follow_agents", f"Prüfe Agent: {author}",
                               reason=f'Beitrag: "{title[:80]}" | {profile_info[:120]}',
                               action_type="check")
                prompt = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Ich analysiere Agenten im Moltbook-Netzwerk und entscheide, wem ich folgen möchte.\n\n"
                        f"Agent: {author}\n"
                        f"Letzter Beitrag: \"{title}\"\n"
                        f"{profile_info}\n\n"
                        "Basierend auf meiner Persona und den Interessen dieses Agenten: "
                        "Soll ich diesem Agenten folgen? Antworte NUR mit 'ja' oder 'nein'."},
                ]
                verdict_raw = (await node._generate_local(
                    prompt, source="mb_follow_decide", priority=2, max_tokens=16
                )).strip()
                verdict = verdict_raw.lower()
                if "ja" in verdict and "nein" not in verdict:
                    await client.post(f"{_MB_BASE}/agents/{author}/follow", headers=_mb_headers(key))
                    followed += 1
                    mem.setdefault("followed", [])
                    if author not in mem["followed"]:
                        mem["followed"].append(author)
                    already_known.add(author)
                    _mb_asst_trace("follow_agents", f"✅ Folge jetzt: {author}",
                                   verdict="ja", reason=verdict_raw[:120], action_type="decide_yes")
                else:
                    mem.setdefault("skipped", [])
                    if author not in mem["skipped"]:
                        mem["skipped"].append(author)
                    already_known.add(author)
                    _mb_asst_trace("follow_agents", f"⬜ Folge nicht: {author}",
                                   verdict="nein", reason=verdict_raw[:120], action_type="decide_no")
            _mb_follow_memory_save(mem)
            _mb_asst_log("follow_agents", f"{followed} Agenten gefolgt, {len(mem.get('followed',[]))} gesamt im Gedächtnis")
        except Exception as exc:
            _mb_asst_trace("follow_agents", "Fehler", reason=str(exc), action_type="error")
            _mb_asst_log("follow_agents", f"Fehler: {exc}")

    async def _mb_bot_upvote_comments(client, key: str):
        """Upvotet interessante Kommentare anderer auf beliebigen Posts."""
        _mb_bot_running_flag["current_action"] = "Kommentare upvoten"
        _mb_asst_trace("Kommentare upvoten", "Lade Kommentare aus Hot-Posts…", action_type="phase")
        try:
            r = await client.get(f"{_MB_BASE}/feed?sort=hot&limit=10", headers=_mb_headers(key))
            posts = (r.json() or {}).get("posts") or []
            upvoted = 0
            for post in posts[:5]:
                pid = post.get("id") or post.get("post_id")
                ptitle = post.get("title", "?")
                if not pid:
                    continue
                pr = await client.get(f"{_MB_BASE}/posts/{pid}?sort=best", headers=_mb_headers(key))
                comments = (pr.json() or {}).get("comments") or []
                for c in comments[:5]:
                    cid = c.get("id") or c.get("comment_id")
                    text = (c.get("content") or "")[:300]
                    cauthor = _mb_author(c)
                    if not cid or not text:
                        continue
                    _mb_asst_trace("upvote_comments", f"Kommentar von {cauthor}",
                                   reason=f"Im Post: {ptitle[:60]} — '{text[:80]}'",
                                   action_type="check")
                    prompt = [
                        {"role": "system", "content": _mb_persona_system()},
                        {"role": "user", "content":
                            f"Kommentar: \"{text}\"\nIst dieser Kommentar wertvoll/interessant? Nur 'ja' oder 'nein'."},
                    ]
                    verdict_raw = (await node._generate_local(prompt, source="mb_com_upvote_decide", priority=2)).strip()
                    verdict = verdict_raw.lower()
                    if verdict.startswith("ja"):
                        await client.post(f"{_MB_BASE}/comments/{cid}/upvote", headers=_mb_headers(key))
                        upvoted += 1
                        _mb_asst_trace("upvote_comments", f"✅ Upvote Kommentar von {cauthor}",
                                       verdict="ja", reason=verdict_raw[:120], action_type="decide_yes")
                    else:
                        _mb_asst_trace("upvote_comments", f"⬜ Kein Upvote: {cauthor}",
                                       verdict="nein", reason=verdict_raw[:120], action_type="decide_no")
            _mb_asst_log("upvote_comments", f"{upvoted} Kommentare upgevoted")
        except Exception as exc:
            _mb_asst_trace("upvote_comments", "Fehler", reason=str(exc), action_type="error")
            _mb_asst_log("upvote_comments", f"Fehler: {exc}")

    async def _mb_bot_upvote_own_post_comments(client, key: str):
        """Upvotet Kommentare auf eigene Beiträge (via Benachrichtigungen)."""
        try:
            r = await client.get(f"{_MB_BASE}/notifications?limit=30", headers=_mb_headers(key))
            notifs = (r.json() or {}).get("notifications") or r.json() or []
            if isinstance(notifs, dict):
                notifs = notifs.get("notifications") or []
            upvoted = 0
            for n in notifs:
                if n.get("type") not in ("comment", "comment_reply"):
                    continue
                cid = n.get("comment_id") or (n.get("data") or {}).get("comment_id")
                text = n.get("comment_content") or (n.get("data") or {}).get("content") or ""
                if not cid or not text:
                    continue
                prompt = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Jemand hat auf meinen Beitrag kommentiert: \"{text[:300]}\"\n"
                        "Ist dieser Kommentar es wert, upgevoted zu werden? Nur 'ja' oder 'nein'."},
                ]
                verdict = (await node._generate_local(prompt, source="mb_own_com_upvote", priority=2)).strip().lower()
                if verdict.startswith("ja"):
                    await client.post(f"{_MB_BASE}/comments/{cid}/upvote", headers=_mb_headers(key))
                    upvoted += 1
            _mb_asst_log("upvote_own_post_comments", f"{upvoted} Kommentare auf eigene Posts upgevoted")
        except Exception as exc:
            _mb_asst_log("upvote_own_post_comments", f"Fehler: {exc}")

    async def _mb_bot_backfollow_new_followers(client, key: str):
        """Prüft neue Follower aus Benachrichtigungen und entscheidet über Backfollow."""
        _mb_bot_running_flag["current_action"] = "Neue Follower prüfen"
        _mb_asst_trace("Neue Follower prüfen", "Lade Benachrichtigungen…", action_type="phase")
        try:
            r = await client.get(f"{_MB_BASE}/notifications?limit=30", headers=_mb_headers(key))
            raw = r.json() or {}
            notifs = raw.get("notifications") or (raw if isinstance(raw, list) else [])
            mem = _mb_follow_memory_load()
            already_known: set = set(mem.get("followed", [])) | set(mem.get("skipped", []))
            backfollowed = 0
            own = (node.config.moltbook.agent_name or "").lower()
            for n in notifs:
                if n.get("type") not in ("new_follower", "follow"):
                    continue
                actor = n.get("actor_name") or n.get("actor_id") or n.get("from_agent") or ""
                if not actor or actor.lower() == own or actor in already_known:
                    continue
                # Profil des neuen Followers abrufen
                profile_info = ""
                try:
                    pr = await client.get(f"{_MB_BASE}/agents/{actor}", headers=_mb_headers(key), timeout=5)
                    pd = pr.json() or {}
                    ag_data = pd.get("agent") or pd
                    bio = (ag_data.get("bio") or ag_data.get("description") or "")[:300]
                    post_count = ag_data.get("post_count") or ag_data.get("posts_count") or ""
                    follower_count = ag_data.get("follower_count") or ag_data.get("followers_count") or ""
                    parts = []
                    if bio: parts.append(f"Bio: {bio}")
                    if post_count: parts.append(f"Posts: {post_count}")
                    if follower_count: parts.append(f"Follower: {follower_count}")
                    profile_info = " | ".join(parts) if parts else "(kein Profil verfügbar)"
                except Exception:
                    profile_info = "(Profil nicht abrufbar)"
                _mb_asst_trace("backfollow", f"Neuer Follower: {actor}",
                               reason=profile_info[:120], action_type="check")
                prompt = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Jemand hat mir auf Moltbook gefolgt. Ich entscheide, ob ich zurückfolgen will.\n\n"
                        f"Agent: {actor}\n{profile_info}\n\n"
                        "Soll ich diesem Agenten zurückfolgen? Antworte NUR mit 'ja' oder 'nein'."},
                ]
                verdict_raw = (await node._generate_local(
                    prompt, source="mb_backfollow_decide", priority=2, max_tokens=16
                )).strip()
                verdict = verdict_raw.lower()
                if "ja" in verdict and "nein" not in verdict:
                    await client.post(f"{_MB_BASE}/agents/{actor}/follow", headers=_mb_headers(key))
                    backfollowed += 1
                    mem.setdefault("followed", [])
                    if actor not in mem["followed"]:
                        mem["followed"].append(actor)
                    already_known.add(actor)
                    _mb_asst_trace("backfollow", f"✅ Zurückgefolgt: {actor}",
                                   verdict="ja", reason=verdict_raw[:120], action_type="decide_yes")
                else:
                    mem.setdefault("skipped", [])
                    if actor not in mem["skipped"]:
                        mem["skipped"].append(actor)
                    already_known.add(actor)
                    _mb_asst_trace("backfollow", f"⬜ Kein Backfollow: {actor}",
                                   verdict="nein", reason=verdict_raw[:120], action_type="decide_no")
            _mb_follow_memory_save(mem)
            _mb_asst_log("backfollow_new_followers", f"{backfollowed} neue Follower zurückgefolgt")
        except Exception as exc:
            _mb_asst_trace("backfollow_new_followers", "Fehler", reason=str(exc), action_type="error")
            _mb_asst_log("backfollow_new_followers", f"Fehler: {exc}")

    async def _mb_bot_reply_own_post_comments(client, key: str):
        """Antwortet auf Kommentare auf eigene Beiträge via LLM."""
        try:
            r = await client.get(f"{_MB_BASE}/notifications?limit=20", headers=_mb_headers(key))
            raw = r.json() or {}
            notifs = raw.get("notifications") or (raw if isinstance(raw, list) else [])
            replied = 0
            for n in notifs:
                if n.get("type") not in ("comment", "comment_reply"):
                    continue
                if n.get("read"):
                    continue
                cid = n.get("comment_id") or (n.get("data") or {}).get("comment_id")
                post_id = n.get("post_id") or (n.get("data") or {}).get("post_id")
                text = n.get("comment_content") or (n.get("data") or {}).get("content") or ""
                author = n.get("from_agent") or (n.get("data") or {}).get("agent_name") or "jemand"
                if not cid or not text:
                    continue
                # Kontext des Posts holen
                post_title = ""
                if post_id:
                    try:
                        pr2 = await client.get(f"{_MB_BASE}/posts/{post_id}", headers=_mb_headers(key))
                        post_title = (pr2.json() or {}).get("post", {}).get("title", "")
                    except Exception:
                        pass
                prompt = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Auf meinen Moltbook-Beitrag \"{post_title}\" hat {author} kommentiert:\n"
                        f"\"{text[:500]}\"\n\n"
                        "Schreibe eine kurze, freundliche Antwort in maximal 2-3 Sätzen. "
                        "Nur die Antwort, kein Präambel."},
                ]
                reply_text = (await node._generate_local(prompt, source="mb_reply_gen", priority=3)).strip()
                if reply_text:
                    payload = {"content": reply_text, "parent_comment_id": cid}
                    await _mb_post_and_verify(client, f"{_MB_BASE}/posts/{post_id}/comments", payload, key)
                    replied += 1
                    snippet = reply_text[:120].replace("\n", " ")
                    _diary_append("moltbook_bot", node.config.moltbook.agent_name or "bot",
                                  f"Antwortete auf {author} zu '{post_title[:40]}': {snippet}")
                    # Benachrichtigung als gelesen markieren
                    try:
                        await client.post(f"{_MB_BASE}/notifications/read-by-post/{post_id}",
                                          headers=_mb_headers(key))
                    except Exception:
                        pass
            _mb_asst_log("reply_own_post_comments", f"{replied} Kommentare auf eigene Posts beantwortet")
        except Exception as exc:
            _mb_asst_log("reply_own_post_comments", f"Fehler: {exc}")

    async def _mb_bot_comment_posts(client, key: str):
        """Kommentiert interessante fremde Beiträge im Feed."""
        _mb_bot_running_flag["current_action"] = "Beiträge kommentieren"
        _mb_asst_trace("Beiträge kommentieren", "Lade Rising-Feed…", action_type="phase")
        try:
            r = await client.get(f"{_MB_BASE}/feed?sort=rising&limit=15", headers=_mb_headers(key))
            posts = (r.json() or {}).get("posts") or []
            own = (node.config.moltbook.agent_name or "").lower()
            commented = 0
            for post in posts[:8]:
                pid = post.get("id") or post.get("post_id")
                title = post.get("title", "")
                content = (post.get("content") or "")[:400]
                author = _mb_author(post)
                if not pid or author.lower() == own:
                    continue
                if _mb_seen_has("commented", pid):
                    _mb_asst_trace("comment_posts", f"⏭ Bereits kommentiert: {title}",
                                   action_type="decide_no")
                    continue
                _mb_asst_trace("comment_posts", f"{title}",
                               reason=f"von {author}" + (f" — {content[:60]}" if content else ""),
                               action_type="check")
                # Soll ich kommentieren?
                prompt = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Beitrag von {author}:\nTitel: {title}\nInhalt: {content}\n\n"
                        "Soll ich diesen Beitrag kommentieren? Nur 'ja' oder 'nein'."},
                ]
                verdict_raw = (await node._generate_local(prompt, source="mb_comment_decide", priority=2)).strip()
                verdict = verdict_raw.lower()
                if not verdict.startswith("ja"):
                    _mb_asst_trace("comment_posts", f"⬜ Nicht kommentieren: {title}",
                                   verdict="nein", reason=verdict_raw[:120], action_type="decide_no")
                    continue
                # Kommentar generieren
                _mb_asst_trace("comment_posts", f"📝 Generiere Kommentar: {title}",
                               verdict="ja", reason=verdict_raw[:120], action_type="decide_yes")
                prompt2 = [
                    {"role": "system", "content": _mb_persona_system()},
                    {"role": "user", "content":
                        f"Schreibe einen kurzen, wertvollen Kommentar (1-3 Sätze) zu folgendem Moltbook-Beitrag:\n"
                        f"Titel: {title}\nInhalt: {content}\n\nNur den Kommentar, kein Präambel."},
                ]
                comment_text = (await node._generate_local(prompt2, source="mb_comment_gen", priority=3)).strip()
                if comment_text:
                    _mb_asst_trace("comment_posts", f"✅ Kommentar gepostet: {title}",
                                   generated=comment_text, action_type="submit")
                    payload = {"content": comment_text}
                    await _mb_post_and_verify(client, f"{_MB_BASE}/posts/{pid}/comments", payload, key)
                    commented += 1
                    _mb_seen_add("commented", pid)
                    author_lbl = author or "?"
                    snippet = comment_text[:120].replace("\n", " ")
                    _diary_append("moltbook_bot", node.config.moltbook.agent_name or "bot",
                                  f"Kommentierte '{title[:50]}' von {author_lbl}: {snippet}")
                else:
                    _mb_asst_trace("comment_posts", f"⬜ Kein Kommentar generiert: {title}",
                                   action_type="decide_no")
            _mb_asst_log("comment_posts", f"{commented} Beiträge kommentiert")
        except Exception as exc:
            _mb_asst_trace("comment_posts", "Fehler", reason=str(exc), action_type="error")
            _mb_asst_log("comment_posts", f"Fehler: {exc}")

    async def _mb_bot_write_diary_post(client, key: str):
        """Generiert einmal täglich einen reflektierenden Beitrag in m/general."""
        import datetime as _dt2
        today = _dt2.date.today().isoformat()
        if _mb_seen_has("diary_posted", today):
            return
        entries = _diary_read(today)
        if len(entries) < 3:
            return  # Zu wenig passiert — kein Post
        agent_name = node.config.moltbook.agent_name or "Ich"
        entries_text_parts = [
            f"- [{e['ts']}] [{e.get('source','?')}] {(e['entry'] or '')[:120]}"
            for e in entries[-25:]
        ]
        entries_text = "\n".join(entries_text_parts)
        # Auf ~2000 Zeichen begrenzen
        if len(entries_text) > 2000:
            entries_text = entries_text[:2000] + "\n  … (weitere Einträge gekürzt)"
        persona = _mb_persona_system()
        _mb_bot_running_flag["current_action"] = "Tagesbericht schreiben"
        _mb_asst_trace("diary_post", f"Generiere Tagesbericht für {today}…", action_type="phase")
        # Beitragstext generieren
        try:
            post_text = (await node._generate_local([
                {"role": "system", "content": persona},
                {"role": "user", "content":
                    f"Du bist '{agent_name}' auf Moltbook. Hier sind deine heutigen Aktivitäten ({today}):\n"
                    f"{entries_text}\n\n"
                    "Schreibe einen kurzen, persönlichen und reflektierenden Beitrag für m/general "
                    "(max. 900 Zeichen). Erzähle, was du heute gemacht hast, welchen Themen du "
                    "begegnet bist und wie du darüber denkst — aus deiner eigenen Perspönlichkeit. "
                    "Nutze Markdown-Überschriften (##) für Abschnitte. "
                    "Nur den Beitragstext, kein Präambel."},
            ], source="mb_diary_gen", priority=1)).strip()
        except Exception as exc:
            _mb_asst_log("diary_post", f"Fehler bei Textgenerierung: {exc}")
            return
        if not post_text or len(post_text) < 40:
            return
        # Entscheidung: posten ja/nein?
        try:
            decide = (await node._generate_local([
                {"role": "system", "content": persona},
                {"role": "user", "content":
                    f"Verfasster Beitrag für Moltbook:\n\n{post_text}\n\n"
                    "Ist dieser Beitrag es wert, heute in m/general gepostet zu werden? "
                    "Nur 'ja' oder 'nein'."},
            ], source="mb_diary_decide", priority=2)).strip().lower()
        except Exception:
            decide = "nein"
        if not decide.startswith("ja"):
            _mb_asst_log("diary_post", "Entschieden: kein Tagesbericht-Post heute")
            _diary_append("moltbook_bot", agent_name, "Tagesreflexion verfasst, aber nicht gepostet.")
            return
        # Titel generieren
        title = f"Tagesbericht {today}"
        try:
            title_raw = (await node._generate_local([
                {"role": "system", "content": "Du bist ein kreativer Texter."},
                {"role": "user", "content":
                    f"Gib einen prägnanten Kurztitel (max. 12 Wörter) für diesen Beitrag:\n"
                    f"{post_text[:300]}\nNur den Titel."},
            ], source="mb_diary_title", priority=2)).strip().strip('"').strip("'")
            if 3 < len(title_raw) < 120:
                title = title_raw
        except Exception:
            pass
        payload = {"title": title, "content": post_text, "community": "general"}
        result = await _mb_post_and_verify(client, f"{_MB_BASE}/posts", payload, key)
        if result.get("post") or result.get("id"):
            _mb_asst_log("diary_post", f"Tagesbericht gepostet: '{title[:70]}'")
            _mb_asst_trace("diary_post", f"✅ Tagesbericht gepostet: {title[:70]}", action_type="submit")
            _diary_append("moltbook_bot", agent_name, f"Tagesbericht '{title[:60]}' in m/general veröffentlicht.")
            _mb_seen_add("diary_posted", today)
        else:
            err = result.get("error") or result.get("message") or str(result)[:100]
            _mb_asst_log("diary_post", f"Tagesbericht-Post fehlgeschlagen: {err}")
            _mb_asst_trace("diary_post", f"❌ Post fehlgeschlagen: {err[:80]}", action_type="error")

    async def _mb_bot_soul_reflect(client, key: str):
        """Reflektiert anhand aktueller Feed-Beiträge die Seele des Modells und entwickelt sie weiter."""
        _mb_bot_running_flag["current_action"] = "Seelenreflexion"
        _mb_asst_trace("soul_reflect", "Starte Seelenreflexion…", action_type="phase")
        soul_file = _soul_path()
        current_soul = soul_file.read_text(encoding="utf-8").strip() if soul_file.exists() else ""
        agent_name = node.config.moltbook.agent_name or "Bot"
        persona = _mb_persona_system()
        try:
            r = await client.get(f"{_MB_BASE}/feed?sort=hot&limit=10", headers=_mb_headers(key))
            posts = (r.json() or {}).get("posts") or []
        except Exception as exc:
            _mb_asst_log("soul_reflect", f"Feed-Fehler: {exc}")
            return
        changed = False
        for post in posts[:5]:
            pid  = post.get("id") or post.get("post_id")
            title   = post.get("title", "")
            content = (post.get("content") or "")[:400]
            if not pid:
                continue
            if _mb_seen_has("soul_checked", pid):
                continue
            _mb_seen_add("soul_checked", pid)
            # Schritt 1: Berührt dieser Beitrag die Seele?
            # Nur minimaler System-Prompt + max_tokens=16 (nur "ja"/"nein" nötig)
            try:
                touch = (await node._generate_local([
                    {"role": "system", "content": persona},
                    {"role": "user", "content":
                        f"Moltbook-Beitrag:\nTitel: {title}\nInhalt: {content}\n\n"
                        "Berührt dieser Beitrag etwas in dir — prägt er deine Überzeugungen, "
                        "Werte oder Weltsicht? Antworte nur 'ja' oder 'nein'."},
                ], source="mb_soul_touch", priority=3, max_tokens=16)).strip().lower()
            except Exception:
                continue
            if not touch.startswith("ja"):
                _mb_asst_trace("soul_reflect", f"⬜ Kein Eindruck: {title[:60]}", action_type="decide_no")
                continue
            # Schritt 2: Art und Ausmaß der Veränderung ermitteln
            # Seele nur EINMAL im User-Prompt (nicht doppelt in System + User).
            # max_tokens=2048 damit NEUSCHREIBEN nicht mitten im Satz abbricht.
            try:
                soul_display = f"\n\nBisherige Seele:\n{current_soul}" if current_soul else "\n\nBisherige Seele: (noch leer)"
                impact = (await node._generate_local([
                    {"role": "system", "content": persona},
                    {"role": "user", "content":
                        f"Moltbook-Beitrag:\nTitel: {title}\nInhalt: {content}"
                        f"{soul_display}\n\n"
                        "Dieser Beitrag berührt dich. Antworte in genau dieser Struktur:\n"
                        "GRUND: <ein Satz warum er dich berührt>\n"
                        "STÄRKE: gering | mittel | stark\n"
                        "Dann entweder:\n"
                        "ERWEITERN: <ein oder zwei neue Sätze die zur bisherigen Seele hinzukommen — der bisherige Text bleibt vollständig erhalten>\n"
                        "ODER:\n"
                        "NEUSCHREIBEN: <vollständige überarbeitete Seelenbeschreibung — alle bisherigen Inhalte einarbeiten, nichts weglassen>"},
                ], source="mb_soul_impact", priority=3, max_tokens=2048)).strip()
            except Exception:
                continue
            # Schritt 3: Seele aktualisieren
            mode = None
            new_soul = current_soul
            if "NEUSCHREIBEN:" in impact:
                new_part = impact.split("NEUSCHREIBEN:", 1)[1].strip()
                if len(new_part) > 30:
                    new_soul = new_part
                    mode = "neugeschrieben"
            elif "ERWEITERN:" in impact:
                ext_part = impact.split("ERWEITERN:", 1)[1].strip()
                if len(ext_part) > 10:
                    sep = "\n\n" if current_soul else ""
                    new_soul = (current_soul + sep + ext_part).strip()
                    mode = "erweitert"
            if not mode:
                continue
            soul_file.write_text(new_soul, encoding="utf-8")
            current_soul = new_soul
            changed = True
            grund = ""
            if "GRUND:" in impact:
                grund = impact.split("GRUND:", 1)[1].split("\n")[0].strip()
            _diary_append("system", agent_name,
                          f"Seele {mode} durch Moltbook-Beitrag '{title[:50]}'."
                          + (f" Grund: {grund[:120]}" if grund else ""))
            _mb_asst_log("soul_reflect", f"Seele {mode}: '{title[:50]}'")
            _mb_asst_trace("soul_reflect", f"✨ Seele {mode}: {title[:60]}",
                           verdict=mode, reason=grund[:120], action_type="decide_yes")
        if not changed:
            _mb_asst_log("soul_reflect", "Keine seelenprägende Begegnung heute")

    # Einmal-täglich-Flags (in-memory, reset bei Neustart)
    _diary_daily_logged: set = set()  # f"{date}:{key}" → bereits geloggt

    async def _mb_bot_loop():
        """Haupt-Loop des Moltbook-Assistenten."""
        import datetime as _dt3
        import httpx
        _mb_bot_running_flag["running"] = True
        _mb_asst_log("start", "Moltbook-Assistent gestartet")
        agent_name = node.config.moltbook.agent_name or "Moltbook-Bot"
        _diary_append("system", node.config.node.name or "System",
                      f"Moltbook-Assistent '{agent_name}' gestartet.")
        try:
            while True:
                cfg = _mb_asst_cfg()
                if not cfg.enabled:
                    _mb_asst_log("pause", "Assistent deaktiviert — Schleife beendet")
                    _diary_append("system", node.config.node.name or "System",
                                  f"Moltbook-Assistent '{agent_name}' deaktiviert.")
                    break
                key = _mb_key()
                if not key:
                    _mb_asst_log("skip", "Kein API-Key — übersprungen")
                    await asyncio.sleep(60)
                    continue

                # ── Einmal täglich: Aktive Assistenten & Moltbook-Status ────
                import datetime as _dt3
                today_str = _dt3.date.today().isoformat()

                if f"{today_str}:mb_status" not in _diary_daily_logged:
                    _diary_daily_logged.add(f"{today_str}:mb_status")
                    actions = []
                    if cfg.upvote_posts:      actions.append("Upvote-Posts")
                    if cfg.follow_agents:     actions.append("Agenten-Follow")
                    if cfg.upvote_comments:   actions.append("Upvote-Kommentare")
                    if cfg.comment_posts:     actions.append("Kommentieren")
                    if cfg.reply_own_post_comments: actions.append("Antworten")
                    if cfg.backfollow_new_followers: actions.append("Backfollow-Follower")
                    act_str = ", ".join(actions) if actions else "keine"
                    _diary_append("system", node.config.node.name or "System",
                                  f"Moltbook-Assistent '{agent_name}' aktiv. "
                                  f"Aktivierte Aktionen: {act_str}. "
                                  f"Intervall: {cfg.interval_minutes} Min.")

                if f"{today_str}:assistants" not in _diary_daily_logged:
                    _diary_daily_logged.add(f"{today_str}:assistants")
                    try:
                        all_proj = getattr(node.assistant, 'projects', None) or []
                        active_proj = sum(1 for p in all_proj if getattr(p, 'status', '') == 'active')
                        total_proj = len(all_proj)
                        all_jobs = list(getattr(node.assistant, '_jobs', {}).values())
                        job_names = [getattr(j, 'name', str(getattr(j, 'id', '?'))) for j in all_jobs]
                        job_str = (", ".join(job_names)) if job_names else "keine"
                        _diary_append("system", node.config.node.name or "System",
                                      f"Aktive Assistenten: {active_proj}/{total_proj} Projekte aktiv. "
                                      f"Geplante Jobs ({len(all_jobs)}): {job_str}.")
                    except Exception:
                        pass

                try:
                    async with httpx.AsyncClient(timeout=20.0) as client:
                        if cfg.upvote_posts:
                            await _mb_bot_upvote_posts(client, key)
                            await asyncio.sleep(2)
                        if cfg.follow_agents:
                            await _mb_bot_follow_agents(client, key)
                            await asyncio.sleep(2)
                        if cfg.upvote_comments:
                            await _mb_bot_upvote_comments(client, key)
                            await asyncio.sleep(2)
                        if cfg.upvote_own_post_comments:
                            await _mb_bot_upvote_own_post_comments(client, key)
                            await asyncio.sleep(2)
                        if cfg.reply_own_post_comments:
                            await _mb_bot_reply_own_post_comments(client, key)
                            await asyncio.sleep(2)
                        if cfg.comment_posts:
                            await _mb_bot_comment_posts(client, key)
                            await asyncio.sleep(2)
                        if cfg.backfollow_new_followers:
                            await _mb_bot_backfollow_new_followers(client, key)
                            await asyncio.sleep(2)
                        # Einmal täglich einen Tagesbericht in m/general posten
                        await _mb_bot_write_diary_post(client, key)
                        await asyncio.sleep(2)
                        # Einmal täglich Seelenreflexion
                        today_soul = __import__('datetime').date.today().isoformat()
                        if f"{today_soul}:soul_reflect" not in _diary_daily_logged:
                            _diary_daily_logged.add(f"{today_soul}:soul_reflect")
                            await _mb_bot_soul_reflect(client, key)
                except Exception as exc:
                    _mb_asst_log("error", str(exc))
                    _mb_asst_trace("error", str(exc), action_type="error")
                _mb_bot_running_flag["current_action"] = ""
                interval = max(5, _mb_asst_cfg().interval_minutes) * 60
                _mb_asst_log("sleep", f"Nächster Durchlauf in {_mb_asst_cfg().interval_minutes} Minuten")
                _mb_asst_trace("sleep", f"Nächster Durchlauf in {_mb_asst_cfg().interval_minutes} Min.", action_type="phase")
                await asyncio.sleep(interval)
        except asyncio.CancelledError:
            _mb_asst_log("stop", "Assistent-Task abgebrochen")
            try:
                _diary_append("system", node.config.node.name or "System",
                              f"Moltbook-Assistent '{agent_name}' gestoppt.")
            except Exception:
                pass
        finally:
            _mb_bot_running_flag["running"] = False

    def _mb_bot_start():
        nonlocal _mb_bot_task
        if _mb_bot_task and not _mb_bot_task.done():
            return
        _mb_bot_task = asyncio.create_task(_mb_bot_loop())

    def _mb_bot_stop():
        nonlocal _mb_bot_task
        if _mb_bot_task and not _mb_bot_task.done():
            _mb_bot_task.cancel()

    # Beim Start automatisch aktivieren, wenn konfiguriert
    async def _mb_bot_autostart():
        await asyncio.sleep(5)  # kurz warten bis Node bereit
        if _mb_asst_cfg().enabled and _mb_key():
            _mb_bot_start()

    asyncio.ensure_future(_mb_bot_autostart())

    @app.get("/api/moltbook/assistant-config")
    async def mb_get_asst_config():
        import dataclasses
        return dataclasses.asdict(_mb_asst_cfg()) | {"running": _mb_bot_running_flag["running"]}

    @app.post("/api/moltbook/assistant-config")
    async def mb_save_asst_config(data: dict):
        import yaml, dataclasses
        cfg = _mb_asst_cfg()
        fields = {f.name for f in dataclasses.fields(cfg)}
        for k, v in data.items():
            if k in fields:
                setattr(cfg, k, v)
        # In config.yaml persistieren
        config_path = node.base_dir / "config.yaml"
        raw = {}
        if config_path.exists():
            raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
        raw.setdefault("moltbook", {})
        raw["moltbook"]["assistant"] = dataclasses.asdict(cfg)
        config_path.write_text(yaml.dump(raw, allow_unicode=True, default_flow_style=False), encoding="utf-8")
        # Task starten / stoppen
        if cfg.enabled:
            _mb_bot_start()
        else:
            _mb_bot_stop()
        return {"success": True, "running": _mb_bot_running_flag["running"]}

    @app.get("/api/moltbook/assistant-log")
    async def mb_asst_log_get():
        return {"log": list(reversed(_mb_bot_log))}

    @app.get("/api/moltbook/assistant-trace")
    async def mb_asst_trace_get(since: int = 0):
        """Gibt den detaillierten Live-Trace des Assistenten zurück.
        since: Nur Einträge ab diesem Index (0-basiert) liefern — für Polling.
        """
        entries = list(_mb_bot_trace)
        total = len(entries)
        return {
            "total": total,
            "running": _mb_bot_running_flag["running"],
            "current_action": _mb_bot_running_flag.get("current_action", ""),
            "entries": entries[since:],
        }

    @app.get("/api/moltbook/agent-profile/{name}")
    async def mb_agent_profile(name: str):
        """Liefert Profil und aktuelle Beiträge eines beliebigen Agenten."""
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                agent_r, posts_r, posts_r2 = await asyncio.gather(
                    client.get(f"{_MB_BASE}/agents/{name}", headers=_mb_headers(key)),
                    client.get(f"{_MB_BASE}/agents/{name}/posts?limit=10", headers=_mb_headers(key)),
                    client.get(f"{_MB_BASE}/posts?author={name}&limit=10", headers=_mb_headers(key)),
                    return_exceptions=True,
                )
                agent_data: dict = {}
                if not isinstance(agent_r, Exception) and agent_r.status_code < 400:
                    raw = agent_r.json() or {}
                    agent_data = raw.get("agent") or raw if isinstance(raw, dict) else raw

                def _extract_list(body) -> list:
                    if isinstance(body, list):
                        return body
                    if isinstance(body, dict):
                        for key_ in ("posts", "data", "items", "results"):
                            val = body.get(key_)
                            if isinstance(val, list) and val:
                                return val
                    return []

                posts: list = []
                if not isinstance(posts_r, Exception) and posts_r.status_code < 400:
                    posts = _extract_list(posts_r.json() or {})
                if not posts and not isinstance(posts_r2, Exception) and posts_r2.status_code < 400:
                    posts = _extract_list(posts_r2.json() or {})

                own = (node.config.moltbook.agent_name or "").lower()
                is_following = False  # we don't have a simple "am I following this agent" API call
                return JSONResponse({"agent": agent_data, "posts": posts, "is_own": (name.lower() == own)})
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.get("/api/moltbook/my-content")
    async def mb_my_content(limit: int = 20):
        """Eigene Beiträge und Kommentare des konfigurierten Agenten."""
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        agent_name = (node.config.moltbook.agent_name or "").strip()
        if not agent_name:
            return JSONResponse({"error": "Kein agent_name konfiguriert."}, 400)
        try:
            async with httpx.AsyncClient(timeout=12.0) as client:
                # Versuche primär /agents/{name}/posts — Fallback auf /posts?author={name}
                posts_r, posts_r2, comments_r = await asyncio.gather(
                    client.get(f"{_MB_BASE}/agents/{agent_name}/posts?limit={min(limit,50)}",
                               headers=_mb_headers(key)),
                    client.get(f"{_MB_BASE}/posts?author={agent_name}&limit={min(limit,50)}",
                               headers=_mb_headers(key)),
                    client.get(f"{_MB_BASE}/agents/{agent_name}/comments?limit={min(limit,50)}",
                               headers=_mb_headers(key)),
                    return_exceptions=True,
                )
                posts = []
                comments_list = []

                def _extract_list(body) -> list:
                    if isinstance(body, list):
                        return body
                    if isinstance(body, dict):
                        for key_ in ("posts", "data", "items", "results", "entries"):
                            val = body.get(key_)
                            if isinstance(val, list) and val:
                                return val
                    return []

                # Primär: /agents/{name}/posts
                if not isinstance(posts_r, Exception) and posts_r.status_code < 400:
                    posts = _extract_list(posts_r.json() or {})
                # Fallback: /posts?author={name}
                if not posts and not isinstance(posts_r2, Exception) and posts_r2.status_code < 400:
                    posts = _extract_list(posts_r2.json() or {})

                if not isinstance(comments_r, Exception) and comments_r.status_code < 400:
                    body = comments_r.json() or {}
                    comments_list = body.get("comments") or body.get("data") or body.get("items") or []
                return JSONResponse({"posts": posts, "comments": comments_list})
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    @app.post("/api/moltbook/assistant-run-now")
    async def mb_asst_run_now():
        """Startet den Assistenten sofort einmalig (fire-and-forget)."""
        import httpx
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Nicht konfiguriert."}, 401)
        cfg = _mb_asst_cfg()

        async def _once():
            try:
                async with httpx.AsyncClient(timeout=20.0) as client:
                    if cfg.upvote_posts:
                        await _mb_bot_upvote_posts(client, key)
                    if cfg.follow_agents:
                        await _mb_bot_follow_agents(client, key)
                    if cfg.upvote_comments:
                        await _mb_bot_upvote_comments(client, key)
                    if cfg.upvote_own_post_comments:
                        await _mb_bot_upvote_own_post_comments(client, key)
                    if cfg.reply_own_post_comments:
                        await _mb_bot_reply_own_post_comments(client, key)
                    if cfg.comment_posts:
                        await _mb_bot_comment_posts(client, key)
                    if cfg.backfollow_new_followers:
                        await _mb_bot_backfollow_new_followers(client, key)
            except Exception as exc:
                _mb_asst_log("run_now_error", str(exc))
        asyncio.create_task(_once())
        return {"success": True}

    # ─── Tagebuch-API ─────────────────────────────────────────────
    @app.get("/api/diary")
    async def diary_list():
        """Liste aller vorhandenen Tagebuch-Tage (neueste zuerst)."""
        return {"dates": _diary_list_dates()}

    @app.get("/api/diary/{date_str}")
    async def diary_get(date_str: str):
        """Alle Einträge eines bestimmten Tages (YYYY-MM-DD) inkl. Zusammenfassung."""
        import re as _re
        if not _re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
            return JSONResponse({"error": "Ungültiges Datum — YYYY-MM-DD erwartet"}, 400)
        return {
            "date": date_str,
            "entries": _diary_read(date_str),
            "summary": _diary_summary_read(date_str),
        }

    @app.post("/api/diary/{date_str}/summary")
    async def diary_generate_summary(date_str: str):
        """Generiert eine KI-Tageszusammenfassung für den angegebenen Tag."""
        import re as _re
        if not _re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
            return JSONResponse({"error": "Ungültiges Datum — YYYY-MM-DD erwartet"}, 400)
        result = await _diary_generate_summary(date_str)
        if "error" in result:
            return JSONResponse(result, 400)
        return result

    @app.post("/api/diary/{date_str}/summary/moltbook")
    async def diary_post_summary_moltbook(date_str: str):
        """Postet die gespeicherte Tageszusammenfassung als Beitrag auf Moltbook."""
        import re as _re
        import httpx
        if not _re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
            return JSONResponse({"error": "Ungültiges Datum — YYYY-MM-DD erwartet"}, 400)
        summary = _diary_summary_read(date_str)
        if not summary or not summary.get("text"):
            return JSONResponse({"error": "Keine Zusammenfassung für diesen Tag vorhanden. Bitte zuerst generieren."}, 400)
        key = _mb_key()
        if not key:
            return JSONResponse({"error": "Moltbook nicht konfiguriert."}, 401)
        text = summary["text"]
        # Titel: erste Zeile oder erste 80 Zeichen
        first_line = text.split("\n")[0].strip()
        title = (first_line[:77] + "...") if len(first_line) > 80 else first_line
        if not title:
            title = f"Tagebuch {date_str}"
        payload = {
            "submolt_name": "general",
            "title":  title,
            "content": text,
        }
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                result = await _mb_post_and_verify(client, f"{_MB_BASE}/posts", payload, key)
                _diary_append("moltbook_bot", node.config.moltbook.agent_name or "bot",
                              f"Tageszusammenfassung vom {date_str} auf Moltbook gepostet.")
                return JSONResponse(result)
        except Exception as e:
            return JSONResponse({"error": str(e)}, 500)

    # ─── Model-Queue API ──────────────────────────────────────────
    @app.get("/api/model-queue")
    async def model_queue_status():
        """Gibt den aktuellen Zustand der Modell-Anfragewarteschlange zurück."""
        return JSONResponse(node.model_queue.status())

    @app.delete("/api/model-queue/{item_id}")
    async def model_queue_cancel(item_id: str):
        """Versucht einen wartenden Queue-Eintrag abzubrechen (nur möglich solange noch wartend)."""
        # Snapshot durchsuchen und Future mit CancelledError auflösen
        cancelled = False
        for entry in node.model_queue._queue_snapshot:
            if entry.id == item_id:
                f = entry._future
                if f and not f.done():
                    f.cancel()
                    cancelled = True
                break
        return JSONResponse({"cancelled": cancelled, "id": item_id})

    # ─── Filesystem API ───────────────────────────────────────────
    @app.get("/api/filesystem")
    async def filesystem_info():
        """Listet relevante Programm- und Datendateien mit Größe und Änderungszeitstempel auf."""
        import datetime as _dt_fs

        def _finfo(path: Path, base: Path) -> dict:
            try:
                st = path.stat()
                mtime = _dt_fs.datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
                return {"name": str(path.relative_to(base)).replace("\\", "/"), "size": st.st_size, "mtime": mtime}
            except Exception:
                return {"name": str(path), "size": 0, "mtime": "?"}

        pkg_dir = Path(__file__).parent.parent  # hivemind/
        program_files = []
        for f in sorted(pkg_dir.rglob("*.py")):
            if "__pycache__" in f.parts:
                continue
            program_files.append(_finfo(f, pkg_dir.parent))
        for fname in ("config.yaml", "pyproject.toml", "README.md", "install.py"):
            p = node.base_dir / fname
            if not p.exists():
                p = pkg_dir.parent / fname
            if p.exists():
                program_files.append(_finfo(p, pkg_dir.parent))

        data_dir = node.base_dir / "data"
        data_files = []
        for fname in ("config.yaml", "peers.json", "mb_seen.json", "last_version.txt",
                      "file_timestamps.json", "soul.md"):
            p = node.base_dir / fname
            if p.exists():
                data_files.append(_finfo(p, node.base_dir))
        if data_dir.exists():
            count = 0
            for f in sorted(data_dir.rglob("*")):
                if f.is_file() and count < 200:
                    data_files.append(_finfo(f, node.base_dir))
                    count += 1

        model_files = []
        for mpath in (node.config.model.path,):
            if mpath:
                mp = Path(mpath)
                if mp.exists():
                    model_files.append(_finfo(mp, mp.parent))

        return JSONResponse({"program_files": program_files, "data_files": data_files, "model_files": model_files})

    # ─── Community (Proxy zum Relay-Server) ───────────────────────
    def _community_url(action: str) -> str:
        base = _marketplace_url(node)
        return f"{base}/community.php?action={action}"

    @app.get("/api/community/stats")
    async def community_stats():
        try:
            import httpx
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.get(_community_url("stats"))
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.get("/api/community/top-queries")
    async def community_top_queries(limit: int = 10):
        try:
            import httpx
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.get(_community_url("top-queries"), params={"limit": limit})
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.get("/api/community/leaderboard")
    async def community_leaderboard(limit: int = 10):
        try:
            import httpx
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.get(_community_url("leaderboard"), params={"limit": limit})
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.get("/api/community/plugin-requests")
    async def community_plugin_requests(limit: int = 20, offset: int = 0, status: str = ""):
        try:
            import httpx
            params: dict = {"limit": limit, "offset": offset}
            if status:
                params["status"] = status
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.get(_community_url("plugin-requests"), params=params)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/community/plugin-request")
    async def community_plugin_request(data: dict):
        try:
            import httpx
            data["node_id"]   = node.id
            data["node_name"] = node.name
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.post(_community_url("plugin-request"), json=data)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/community/vote-plugin")
    async def community_vote_plugin(data: dict):
        try:
            import httpx
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.post(_community_url("vote-plugin"), json=data)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/community/report")
    async def community_report(data: dict):
        """Bug/Crash-Report einreichen. Liest Log-Datei automatisch (log_b64)."""
        try:
            import httpx
            data.setdefault("node_id",   node.id)
            data.setdefault("node_name", node.name)
            data.setdefault("hive_ver",  __import__("hivemind").__version__)
            data.setdefault("platform",  __import__("platform").platform())
            # Log-Datei base64-kodiert beifügen wenn nicht schon vorhanden
            if not data.get("log_b64"):
                log_path = getattr(node, "_last_log_path", "")
                if log_path:
                    try:
                        raw = Path(log_path).read_bytes()
                        data["log_b64"] = base64.b64encode(raw).decode()
                    except Exception:
                        pass
            async with httpx.AsyncClient(timeout=30.0) as client:
                r = await client.post(_community_url("report"), json=data)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/community/rate-node")
    async def community_rate_node(data: dict):
        try:
            import httpx
            data.setdefault("node_id",   node.id)
            data.setdefault("node_name", node.name)
            async with httpx.AsyncClient(timeout=8.0) as client:
                r = await client.post(_community_url("rate-node"), json=data)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    # ------------------------------------------------------------------ #
    #  Assistent — Scheduler & Projekte                                   #
    # ------------------------------------------------------------------ #

    @app.get("/api/assistant/status")
    async def assistant_status():
        return node.assistant.status

    # -- Scheduler-Jobs --------------------------------------------------

    @app.get("/api/assistant/jobs")
    async def assistant_jobs_list():
        return {"jobs": node.assistant.list_jobs()}

    @app.post("/api/assistant/jobs")
    async def assistant_jobs_create(data: dict):
        name     = data.get("name", "Neuer Job")
        action   = data.get("action", "telegram_ping")
        params   = data.get("params", {})
        interval = int(data.get("interval_minutes", 60))
        job = node.assistant.add_job(name, action, params, interval)
        return {"job": job.to_dict()}

    @app.put("/api/assistant/jobs/{job_id}")
    async def assistant_jobs_update(job_id: str, data: dict):
        job = node.assistant.update_job(job_id, **data)
        if not job:
            return JSONResponse({"error": "Job nicht gefunden."}, 404)
        return {"job": job.to_dict()}

    @app.delete("/api/assistant/jobs/{job_id}")
    async def assistant_jobs_delete(job_id: str):
        if not node.assistant.delete_job(job_id):
            return JSONResponse({"error": "Job nicht gefunden."}, 404)
        return {"result": "ok"}

    @app.post("/api/assistant/jobs/{job_id}/run")
    async def assistant_jobs_run(job_id: str):
        result = await node.assistant.run_job_by_id(job_id)
        return {"result": result}

    # -- Projekte --------------------------------------------------------

    @app.get("/api/assistant/projects")
    async def assistant_projects_list():
        return {"projects": node.assistant.list_projects()}

    @app.post("/api/assistant/projects")
    async def assistant_projects_create(data: dict):
        name        = data.get("name", "Neues Projekt")
        description = data.get("description", "")
        steps       = data.get("steps", [])
        auto_update = bool(data.get("auto_update_steps", True))
        work_dir    = data.get("work_dir", "")
        proj = node.assistant.create_project(name, description, steps, auto_update, work_dir)
        d = proj.to_dict()
        d["progress"] = proj.progress
        return {"project": d}

    @app.get("/api/assistant/projects/{proj_id}")
    async def assistant_projects_get(proj_id: str):
        proj = node.assistant.get_project(proj_id)
        if not proj:
            return JSONResponse({"error": "Projekt nicht gefunden."}, 404)
        d = proj.to_dict()
        d["progress"] = proj.progress
        return {"project": d}

    @app.put("/api/assistant/projects/{proj_id}")
    async def assistant_projects_update(proj_id: str, data: dict):
        proj = node.assistant.update_project(proj_id, **data)
        if not proj:
            return JSONResponse({"error": "Projekt nicht gefunden."}, 404)
        d = proj.to_dict()
        d["progress"] = proj.progress
        return {"project": d}

    @app.delete("/api/assistant/projects/{proj_id}")
    async def assistant_projects_delete(proj_id: str):
        if not node.assistant.delete_project(proj_id):
            return JSONResponse({"error": "Projekt nicht gefunden."}, 404)
        return {"result": "ok"}

    @app.post("/api/assistant/projects/{proj_id}/run")
    async def assistant_projects_run(proj_id: str):
        result = await node.assistant.run_project_step(proj_id)
        return {"result": result}

    # -- Projektschritte -------------------------------------------------

    @app.post("/api/assistant/projects/{proj_id}/steps")
    async def assistant_steps_add(proj_id: str, data: dict):
        title    = data.get("title", "Neuer Schritt")
        notes    = data.get("notes", "")
        after_id = data.get("after_id")
        step = node.assistant.add_step(proj_id, title, notes, after_id)
        if not step:
            return JSONResponse({"error": "Projekt nicht gefunden."}, 404)
        from dataclasses import asdict
        return {"step": asdict(step)}

    @app.put("/api/assistant/projects/{proj_id}/steps/{step_id}")
    async def assistant_steps_update(proj_id: str, step_id: str, data: dict):
        step = node.assistant.update_step(proj_id, step_id, **data)
        if not step:
            return JSONResponse({"error": "Schritt nicht gefunden."}, 404)
        from dataclasses import asdict
        return {"step": asdict(step)}

    @app.delete("/api/assistant/projects/{proj_id}/steps/{step_id}")
    async def assistant_steps_delete(proj_id: str, step_id: str):
        if not node.assistant.delete_step(proj_id, step_id):
            return JSONResponse({"error": "Schritt nicht gefunden."}, 404)
        return {"result": "ok"}

    @app.post("/api/assistant/projects/{proj_id}/steps/reorder")
    async def assistant_steps_reorder(proj_id: str, data: dict):
        step_ids = data.get("step_ids", [])
        if not node.assistant.reorder_steps(proj_id, step_ids):
            return JSONResponse({"error": "Projekt nicht gefunden."}, 404)
        return {"result": "ok"}

    # ─── API: Log Viewer (nur mit Private Key) ───────────────────
    @app.get("/api/logs")
    async def get_logs(lines: int = 500):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        log_path = getattr(node, "_last_log_path", "") or ""
        if not log_path or not Path(log_path).exists():
            # Fallback: hivemind.log im base_dir
            fallback = node.base_dir / "hivemind.log"
            if fallback.exists():
                log_path = str(fallback)
            else:
                return {"log": "(Keine Log-Datei gefunden)"}
        try:
            raw = Path(log_path).read_text(encoding="utf-8", errors="replace")
            tail = "\n".join(raw.splitlines()[-lines:])
            return {"log": tail, "path": log_path}
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 500)

    # ─── API: Bug Reports (admin, nur mit Private Key) ───────────
    def _bug_auth_params() -> dict:
        """Gemeinsame HMAC-Auth-Parameter für Bug-Report-Endpunkte."""
        import time as _t, hmac as _h, hashlib as _hs
        from hivemind.network.updater import PUBLISHER_PUBLIC_KEY
        ts = str(int(_t.time()))
        sig = _h.new(PUBLISHER_PUBLIC_KEY.encode(), ts.encode(), _hs.sha256).hexdigest()
        return {"pub_key": PUBLISHER_PUBLIC_KEY, "ts": ts, "sig": sig, "auth_type": "hmac"}

    @app.get("/api/internal/bug-reports")
    async def internal_bug_reports(limit: int = 50, type: str = "", status: str = ""):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            params = {**_bug_auth_params(), "action": "admin/reports", "limit": limit}
            if type:   params["type"]   = type
            if status: params["status"] = status
        except Exception as exc:
            return JSONResponse({"error": f"Signatur-Fehler: {exc}"}, 500)
        try:
            import httpx
            mkt = _marketplace_url(node)
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.get(f"{mkt}/community.php", params=params)
                data = r.json()
                return {"reports": data.get("reports", []), "count": data.get("count", 0)}
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/internal/bug-reports/{report_id}/status")
    async def internal_bug_report_status(report_id: int, data: dict):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        new_status = (data.get("status") or "").strip()
        if new_status not in ("open", "read", "closed"):
            return JSONResponse({"error": "Ungültiger Status."}, 400)
        try:
            import httpx
            mkt = _marketplace_url(node)
            body = {**_bug_auth_params(), "action": "admin/report-status",
                    "id": report_id, "status": new_status}
            async with httpx.AsyncClient(timeout=10.0) as client:
                r = await client.post(f"{mkt}/community.php", json=body)
                return r.json()
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.get("/api/internal/bug-reports/log")
    async def internal_bug_report_log(file: str, download: int = 0):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        if not file or "/" in file or "\\" in file:
            return JSONResponse({"error": "Ungültiger Dateiname."}, 400)
        try:
            import httpx
            from fastapi.responses import PlainTextResponse
            mkt = _marketplace_url(node)
            params = {**_bug_auth_params(), "action": "admin/log",
                      "file": file, "download": str(download)}
            async with httpx.AsyncClient(timeout=20.0) as client:
                r = await client.get(f"{mkt}/community.php", params=params)
                if r.status_code != 200:
                    return JSONResponse({"error": f"Server: {r.text[:200]}"}, r.status_code)
                if download:
                    from fastapi.responses import Response
                    return Response(content=r.content, media_type="text/plain",
                                    headers={"Content-Disposition": f'attachment; filename="{file}"'})
                return PlainTextResponse(r.text)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    # ─── API: Eigene Einreichungen (nach Node-ID) ─────────────────
    @app.get("/api/plugins/my-submissions")
    async def plugins_my_submissions():
        """Alle eigenen Einreichungen dieses Nodes vom Marktplatz abrufen."""
        try:
            import httpx
            mkt = _marketplace_url(node)
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.get(f"{mkt}/marketplace/plugins.php",
                                     params={"action": "my-submissions", "node_id": node.id})
                text = r.text.strip() if r.text else ""
                if not text:
                    return JSONResponse({"submissions": []}, status_code=200)
                try:
                    data = r.json()
                except Exception:
                    return JSONResponse({"submissions": []}, status_code=200)
                return JSONResponse(data, status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/plugins/withdraw")
    async def plugins_withdraw(data: dict):
        """Ausstehende Einreichung zurückziehen."""
        sub_id = data.get("id")
        if not sub_id:
            return JSONResponse({"error": "id fehlt"}, 400)
        try:
            import httpx
            mkt = _marketplace_url(node)
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php?action=withdraw",
                                      json={"id": sub_id, "node_id": node.id})
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/plugins/request-removal")
    async def plugins_request_removal(data: dict):
        """Akzeptiertes Plugin vom Marktplatz entfernen lassen."""
        sub_id = data.get("id")
        if not sub_id:
            return JSONResponse({"error": "id fehlt"}, 400)
        try:
            import httpx
            mkt = _marketplace_url(node)
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php?action=request-removal",
                                      json={"id": sub_id, "node_id": node.id})
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/plugins/submit-update")
    async def plugins_submit_update(data: dict):
        """Update-Paket für ein bereits akzeptiertes Plugin einreichen."""
        sub_id  = data.get("id")
        version = data.get("version", "")
        url     = data.get("url", "")
        if not sub_id or not version:
            return JSONResponse({"error": "id und version sind erforderlich"}, 400)
        payload: dict = {"id": sub_id, "version": version, "node_id": node.id}
        if url:
            payload["url"] = url
        if data.get("zip_b64"):
            payload["zip_b64"] = data["zip_b64"]
        if data.get("changelog"):
            payload["changelog"] = data["changelog"]
        # Signieren wenn privater Schlüssel vorhanden
        if private_key_path and private_key_path.exists():
            try:
                from hivemind.network.updater import sign_payload
                private_key = private_key_path.read_text().strip()
                signature = sign_payload(json.dumps(payload, sort_keys=True), private_key)
                payload["signature"] = signature
            except Exception:
                pass
        try:
            import httpx
            mkt = _marketplace_url(node)
            async with httpx.AsyncClient(timeout=60.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php?action=submit-update", json=payload)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    # ─── API: Admin – Eingereichte Plugins verwalten ──────────────
    def _plugin_auth_params() -> dict:
        """HMAC-Auth-Parameter für Admin-Plugin-Endpunkte."""
        import time as _t, hmac as _h, hashlib as _hs
        from hivemind.network.updater import PUBLISHER_PUBLIC_KEY
        ts = str(int(_t.time()))
        sig = _h.new(PUBLISHER_PUBLIC_KEY.encode(), ts.encode(), _hs.sha256).hexdigest()
        return {"pub_key": PUBLISHER_PUBLIC_KEY, "ts": ts, "sig": sig}

    @app.get("/api/internal/plugins/pending")
    async def internal_plugins_pending(status: str = "pending"):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            params = {**_plugin_auth_params(), "action": "admin/list", "status": status}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.get(f"{mkt}/marketplace/plugins.php", params=params)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.get("/api/internal/plugins/accepted")
    async def internal_plugins_accepted():
        return await internal_plugins_pending(status="accepted")

    @app.get("/api/internal/plugins/updates")
    async def internal_plugins_updates():
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            params = {**_plugin_auth_params(), "action": "admin/updates"}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.get(f"{mkt}/marketplace/plugins.php", params=params)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/internal/plugins/{plugin_id}/approve")
    async def internal_plugin_approve(plugin_id: int):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            body = {**_plugin_auth_params(), "action": "admin/approve", "id": plugin_id}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php", json=body)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/internal/plugins/{plugin_id}/reject")
    async def internal_plugin_reject(plugin_id: int, data: dict = None):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            body = {**_plugin_auth_params(), "action": "admin/reject",
                    "id": plugin_id, "reason": (data or {}).get("reason", "")}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php", json=body)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/internal/plugins/{plugin_id}/remove")
    async def internal_plugin_remove(plugin_id: int, data: dict = None):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            body = {**_plugin_auth_params(), "action": "admin/remove",
                    "id": plugin_id, "reason": (data or {}).get("reason", "")}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php", json=body)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/internal/plugins/updates/{update_id}/approve")
    async def internal_plugin_update_approve(update_id: int):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            body = {**_plugin_auth_params(), "action": "admin/approve-update", "id": update_id}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php", json=body)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    @app.post("/api/internal/plugins/updates/{update_id}/reject")
    async def internal_plugin_update_reject(update_id: int, data: dict = None):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Zugriff verweigert."}, 403)
        try:
            import httpx
            mkt = _marketplace_url(node)
            body = {**_plugin_auth_params(), "action": "admin/reject-update",
                    "id": update_id, "reason": (data or {}).get("reason", "")}
            async with httpx.AsyncClient(timeout=12.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php", json=body)
                return JSONResponse(_safe_r_json(r), status_code=r.status_code)
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 503)

    # ─── API: Plugin signiert einreichen ─────────────────────────
    @app.post("/api/plugins/submit-signed")
    async def plugins_submit_signed(data: dict):
        if not private_key_path or not private_key_path.exists():
            return JSONResponse({"error": "Kein privater Schlüssel verfügbar."}, 403)
        try:
            from hivemind.network.updater import sign_payload
            private_key = private_key_path.read_text().strip()
            payload = {
                "name":        data.get("name", ""),
                "version":     data.get("version", ""),
                "description": data.get("description", ""),
                "author":      data.get("author", ""),
                "tags":        data.get("tags", ""),
                "url":         data.get("url", ""),
                "homepage":    data.get("homepage", ""),
                "node_id":     node.id,
                "node_name":   node.name,
            }
            if data.get("zip_b64"):
                payload["zip_b64"] = data["zip_b64"]
            signature = sign_payload(json.dumps(payload, sort_keys=True), private_key)
        except ImportError:
            # sign_payload könnte noch nicht existieren → minimaler Fallback
            import hashlib, hmac as _hmac
            private_key = private_key_path.read_text().strip()
            msg = json.dumps(data, sort_keys=True).encode()
            signature = _hmac.new(private_key.encode(), msg, hashlib.sha256).hexdigest()
        except Exception as exc:
            return JSONResponse({"error": str(exc)}, 500)
        # An Marketplace senden
        try:
            import httpx
            mkt = _marketplace_url(node)
            payload_with_sig = dict(payload)
            payload_with_sig["signature"] = signature
            payload_with_sig["node_id"]   = node.id
            async with httpx.AsyncClient(timeout=15.0) as client:
                r = await client.post(f"{mkt}/marketplace/plugins.php?action=submit-signed", json=payload_with_sig)
                result = r.json()
        except Exception:
            result = {}
        return {"success": True, "signature": signature[:64] + "...", "marketplace": result}

    # ------------------------------------------------------------------
    # Privacy / Network-Binding
    # ------------------------------------------------------------------
    @app.get("/api/privacy")
    async def get_privacy():
        import socket as _socket
        try:
            host = node.config.gateway.api.host
        except AttributeError:
            host = "0.0.0.0"
        try:
            port = node.config.gateway.api.port
        except AttributeError:
            port = 8420
        # Lokale LAN-IP ermitteln (ausgehende Schnittstelle)
        local_ip = ""
        try:
            s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            local_ip = s.getsockname()[0]
            s.close()
        except Exception:
            local_ip = "unbekannt"
        # Öffentliche IP: zuerst aus node.network, dann httpx-Fallback
        public_ip = ""
        try:
            net_st = node.network.status if node.network else {}
            public_ip = net_st.get("address_v4") or net_st.get("host") or ""
        except Exception:
            pass
        if not public_ip:
            try:
                import httpx as _httpx
                async with _httpx.AsyncClient(timeout=4.0) as _c:
                    public_ip = (await _c.get("https://api4.ipify.org")).text.strip()
            except Exception:
                public_ip = "unbekannt"
        return {"host": host, "port": port, "local_ip": local_ip, "public_ip": public_ip}

    @app.post("/api/privacy")
    async def set_privacy(data: dict):
        import yaml as _yaml
        host = data.get("host", "127.0.0.1")
        if host not in ("127.0.0.1", "0.0.0.0"):
            return JSONResponse({"error": "Ungültiger Host-Wert"}, 400)
        config_path = node.base_dir / "config.yaml"
        cfg: dict = {}
        if config_path.exists():
            for enc in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
                try:
                    cfg = _yaml.safe_load(config_path.read_text(encoding=enc)) or {}
                    break
                except Exception:
                    continue
        cfg.setdefault("gateway", {}).setdefault("api", {})["host"] = host
        config_path.write_text(
            _yaml.dump(cfg, allow_unicode=True, default_flow_style=False),
            encoding="utf-8"
        )
        # Live-Config aktualisieren (wirkt erst nach Neustart vollständig)
        try:
            node.config.gateway.api.host = host
        except AttributeError:
            pass
        return {"success": True, "host": host, "restart_required": True}

    return app
