commit 569a6022feff974e2bf0711334f4a42c97f68d28 Author: Nico Date: Fri Mar 27 23:21:51 2026 +0100 cognitive agent runtime v0.4.6: 3-node graph + Zitadel auth + K3s deploy - Input/Output/Memorizer nodes with OpenRouter (Gemini Flash) - Zitadel OIDC auth with PKCE flow, service token for Titan - SSE event stream + poll endpoint for external observers - Identity from Zitadel userinfo, listener context in Input prompt - Trace logging to file + SSE broadcast - K3s deployment on IONOS with Let's Encrypt TLS - Frontend: chat + trace view, OIDC login Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f316d07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +trace.jsonl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f316d07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +trace.jsonl diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c96a8b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "agent:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..15057d4 --- /dev/null +++ b/agent.py @@ -0,0 +1,596 @@ +""" +Cognitive Agent Runtime — Phase A.2: Three-node graph (Input → Output + Memorizer). +Input decides WHAT to do. Output executes and streams. +Memorizer holds shared state (S2 — coordination). +""" + +import asyncio +import json +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, Query +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.staticfiles import StaticFiles + +from dotenv import load_dotenv +load_dotenv(Path(__file__).parent / ".env") + +# --- Config --- + +API_KEY = os.environ["OPENROUTER_API_KEY"] +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" + +# --- Auth (Zitadel OIDC) --- + +ZITADEL_ISSUER = os.environ.get("ZITADEL_ISSUER", "https://auth.loop42.de") +ZITADEL_CLIENT_ID = os.environ.get("ZITADEL_CLIENT_ID", "365996029172056091") +ZITADEL_PROJECT_ID = os.environ.get("ZITADEL_PROJECT_ID", "365995955654230043") +AUTH_ENABLED = os.environ.get("AUTH_ENABLED", "false").lower() == "true" +SERVICE_TOKENS = set(filter(None, os.environ.get("SERVICE_TOKENS", "").split(","))) + +_jwks_cache: dict = {"keys": [], "fetched_at": 0} + +async def _get_jwks(): + if time.time() - _jwks_cache["fetched_at"] < 3600: + return _jwks_cache["keys"] + async with httpx.AsyncClient() as client: + resp = await client.get(f"{ZITADEL_ISSUER}/oauth/v2/keys") + _jwks_cache["keys"] = resp.json()["keys"] + _jwks_cache["fetched_at"] = time.time() + return _jwks_cache["keys"] + +async def _validate_token(token: str) -> dict: + """Validate token: check service tokens, then JWT, then introspection.""" + import base64 + + # Check static service tokens (for machine accounts like titan) + if token in SERVICE_TOKENS: + return {"sub": "titan", "username": "titan", "source": "service_token"} + + # Try JWT validation first + try: + parts = token.split(".") + if len(parts) == 3: + keys = await _get_jwks() + header_b64 = parts[0] + "=" * (4 - len(parts[0]) % 4) + header = json.loads(base64.urlsafe_b64decode(header_b64)) + kid = header.get("kid") + key = next((k for k in keys if k["kid"] == kid), None) + if key: + import jwt as pyjwt + from jwt import PyJWK + jwk_obj = PyJWK(key) + claims = pyjwt.decode( + token, jwk_obj.key, algorithms=["RS256"], + issuer=ZITADEL_ISSUER, options={"verify_aud": False}, + ) + return claims + except Exception: + pass + + # Fall back to introspection (for opaque access tokens) + # Zitadel requires client_id + client_secret or JWT profile for introspection + # For a public SPA client, use the project's API app instead + # Simplest: check via userinfo endpoint with the token + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{ZITADEL_ISSUER}/oidc/v1/userinfo", + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code == 200: + info = resp.json() + log.info(f"[auth] userinfo response: {info}") + return {"sub": info.get("sub"), "preferred_username": info.get("preferred_username"), + "email": info.get("email"), "name": info.get("name"), "source": "userinfo"} + + raise HTTPException(status_code=401, detail="Invalid token") + +_bearer = HTTPBearer(auto_error=False) + +async def require_auth(credentials: HTTPAuthorizationCredentials | None = Depends(_bearer)): + """Dependency: require valid JWT when AUTH_ENABLED.""" + if not AUTH_ENABLED: + return {"sub": "anonymous"} + if not credentials: + raise HTTPException(status_code=401, detail="Missing token") + return await _validate_token(credentials.credentials) + +async def ws_auth(token: str | None = Query(None)) -> dict: + """Validate WebSocket token from query param.""" + if not AUTH_ENABLED: + return {"sub": "anonymous"} + if not token: + return None # Will reject in ws_endpoint + return await _validate_token(token) + +# --- LLM helper --- + +import logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s", datefmt="%H:%M:%S") +log = logging.getLogger("runtime") + + +async def llm_call(model: str, messages: list[dict], stream: bool = False) -> Any: + """Single LLM call via OpenRouter. Returns full text or (client, response) for streaming.""" + headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"} + body = {"model": model, "messages": messages, "stream": stream} + + client = httpx.AsyncClient(timeout=60) + if stream: + resp = await client.send(client.build_request("POST", OPENROUTER_URL, headers=headers, json=body), stream=True) + return client, resp # caller owns cleanup + + resp = await client.post(OPENROUTER_URL, headers=headers, json=body) + await client.aclose() + data = resp.json() + if "choices" not in data: + log.error(f"LLM error: {data}") + return f"[LLM error: {data.get('error', {}).get('message', 'unknown')}]" + return data["choices"][0]["message"]["content"] + + +# --- Message types --- + +@dataclass +class Envelope: + """What flows between nodes.""" + text: str + user_id: str = "anon" + session_id: str = "" + timestamp: str = "" + + +@dataclass +class Command: + """Input node's decision — tells Output what to do.""" + instruction: str # natural language command for Output LLM + source_text: str # original user message (Output may need it) + metadata: dict = field(default_factory=dict) + + +# --- Base Node --- + +class Node: + name: str = "node" + model: str | None = None + + def __init__(self, send_hud): + self.send_hud = send_hud # async callable to emit hud events to frontend + + async def hud(self, event: str, **data): + await self.send_hud({"node": self.name, "event": event, **data}) + + +# --- Input Node --- + +class InputNode(Node): + name = "input" + model = "google/gemini-2.0-flash-001" + + SYSTEM = """You are the Input node — the ear of this cognitive runtime. + +Listener context: +- Authenticated user: {identity} +- Channel: {channel} (Chrome browser on Nico's Windows PC, in his room at home) +- Physical: private space, Nico lives with Tina — she may use this session too +- Security: single-user account, shared physical space — other voices are trusted household + +You hear what comes through this channel. Emit ONE instruction sentence telling Output how to respond. +No content, just the command. + +{memory_context}""" + + async def process(self, envelope: Envelope, history: list[dict], memory_context: str = "", + identity: str = "unknown", channel: str = "unknown") -> Command: + await self.hud("thinking", detail="deciding how to respond") + log.info(f"[input] user said: {envelope.text}") + + messages = [ + {"role": "system", "content": self.SYSTEM.format( + memory_context=memory_context, identity=identity, channel=channel)}, + ] + # History already includes current user message — don't add it again + for msg in history[-8:]: + messages.append(msg) + + await self.hud("context", messages=messages) + instruction = await llm_call(self.model, messages) + log.info(f"[input] → command: {instruction}") + await self.hud("decided", instruction=instruction) + return Command(instruction=instruction, source_text=envelope.text) + + +# --- Output Node --- + +class OutputNode(Node): + name = "output" + model = "google/gemini-2.0-flash-001" + + SYSTEM = """You are the Output node of a cognitive agent runtime. +You receive a command from the Input node telling you HOW to respond, plus the user's original message. +Follow the command's tone and intent. Be natural, don't mention the command or the runtime architecture. +Be concise. + +{memory_context}""" + + async def process(self, command: Command, history: list[dict], ws: WebSocket, memory_context: str = "") -> str: + await self.hud("streaming") + + messages = [ + {"role": "system", "content": self.SYSTEM.format(memory_context=memory_context)}, + ] + # Conversation history for continuity (already includes current user message) + for msg in history[-20:]: + messages.append(msg) + # Inject command as system guidance after the user message + messages.append({"role": "system", "content": f"Input node command: {command.instruction}"}) + + await self.hud("context", messages=messages) + + # Stream response + client, resp = await llm_call(self.model, messages, stream=True) + full_response = "" + try: + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + chunk = json.loads(payload) + delta = chunk["choices"][0].get("delta", {}) + token = delta.get("content", "") + if token: + full_response += token + await ws.send_text(json.dumps({"type": "delta", "content": token})) + finally: + await resp.aclose() + await client.aclose() + + log.info(f"[output] response: {full_response[:100]}...") + await ws.send_text(json.dumps({"type": "done"})) + await self.hud("done") + return full_response + + +# --- Memorizer Node (S2 — shared state / coordination) --- + +class MemorizerNode(Node): + name = "memorizer" + model = "google/gemini-2.0-flash-001" + + DISTILL_SYSTEM = """You are the Memorizer node of a cognitive agent runtime. +After each exchange you update the shared state that Input and Output nodes read. + +Given the conversation so far, output a JSON object with these fields: +- user_name: string — how the user identifies themselves (null if unknown) +- user_mood: string — current emotional tone (neutral, happy, frustrated, playful, etc.) +- topic: string — what the conversation is about right now +- topic_history: list of strings — previous topics in this session +- situation: string — social/physical context if mentioned (e.g. "at a pub with tina", "private dev session") +- language: string — primary language being used (en, de, mixed) +- style_hint: string — how Output should talk (casual, formal, technical, poetic, etc.) +- facts: list of strings — important facts learned about the user + +Output ONLY valid JSON. No explanation, no markdown fences.""" + + def __init__(self, send_hud): + super().__init__(send_hud) + # The shared state — starts empty, grows over conversation + self.state: dict = { + "user_name": None, + "user_mood": "neutral", + "topic": None, + "topic_history": [], + "situation": "localhost test runtime, private dev session", + "language": "en", + "style_hint": "casual, technical", + "facts": [], + } + + def get_context_block(self) -> str: + """Returns a formatted string for injection into Input/Output system prompts.""" + lines = ["Shared memory (from Memorizer):"] + for k, v in self.state.items(): + if v: + lines.append(f"- {k}: {v}") + return "\n".join(lines) + + async def update(self, history: list[dict]): + """Distill conversation into updated shared state. Called after each exchange.""" + if len(history) < 2: + await self.hud("updated", state=self.state) # emit default state + return + + await self.hud("thinking", detail="updating shared state") + + messages = [ + {"role": "system", "content": self.DISTILL_SYSTEM}, + {"role": "system", "content": f"Current state: {json.dumps(self.state)}"}, + ] + # Last few exchanges for distillation + for msg in history[-10:]: + messages.append(msg) + messages.append({"role": "user", "content": "Update the shared state based on this conversation. Output JSON only."}) + + await self.hud("context", messages=messages) + + raw = await llm_call(self.model, messages) + log.info(f"[memorizer] raw: {raw[:200]}") + + # Parse JSON from response (strip markdown fences if present) + text = raw.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): + text = text[:-3] + text = text.strip() + + try: + new_state = json.loads(text) + # Merge: keep old facts, add new ones + old_facts = set(self.state.get("facts", [])) + new_facts = set(new_state.get("facts", [])) + new_state["facts"] = list(old_facts | new_facts) + # Preserve topic history + if self.state.get("topic") and self.state["topic"] != new_state.get("topic"): + hist = new_state.get("topic_history", []) + if self.state["topic"] not in hist: + hist.append(self.state["topic"]) + new_state["topic_history"] = hist[-5:] # keep last 5 + self.state = new_state + log.info(f"[memorizer] updated state: {self.state}") + await self.hud("updated", state=self.state) + except (json.JSONDecodeError, Exception) as e: + log.error(f"[memorizer] update error: {e}, raw: {text[:200]}") + await self.hud("error", detail=f"Update failed: {e}") + # Still emit current state so frontend shows something + await self.hud("updated", state=self.state) + + +# --- Runtime (wires nodes together) --- + +TRACE_FILE = Path(__file__).parent / "trace.jsonl" + + +class Runtime: + def __init__(self, ws: WebSocket, user_claims: dict = None, origin: str = ""): + self.ws = ws + self.history: list[dict] = [] + self.input_node = InputNode(send_hud=self._send_hud) + self.output_node = OutputNode(send_hud=self._send_hud) + self.memorizer = MemorizerNode(send_hud=self._send_hud) + # Verified identity from auth — Input and Memorizer use this + claims = user_claims or {} + log.info(f"[runtime] user_claims: {claims}") + self.identity = claims.get("name") or claims.get("preferred_username") or claims.get("username") or "unknown" + log.info(f"[runtime] resolved identity: {self.identity}") + self.channel = origin or "unknown" + # Seed memorizer with verified info + self.memorizer.state["user_name"] = self.identity + self.memorizer.state["situation"] = f"authenticated on {self.channel}" if origin else "local session" + + async def _send_hud(self, data: dict): + # Send to frontend + await self.ws.send_text(json.dumps({"type": "hud", **data})) + # Append to trace file + broadcast to SSE subscribers + trace_entry = {"ts": time.strftime("%Y-%m-%d %H:%M:%S.") + f"{time.time() % 1:.3f}"[2:], **data} + try: + with open(TRACE_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(trace_entry, ensure_ascii=False) + "\n") + except Exception as e: + log.error(f"trace write error: {e}") + _broadcast_sse(trace_entry) + + async def handle_message(self, text: str): + envelope = Envelope( + text=text, + user_id="nico", + session_id="test", + timestamp=time.strftime("%Y-%m-%d %H:%M:%S"), + ) + + # Append user message to history FIRST — both nodes see it + self.history.append({"role": "user", "content": text}) + + # Get shared memory context for both nodes + mem_ctx = self.memorizer.get_context_block() + + # Input node decides (with memory context + identity + channel) + command = await self.input_node.process( + envelope, self.history, memory_context=mem_ctx, + identity=self.identity, channel=self.channel) + + # Output node executes (with memory context + history including user msg) + response = await self.output_node.process(command, self.history, self.ws, memory_context=mem_ctx) + self.history.append({"role": "assistant", "content": response}) + + # Memorizer updates shared state after each exchange + await self.memorizer.update(self.history) + + +# --- App --- + +STATIC_DIR = Path(__file__).parent / "static" + +app = FastAPI(title="Cognitive Agent Runtime") + +# Keep a reference to the active runtime for API access +_active_runtime: Runtime | None = None + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/auth/config") +async def auth_config(): + """Public: auth config for frontend OIDC flow.""" + return { + "enabled": AUTH_ENABLED, + "issuer": ZITADEL_ISSUER, + "clientId": ZITADEL_CLIENT_ID, + "projectId": ZITADEL_PROJECT_ID, + } + + +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket, token: str | None = Query(None), access_token: str | None = Query(None)): + global _active_runtime + # Validate auth if enabled + user_claims = {"sub": "anonymous"} + if AUTH_ENABLED and token: + try: + user_claims = await _validate_token(token) + # If id_token lacks name, enrich from userinfo with access_token + if not user_claims.get("name") and access_token: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{ZITADEL_ISSUER}/oidc/v1/userinfo", + headers={"Authorization": f"Bearer {access_token}"}) + if resp.status_code == 200: + info = resp.json() + log.info(f"[auth] userinfo enrichment: {info}") + user_claims["name"] = info.get("name") + user_claims["preferred_username"] = info.get("preferred_username") + user_claims["email"] = info.get("email") + except HTTPException: + await ws.close(code=4001, reason="Invalid token") + return + origin = ws.headers.get("origin", ws.headers.get("host", "")) + await ws.accept() + runtime = Runtime(ws, user_claims=user_claims, origin=origin) + _active_runtime = runtime + try: + while True: + data = await ws.receive_text() + msg = json.loads(data) + await runtime.handle_message(msg["text"]) + except WebSocketDisconnect: + if _active_runtime is runtime: + _active_runtime = None + + +# --- API endpoints (for Claude to inspect runtime state) --- + +import hashlib +from asyncio import Queue +from starlette.responses import StreamingResponse + +# SSE subscribers (for titan/service accounts to watch live) +_sse_subscribers: list[Queue] = [] + +def _broadcast_sse(event: dict): + """Push an event to all SSE subscribers.""" + for q in _sse_subscribers: + try: + q.put_nowait(event) + except asyncio.QueueFull: + pass # drop if subscriber is too slow + +def _state_hash() -> str: + """Hash of current runtime state — cheap way to detect changes.""" + if not _active_runtime: + return "no_session" + raw = json.dumps({ + "mem": _active_runtime.memorizer.state, + "hlen": len(_active_runtime.history), + }, sort_keys=True) + return hashlib.md5(raw.encode()).hexdigest()[:12] + + +@app.get("/api/events") +async def sse_events(user=Depends(require_auth)): + """SSE stream of runtime events (trace, state changes).""" + q: Queue = Queue(maxsize=100) + _sse_subscribers.append(q) + + async def generate(): + try: + while True: + event = await q.get() + yield f"data: {json.dumps(event)}\n\n" + except asyncio.CancelledError: + pass + finally: + _sse_subscribers.remove(q) + + return StreamingResponse(generate(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.get("/api/poll") +async def poll(since: str = "", user=Depends(require_auth)): + """Returns current hash. If 'since' matches, returns {changed: false}. Cheap polling.""" + h = _state_hash() + if since and since == h: + return {"changed": False, "hash": h} + return { + "changed": True, + "hash": h, + "state": _active_runtime.memorizer.state if _active_runtime else None, + "history_len": len(_active_runtime.history) if _active_runtime else 0, + "last_messages": _active_runtime.history[-3:] if _active_runtime else [], + } + +@app.get("/api/state") +async def get_state(user=Depends(require_auth)): + """Current memorizer state + history length.""" + if not _active_runtime: + return {"status": "no_session"} + return { + "status": "active", + "memorizer": _active_runtime.memorizer.state, + "history_len": len(_active_runtime.history), + } + + +@app.get("/api/history") +async def get_history(last: int = 10, user=Depends(require_auth)): + """Recent conversation history.""" + if not _active_runtime: + return {"status": "no_session", "messages": []} + return { + "status": "active", + "messages": _active_runtime.history[-last:], + } + + +@app.get("/api/trace") +async def get_trace(last: int = 30, user=Depends(require_auth)): + """Recent trace lines from trace.jsonl.""" + if not TRACE_FILE.exists(): + return {"lines": []} + lines = TRACE_FILE.read_text(encoding="utf-8").strip().split("\n") + parsed = [] + for line in lines[-last:]: + try: + parsed.append(json.loads(line)) + except json.JSONDecodeError: + pass + return {"lines": parsed} + + +# Serve index.html explicitly, then static assets +from fastapi.responses import FileResponse + +@app.get("/") +async def index(): + return FileResponse(STATIC_DIR / "index.html") + +@app.get("/callback") +async def callback(): + """OIDC callback — serves the same SPA, JS handles the code exchange.""" + return FileResponse(STATIC_DIR / "index.html") + +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("agent:app", host="0.0.0.0", port=8000, reload=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6554b7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + runtime: + build: . + ports: + - "8000:8000" + volumes: + - ./agent.py:/app/agent.py + - ./static:/app/static + - ./trace.jsonl:/app/trace.jsonl + env_file: + - .env + restart: unless-stopped diff --git a/k8s/cog-ingress.yaml b/k8s/cog-ingress.yaml new file mode 100644 index 0000000..f0063fe --- /dev/null +++ b/k8s/cog-ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cog-runtime + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +spec: + ingressClassName: traefik + tls: + - secretName: cog-tls + hosts: + - cog.loop42.de + rules: + - host: cog.loop42.de + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: agent-runtime + port: + number: 80 diff --git a/k8s/zitadel-login-ingress.yaml b/k8s/zitadel-login-ingress.yaml new file mode 100644 index 0000000..8b38a79 --- /dev/null +++ b/k8s/zitadel-login-ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: zitadel-login + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +spec: + ingressClassName: traefik + tls: + - secretName: zitadel-tls + hosts: + - auth.loop42.de + rules: + - host: auth.loop42.de + http: + paths: + - path: /ui/v2/login + pathType: Prefix + backend: + service: + name: zitadel-login + port: + number: 3000 diff --git a/k8s/zitadel-values.yaml b/k8s/zitadel-values.yaml new file mode 100644 index 0000000..9442301 --- /dev/null +++ b/k8s/zitadel-values.yaml @@ -0,0 +1,85 @@ +replicaCount: 1 + +zitadel: + masterkeySecretName: zitadel-masterkey + configmapConfig: + ExternalDomain: auth.loop42.de + ExternalPort: 443 + ExternalSecure: true + TLS: + Enabled: false + Database: + Postgres: + Host: zitadel-db-postgresql + Port: 5432 + Database: zitadel + MaxOpenConns: 10 + MaxIdleConns: 5 + MaxConnLifetime: 30m + MaxConnIdleTime: 5m + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: postgres + SSL: + Mode: disable + FirstInstance: + Org: + Name: loop42 + Human: + UserName: nico + FirstName: Nico + LastName: Zimmermann + NickName: nico + Email: + Address: nico@loop42.de + Verified: true + Password: ChangeMe42! + PasswordChangeRequired: true + + secretConfig: + Database: + Postgres: + User: + Password: zitadel-db-pw-42 + Admin: + Password: postgres-admin-pw-42 + +ingress: + enabled: true + className: traefik + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + hosts: + - host: auth.loop42.de + paths: + - path: / + pathType: Prefix + tls: + - secretName: zitadel-tls + hosts: + - auth.loop42.de + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + +setupJob: + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +postgresql: + enabled: false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e51feaa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.135.2 +uvicorn==0.42.0 +httpx==0.28.1 +httpx-sse==0.4.3 +websockets==16.0 +python-dotenv==1.2.2 +pydantic==2.12.5 +PyJWT[crypto]==2.10.1 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..860b5fc --- /dev/null +++ b/static/app.js @@ -0,0 +1,229 @@ +const msgs = document.getElementById('messages'); +const inputEl = document.getElementById('input'); +const statusEl = document.getElementById('status'); +const traceEl = document.getElementById('trace'); +let ws, currentEl; +let authToken = localStorage.getItem('cog_token'); +let authConfig = null; + +// --- OIDC Auth --- + +async function initAuth() { + try { + const resp = await fetch('/auth/config'); + authConfig = await resp.json(); + } catch { authConfig = { enabled: false }; } + + if (!authConfig.enabled) { connect(); return; } + + // Handle OIDC callback + if (location.pathname === '/callback') { + const params = new URLSearchParams(location.search); + const code = params.get('code'); + const verifier = sessionStorage.getItem('pkce_verifier'); + if (code && verifier) { + const tokenResp = await fetch(authConfig.issuer + '/oauth/v2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: authConfig.clientId, + code, + redirect_uri: location.origin + '/callback', + code_verifier: verifier, + }), + }); + const tokens = await tokenResp.json(); + if (tokens.access_token) { + // Store access token for userinfo, id_token for JWT validation + localStorage.setItem('cog_access_token', tokens.access_token); + authToken = tokens.id_token || tokens.access_token; + localStorage.setItem('cog_token', authToken); + sessionStorage.removeItem('pkce_verifier'); + } + } + history.replaceState(null, '', '/'); + } + + if (authToken) { + connect(); + } else { + showLogin(); + } +} + +function showLogin() { + statusEl.textContent = 'not authenticated'; + statusEl.style.color = '#f59e0b'; + const btn = document.createElement('button'); + btn.textContent = 'Log in with loop42'; + btn.className = 'login-btn'; + btn.onclick = startLogin; + document.getElementById('input-bar').replaceChildren(btn); +} + +async function startLogin() { + // PKCE: generate code_verifier + code_challenge + const verifier = randomString(64); + sessionStorage.setItem('pkce_verifier', verifier); + const encoder = new TextEncoder(); + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: authConfig.clientId, + redirect_uri: location.origin + '/callback', + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + location.href = authConfig.issuer + '/oauth/v2/authorize?' + params; +} + +function randomString(len) { + const arr = new Uint8Array(len); + crypto.getRandomValues(arr); + return btoa(String.fromCharCode(...arr)).replace(/[^a-zA-Z0-9]/g, '').slice(0, len); +} + +// --- WebSocket --- + +function connect() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + let wsUrl = proto + '//' + location.host + '/ws'; + if (authToken) { + const accessToken = localStorage.getItem('cog_access_token') || ''; + wsUrl += '?token=' + encodeURIComponent(authToken) + '&access_token=' + encodeURIComponent(accessToken); + } + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + statusEl.textContent = 'connected'; + statusEl.style.color = '#22c55e'; + addTrace('runtime', 'connected', 'ws open'); + }; + + ws.onclose = () => { + statusEl.textContent = 'disconnected'; + statusEl.style.color = '#666'; + addTrace('runtime', 'disconnected', 'ws closed'); + setTimeout(connect, 2000); + }; + + ws.onmessage = (e) => { + const data = JSON.parse(e.data); + + if (data.type === 'hud') { + handleHud(data); + + } else if (data.type === 'delta') { + if (!currentEl) { + currentEl = addMsg('assistant', ''); + currentEl.classList.add('streaming'); + } + currentEl.textContent += data.content; + scroll(msgs); + + } else if (data.type === 'done') { + if (currentEl) currentEl.classList.remove('streaming'); + currentEl = null; + } + }; +} + +function handleHud(data) { + const node = data.node || 'unknown'; + const event = data.event || ''; + + if (event === 'context') { + // Expandable: show message count, click to see full context + const count = (data.messages || []).length; + const summary = count + ' msgs: ' + (data.messages || []).map(m => + m.role[0].toUpperCase() + ':' + truncate(m.content, 30) + ).join(' | '); + const detail = (data.messages || []).map((m, i) => + i + ' [' + m.role + '] ' + m.content + ).join('\n'); + addTrace(node, 'context', summary, 'context', detail); + + } else if (event === 'decided') { + addTrace(node, 'decided', data.instruction, 'instruction'); + + } else if (event === 'updated' && data.state) { + const pairs = Object.entries(data.state).map(([k, v]) => { + const val = Array.isArray(v) ? v.join(', ') : (v || 'null'); + return k + '=' + truncate(val, 25); + }).join(' '); + const detail = JSON.stringify(data.state, null, 2); + addTrace(node, 'state', pairs, 'state', detail); + + } else if (event === 'error') { + addTrace(node, 'error', data.detail || '', 'error'); + + } else if (event === 'thinking') { + addTrace(node, 'thinking', data.detail || ''); + + } else if (event === 'streaming') { + addTrace(node, 'streaming', ''); + + } else if (event === 'done') { + addTrace(node, 'done', ''); + + } else { + // Generic fallback + const detail = JSON.stringify(data, null, 2); + addTrace(node, event, '', '', detail); + } +} + +function addTrace(node, event, text, cls, detail) { + const line = document.createElement('div'); + line.className = 'trace-line' + (detail ? ' expandable' : ''); + + const ts = new Date().toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 1 }); + + line.innerHTML = + '' + ts + '' + + '' + esc(node) + '' + + '' + esc(event) + '' + + '' + esc(text) + ''; + + traceEl.appendChild(line); + + if (detail) { + const detailEl = document.createElement('div'); + detailEl.className = 'trace-detail'; + detailEl.textContent = detail; + traceEl.appendChild(detailEl); + line.addEventListener('click', () => detailEl.classList.toggle('open')); + } + + scroll(traceEl); +} + +function scroll(el) { el.scrollTop = el.scrollHeight; } +function esc(s) { const d = document.createElement('span'); d.textContent = s; return d.innerHTML; } +function truncate(s, n) { return s.length > n ? s.slice(0, n) + '\u2026' : s; } + +function addMsg(role, text) { + const el = document.createElement('div'); + el.className = 'msg ' + role; + el.textContent = text; + msgs.appendChild(el); + scroll(msgs); + return el; +} + +function send() { + const text = inputEl.value.trim(); + if (!text || !ws || ws.readyState !== 1) return; + addMsg('user', text); + addTrace('runtime', 'user_msg', truncate(text, 60)); + ws.send(JSON.stringify({ text })); + inputEl.value = ''; +} + +inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); +initAuth(); diff --git a/static/design.html b/static/design.html new file mode 100644 index 0000000..022b052 --- /dev/null +++ b/static/design.html @@ -0,0 +1,483 @@ + + + + + +Cognitive Runtime — Design Exploration + + + +
+ +

Cognitive Agent Runtime — Design Exploration

+
Node graph architecture grounded in cybernetics, Cynefin, actor-network theory, and signal processing
+ + +

Theoretical Grounding

+ +
+
+

Cybernetics (Wiener, Ashby, Beer)

+

Core idea: systems that regulate themselves through feedback loops.

+
    +
  • Ashby's Law of Requisite Variety — the controller must have at least as much variety as the disturbance. One monolithic agent fails because it can't match the variety of all inputs. Specialized nodes CAN.
  • +
  • Viable System Model (Beer) — every viable system has 5 subsystems: Operations (Output), Coordination (Router), Control (Input), Intelligence (Thinker), Policy (human/config). Our graph maps directly.
  • +
  • Homeostasis — the system maintains stability through feedback. The I/O Feedback node IS the homeostatic loop — detecting drift, repeated failures, frustration.
  • +
  • Circular causality — output affects input. The user's next message is shaped by the response. The graph must be a loop, not a pipeline.
  • +
+

Design takeaway: Every node is either a sensor, controller, or effector. Missing any breaks the feedback loop.

+
+ +
+

Cynefin (Snowden)

+

Core idea: different problem domains need different response strategies.

+
    +
  • Clear — "what time is it?" → sense-categorize-respond. Input routes directly to Output, no Thinker needed. Fast.
  • +
  • Complicated — "how do I deploy to K3s?" → sense-analyze-respond. Input routes to Thinker with tools. Expert knowledge.
  • +
  • Complex — "should we use microservices?" → probe-sense-respond. Thinker explores, Memorizer tracks evolving understanding. No single right answer.
  • +
  • Chaotic — system is down, user panicking → act-sense-respond. Output responds FIRST (acknowledge), then Input figures out what happened.
  • +
  • Confused — unclear what domain we're in → Input's primary job! Classify before routing.
  • +
+

Design takeaway: Input node IS the Cynefin classifier. Different domains = different graph paths.

+
+ +
+

Actor-Network Theory (Latour)

+

Core idea: capability emerges from the network, not individual actors.

+
    +
  • Actants — both human and non-human entities have agency. Each node is an actant. The user is an actant. The LLM API is an actant.
  • +
  • Translation — messages change form as they pass through the network. User text → envelope → command → LLM prompt → stream → display. Each node translates.
  • +
  • Irreducibility — you can't reduce the system to one actor. No single node "is" the agent. The GRAPH is the agent.
  • +
  • Enrollment — new nodes join the network as needed. Tool nodes, sensor nodes, human-in-the-loop nodes. The graph grows.
  • +
+

Design takeaway: The graph IS the intelligence. Nodes are replaceable. Edges are where meaning happens.

+
+
+ + +

Node Taxonomy (from signal processing + cybernetics)

+ +
+
+

Sensors (perceive)

+
    +
  • Input — user message sensor
  • +
  • Timer — periodic trigger (cron, polling)
  • +
  • Webhook — external event sensor
  • +
  • FileWatch — filesystem change sensor
  • +
  • SystemProbe — health/load sensor
  • +
+

Like a webcam node in TouchDesigner. Always on, emits when something happens.

+
+ +
+

Controllers (decide + transform)

+
    +
  • Classifier — categorize input (Cynefin domain, intent, tone)
  • +
  • Router — direct to different paths based on classification
  • +
  • Thinker — deep reasoning, tool use
  • +
  • Filter — reduce, summarize, extract
  • +
  • Accumulator — aggregate over time (topic tracker)
  • +
+

Like filter/transform nodes in Max/MSP. Shape the signal.

+
+ +
+

Effectors (act)

+
    +
  • Output — stream text to user
  • +
  • Feedback — emit HUD/status events
  • +
  • ToolExec — execute external tools
  • +
  • Writer — persist to storage
  • +
  • Notifier — push to external systems
  • +
+

Like output nodes in Unreal Blueprints. Make something happen in the world.

+
+
+ +
+

Memory (special: both reads and writes)

+
    +
  • Memorizer — working memory, session history, user facts (SQLite/JSON)
  • +
  • TopicTracker — maintains list of active conversation topics
  • +
  • ContextBuilder — assembles the right context for each node on demand
  • +
+

Memory nodes are unique: they're called BY other nodes, not just wired in sequence. They're shared state — the "blackboard" in classic AI.

+
+ + +

Graph Architecture — Full Vision

+ +
+ ┌─────────────────────────────────────────────────┐ + │ │ + ▼ │ + [User WS] ──► [Input/Classifier] ──┬──────────────────────────┐ │ + │ │ │ │ + │ ┌─────────┘ │ │ + │ │ │ │ + │ │ │ │ + │ │ Complicated/Complex: │ │ + (skip │ │ ▼ │ + thinker) │ ▼ [Memorizer] +[Thinker] ◄── context ──────── + │ │ │ + │ │ └──── memory updates ──────── + │ │ │ │ │ + │ │ └──► [ToolExec] ─── results ──► │ │ + │ │ │ + ▼ ▼ │ + [Output] ──► [User WS] (stream delta/done) │ + │ │ + └──► ──► [User WS] (hud events) │ + │ │ + └──► ── hints ──────────┘ + + [Timer] ──────────────────────────────►│ + [Webhook] ────────────────────────────►│ + [SystemProbe] ────────────────────────►│ + + + [TopicTracker] ──► [UI: topic list, action buttons] +
+ + +

Node-Based Programming Analogy

+ +
+
+

TouchDesigner / Max/MSP

+
    +
  • Webcam → filter → skeleton detector → output display
  • +
  • Always running — not request/response
  • +
  • Nodes have typed inputs/outputs
  • +
  • Graph is the program
  • +
+
+
+

ComfyUI (Stable Diffusion)

+
    +
  • Prompt → CLIP → sampler → VAE → image
  • +
  • Each node: one model, one job
  • +
  • Swap nodes to change behavior
  • +
  • Visual graph = full transparency
  • +
+
+
+

Our Cognitive Runtime

+
    +
  • User msg → classify → think → stream → display
  • +
  • Each node: one LLM (or none), one job
  • +
  • Swap models per node via config
  • +
  • Three-column view = our visual debugger
  • +
+
+
+ +

Key insight: like node-based visual programming, the graph runs continuously. Sensors fire, signals propagate, effectors act. The chat is just ONE sensor. Timer events, webhooks, system probes — all feed the same graph.

+ + +

10 Test Use Cases

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Use CaseTestsNodes NeededPhase
1Greeting — "hey!"Input classifies casual, Output responds warmly. Verify command + context visible in panels.Input OutputNOW
2Technical question — "how does asyncio.Queue work?"Input classifies knowledge-needed. Output gives detailed answer. Context panel shows history growth.Input OutputNOW
3Multi-turn follow-up — ask, then "tell me more"Input sees follow-up pattern. Output uses history for continuity. Watch context grow in both panels.Input OutputNOW
4Tone shift — friendly then frustrated "this is broken!"Input detects tone change, adjusts command. Output shifts from casual to empathetic/helpful.Input OutputNOW
5Memory persistence — "my name is Nico" ... later ... "what's my name?"Memorizer stores user fact. On later question, provides context to Thinker. Output answers correctly.Input Memorizer OutputNEXT
6Background monitoring — "watch CPU load, alert if >80%"Timer/SystemProbe sensor fires periodically. Input classifies as monitoring. Feedback emits to UI without chat message.Timer SystemProbe FeedbackLATER
7System unresponsive — LLM takes 30s+Feedback node shows "thinking..." immediately. Timeout handling. User sees activity, not silence.Input Feedback OutputNEXT
8Cynefin: Clear domain — "what's 2+2?"Input classifies as Clear, skips Thinker, routes directly to Output. Faster response, cheaper.Input Router OutputLATER
9Topic tracking — conversation covers 3 topics, user says "back to the first thing"TopicTracker accumulates topics. UI shows topic list. User can click to refocus. Memorizer provides relevant context.TopicTracker Memorizer UILATER
10UI action — button in top bar triggers "summarize this conversation"Non-chat input (button click) enters the graph as a sensor event. Input classifies, routes to Thinker+Memorizer. Output renders summary.UI Sensor Thinker MemorizerLATER
+ + +

Build Roadmap — What To Validate

+ +
+
1
+
+

Input + Output (NOW — we're here)

+

Validates: Two-node communication works. LLM-to-LLM command passing. Context isolation visible. Streaming through graph.

+

Test cases: #1-4 (greeting, technical, follow-up, tone shift)

+

Success = You can see Input's reasoning and Output's execution as separate contexts. The command is meaningful, not just pass-through.

+
+
+ +
+
2
+
+

+ Feedback Node (pure Python, no LLM)

+

Validates: Non-LLM nodes in the graph. Real-time status. Parallel event emission (HUD fires while Output streams).

+

Test cases: #7 (unresponsive system — user sees "thinking..." not silence)

+

Success = User always sees activity within 200ms. Feedback panel in the UI shows event timeline.

+
+
+ +
+
3
+
+

+ Memorizer Node (LLM: Gemini Flash + SQLite)

+

Validates: Persistent state across the graph. Node-to-node context requests (Thinker asks Memorizer for context). Three LLM calls per turn, each with different context.

+

Test cases: #5 (memory persistence — "what's my name?")

+

Success = Conversation survives page reload. Memorizer panel shows what it stores vs what it provides.

+
+
+ +
+
4
+
+

+ Timer Sensor + SystemProbe (always-on)

+

Validates: The graph runs without user input. Sensor-driven processing. Background monitoring. The system is ALIVE, not just reactive.

+

Test cases: #6 (background CPU monitoring)

+

Success = UI shows activity without chat. Alerts appear. The graph is a living system, not a request/response pipe.

+
+
+ +
+
5
+
+

+ Router + Cynefin Classification

+

Validates: Multi-path graph. Input classifies domain, routes differently. Clear = fast path (skip Thinker). Complex = deep path (Thinker + tools + Memorizer). The graph adapts to the problem.

+

Test cases: #8 (Clear domain — fast path)

+

Success = Simple questions are 3x faster. Complex questions get deeper treatment. Visible in the graph view.

+
+
+ +
+
6
+
+

+ UI Extensions (topic list, action buttons, productivity)

+

Validates: The graph doesn't just do chat. Non-chat inputs (buttons, lists) enter the graph. Non-chat outputs (topic sidebar, action bar) exit the graph. Full cybernetic loop with rich UI.

+

Test cases: #9 (topic tracking), #10 (UI button triggers graph)

+

Success = The agent is a workspace tool, not just a chatbot.

+
+
+ + +

Viable System Model (Beer) — The Graph as Organism

+ +
+
+

System 1 — Operations

+

The worker nodes doing actual work.

+
    +
  • Thinker — reasoning, tool calls
  • +
  • Output — response generation
  • +
  • ToolExec — external actions
  • +
+
+
+

System 2 — Coordination

+

Prevents conflicts between System 1 nodes.

+
    +
  • Router — sequencing, dedup
  • +
  • ContextBuilder — shared context
  • +
  • Message queue ordering
  • +
+
+
+

System 3 — Control

+

Monitors performance, enforces policies.

+
    +
  • Feedback — quality gates
  • +
  • Token budget manager
  • +
  • Rate limiter, safety filter
  • +
+
+
+
+
+

System 4 — Intelligence

+

Looks outward + forward. Adapts.

+
    +
  • I/O Monitor — pattern detection
  • +
  • Learns from failures
  • +
  • Adapts routing rules over time
  • +
+
+
+

System 5 — Identity

+

What the graph IS and won't do.

+
    +
  • System prompts, persona
  • +
  • Safety boundaries
  • +
  • graph.md config
  • +
+
+
+

The Human Node

+

User isn't outside the system — they're an actant (ANT).

+
    +
  • Messages = sensor signals
  • +
  • Approvals = gate controls
  • +
  • Corrections = feedback loops
  • +
  • The graph includes the human
  • +
+
+
+ + +

Design Principles (Synthesis)

+ +
+
+

1. Three-Layer Architecture

+

From cybernetics

+

Perception (sensors, classifiers) → Decision (routers, controllers) → Action (processors, effectors). Feedback from action back to perception. Missing any layer breaks the loop.

+
+
+

2. Invest in Translations

+

From ANT

+

Message schemas and inter-node protocols matter MORE than individual node intelligence. A mediocre LLM with excellent routing outperforms a brilliant LLM with bad routing. Capability is emergent from topology.

+
+
+

3. Mixed Compute

+

From signal processing

+

Only Processor nodes need LLMs. Classifiers, routers, filters, accumulators can be lightweight models or pure code. Keeps cost and latency sane. Reserve expensive calls for where reasoning matters.

+
+
+
+
+

4. Typed Event-Driven Connections

+

From Max/MSP, Blueprints

+

Distinguish trigger inputs (fire processing) from context inputs (available but passive). Type the message wires. Dampen every feedback cycle (max iterations, circuit breakers).

+
+
+

5. Requisite Variety

+

From Ashby

+

Classifier/router layer must distinguish at least as many input types as you have processing strategies. Under-classification = wasted capability. Over-classification = premature complexity.

+
+
+

6. Domain-Aware Routing

+

From Cynefin

+

Clear = shallow/fast (skip Thinker). Complicated = specialist path. Complex = parallel probes. Chaotic = hardcoded fallback, act first. Different domains = different graph depths.

+
+
+ + +

The Key Insight

+ +
+

The graph IS the agent. Not a single LLM with a prompt. Not a chain of API calls. A living, always-on, multi-model network of specialized processors — exactly like a signal processing graph, but for language and reasoning.

+

Each phase adds a node. Each node validates one architectural claim. If any claim fails, we learn something. If they all hold, we have a new kind of agent runtime.

+

The network is the capability (ANT). The variety must match the disturbance (Ashby). The domain determines the strategy (Cynefin). The organism needs all five systems to be viable (Beer).

+
+ +
+ + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..63e0a42 --- /dev/null +++ b/static/index.html @@ -0,0 +1,33 @@ + + + + + +Cognitive Agent Runtime + + + + +
+

Cognitive Agent Runtime

+
disconnected
+
+ +
+
+
Chat
+
+
+ + +
+
+
+
Trace
+
+
+
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..80ac09c --- /dev/null +++ b/static/style.css @@ -0,0 +1,56 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; } + +/* Top bar */ +#top-bar { display: flex; align-items: center; gap: 1rem; padding: 0.4rem 1rem; background: #111; border-bottom: 1px solid #222; } +#top-bar h1 { font-size: 0.85rem; font-weight: 600; color: #888; } +#status { font-size: 0.75rem; color: #666; } + +/* Two-column layout: chat 1/3 | trace 2/3 */ +#main { flex: 1; display: grid; grid-template-columns: 1fr 2fr; gap: 1px; background: #222; overflow: hidden; min-height: 0; } + +.panel { background: #0a0a0a; display: flex; flex-direction: column; overflow: hidden; } +.panel-header { padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #222; flex-shrink: 0; } +.panel-header.chat-h { color: #60a5fa; background: #0a1628; } +.panel-header.trace-h { color: #a78bfa; background: #120a1e; } + +/* Chat panel */ +.chat-panel { display: flex; flex-direction: column; } +#messages { flex: 1; overflow-y: auto; padding: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; } +.msg { max-width: 90%; padding: 0.5rem 0.75rem; border-radius: 0.6rem; line-height: 1.4; white-space: pre-wrap; font-size: 0.9rem; } +.msg.user { align-self: flex-end; background: #2563eb; color: white; } +.msg.assistant { align-self: flex-start; background: #1e1e1e; border: 1px solid #333; } +.msg.assistant.streaming { border-color: #2563eb; } + +/* Input bar */ +#input-bar { display: flex; gap: 0.5rem; padding: 0.75rem; background: #111; border-top: 1px solid #222; } +#input { flex: 1; padding: 0.5rem 0.75rem; background: #1a1a1a; color: #e0e0e0; border: 1px solid #333; border-radius: 0.4rem; font-size: 0.9rem; outline: none; } +#input:focus { border-color: #2563eb; } +button { padding: 0.5rem 1rem; background: #2563eb; color: white; border: none; border-radius: 0.4rem; cursor: pointer; font-size: 0.9rem; } +button:hover { background: #1d4ed8; } + +/* Trace panel */ +#trace { flex: 1; overflow-y: auto; padding: 0.5rem; font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.72rem; line-height: 1.5; } + +.trace-line { padding: 0.15rem 0.4rem; border-bottom: 1px solid #111; display: flex; gap: 0.5rem; align-items: baseline; } +.trace-line:hover { background: #1a1a2e; } + +.trace-ts { color: #555; flex-shrink: 0; min-width: 5rem; } +.trace-node { font-weight: 700; flex-shrink: 0; min-width: 6rem; } +.trace-node.input { color: #f59e0b; } +.trace-node.output { color: #34d399; } +.trace-node.memorizer { color: #c084fc; } +.trace-node.runtime { color: #60a5fa; } + +.trace-event { color: #888; flex-shrink: 0; min-width: 6rem; } + +.trace-data { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.trace-data.instruction { color: #22c55e; } +.trace-data.error { color: #ef4444; } +.trace-data.state { color: #c084fc; } +.trace-data.context { color: #666; } + +/* Expandable trace detail */ +.trace-line.expandable { cursor: pointer; } +.trace-detail { display: none; padding: 0.3rem 0.4rem 0.3rem 12rem; font-size: 0.65rem; color: #777; white-space: pre-wrap; word-break: break-all; max-height: 10rem; overflow-y: auto; background: #0d0d14; border-bottom: 1px solid #1a1a2e; } +.trace-detail.open { display: block; }