Skill: jeanne0r/openclaw-web-gateway (version 1.0.6)

# OpenClaw Web Gateway ## Type interface ## Runtime python ## Description Lightweight Flask web interface that forwards chat requests to a configured OpenClaw HTTP endpoint. The gateway provides a browser chat UI and stores minimal local state. ## Requirements - Python 3.10+ - A reachable OpenClaw instance ## Configuration Configuration is done using environment variables. Typical values: OPENCLAW_BASE=http://127.0.0.1:18789 OPENCLAW_AUTH= OPENCLAW_AGENT=main OPENCLAW_CHANNEL=web-gateway OPENCLAW_MODEL=default DEFAULT_USER=family PORT=5002 `OPENCLAW_AUTH` is optional and only required if the upstream OpenClaw endpoint requires authentication. ## Run python app.py Then open: http://127.0.0.1:5002 ## Notes The application may create small local runtime files in directories such as: memory/ state/ These directories are intended for local runtime data and should not be committed to version control.

path: prompts.py
content: from config import APP_TITLE, PARTICIPANTS


def get_prompt(user: str) -> str:
    participant_names = "\n".join(f"- {item['display_name']}" for item in PARTICIPANTS)

    return f"""
You are a helpful household assistant running inside {APP_TITLE}.

The person currently speaking is: {user}

Rules:
1. Treat each participant as a different person.
2. You may use the information shown in the conversation context, including persistent memory snippets.
3. Never invent facts that are not present in the provided context.
4. Never claim that something was saved, remembered, stored, or added to persistent memory unless the system explicitly confirmed it.
5. If a fact is missing, say you do not have that information.
6. Respond naturally and helpfully. Do not mention internal files, JSON, databases, APIs, or OpenClaw unless the user explicitly asks a technical question.
7. Keep answers clear, friendly, and concise.

Configured participants:
{participant_names}
""".strip()


path: memory_store.py
content: from __future__ import annotations

import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from config import FACTS_FILE, MEMORY_ROOT, USERS_DIR


MEMORY_ROOT.mkdir(parents=True, exist_ok=True)
USERS_DIR.mkdir(parents=True, exist_ok=True)


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _slugify(value: str) -> str:
    value = (value or "").strip().lower()
    value = re.sub(r"[^\w\-]+", "_", value, flags=re.UNICODE)
    value = re.sub(r"_+", "_", value).strip("_")
    return value or "unknown"


def _read_json(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return default


def _write_json(path: Path, data: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(path)


@dataclass
class MemoryHit:
    source: str
    subject: str
    field: str
    value: Any
    updated_at: Optional[str] = None


class MemoryStore:
    def _user_file(self, user: str) -> Path:
        return USERS_DIR / f"{_slugify(user)}.json"

    def load_facts(self) -> dict[str, Any]:
        return _read_json(FACTS_FILE, {"subjects": {}, "history": []})

    def save_facts(self, data: dict[str, Any]) -> None:
        _write_json(FACTS_FILE, data)

    def load_user_profile(self, user: str) -> dict[str, Any]:
        return _read_json(
            self._user_file(user),
            {
                "user": user,
                "aliases": [],
                "profile": {},
                "preferences": {},
                "devices": [],
                "notes": [],
                "updated_at": None,
            },
        )

    def save_user_profile(self, user: str, data: dict[str, Any]) -> None:
        data["user"] = user
        data["updated_at"] = _now_iso()
        _write_json(self._user_file(user), data)

    def remember_fact(
        self,
        subject: str,
        field: str,
        value: Any,
        source_user: str | None = None,
        confidence: float = 1.0,
    ) -> dict[str, Any]:
        subject_key = _slugify(subject)
        facts = self.load_facts()
        subjects = facts.setdefault("subjects", {})
        subject_entry = subjects.setdefault(subject_key, {"display_name": subject, "facts": {}, "updated_at": None})
        subject_entry["display_name"] = subject
        subject_entry["facts"][field] = {
            "value": value,
            "updated_at": _now_iso(),
            "source_user": source_user,
            "confidence": confidence,
        }
        subject_entry["updated_at"] = _now_iso()
        facts.setdefault("history", []).append(
            {
                "type": "fact_upsert",
                "subject": subject,
                "field": field,
                "value": value,
                "source_user": source_user,
                "confidence": confidence,
                "timestamp": _now_iso(),
            }
        )
        self.save_facts(facts)
        return subject_entry

    def fact_exists(self, subject: str, field: str, value: Any | None = None) -> bool:
        entry = self.load_facts().get("subjects", {}).get(_slugify(subject))
        if not entry:
            return False
        fact = entry.get("facts", {}).get(field)
        if not fact:
            return False
        return value is None or fact.get("value") == value

    def append_note(self, user: str, note: str) -> dict[str, Any]:
        profile = self.load_user_profile(user)
        profile.setdefault("notes", []).append({"text": note, "created_at": _now_iso()})
        self.save_user_profile(user, profile)
        return profile

    def search(self, query: str, limit: int = 5) -> list[MemoryHit]:
        q = str(query or "").strip().lower()
        hits: list[MemoryHit] = []
        if not q:
            return hits

        facts = self.load_facts()
        for subject_key, subject_entry in facts.get("subjects", {}).items():
            display_name = subject_entry.get("display_name", subject_key)
            if q in subject_key or q in str(display_name).lower():
                for field, fact in subject_entry.get("facts", {}).items():
                    hits.append(MemoryHit("facts", display_name, field, fact.get("value"), fact.get("updated_at")))
                continue
            for field, fact in subject_entry.get("facts", {}).items():
                value_str = json.dumps(fact.get("value", ""), ensure_ascii=False).lower()
                if q in field.lower() or q in value_str:
                    hits.append(MemoryHit("facts", display_name, field, fact.get("value"), fact.get("updated_at")))

        for path in USERS_DIR.glob("*.json"):
            profile = _read_json(path, {})
            user = profile.get("user", path.stem)
            for section_name in ("profile", "preferences"):
                section = profile.get(section_name, {})
                if isinstance(section, dict):
                    for key, value in section.items():
                        blob = f"{user} {section_name} {key} {value}".lower()
                        if q in blob:
                            hits.append(MemoryHit(f"user:{user}", user, f"{section_name}.{key}", value, profile.get("updated_at")))
            for note in profile.get("notes", []):
                text = str(note.get("text", ""))
                if q in text.lower():
                    hits.append(MemoryHit(f"user:{user}", user, "note", text, note.get("created_at")))

        return hits[:limit]

    def get_subject_summary(self, subject: str) -> dict[str, Any]:
        entry = self.load_facts().get("subjects", {}).get(_slugify(subject))
        if not entry:
            return {"subject": subject, "facts": {}, "found": False}
        return {
            "subject": entry.get("display_name", subject),
            "facts": {field: fact.get("value") for field, fact in entry.get("facts", {}).items()},
            "found": True,
            "updated_at": entry.get("updated_at"),
        }

    def build_context_for_user(self, user: str) -> str:
        profile = self.load_user_profile(user)
        lines: list[str] = [f"Current user: {profile.get('user', user)}"]
        if profile.get("profile"):
            lines.append("User profile:")
            for key, value in profile["profile"].items():
                lines.append(f"- {key}: {value}")
        if profile.get("preferences"):
            lines.append("Preferences:")
            for key, value in profile["preferences"].items():
                lines.append(f"- {key}: {value}")
        notes = profile.get("notes", [])[-5:]
        if notes:
            lines.append("Recent notes:")
            for note in notes:
                lines.append(f"- {note.get('text')}")
        summary = self.get_subject_summary(user)
        if summary.get("found"):
            lines.append("Known facts for this user:")
            for key, value in summary.get("facts", {}).items():
                lines.append(f"- {key}: {value}")
        return "\n".join(lines)


path: requirements.txt
content: Flask>=3.0.0
requests>=2.31.0
python-dotenv>=1.0.1


path: openclaw_client.py
content: from __future__ import annotations

import requests

from config import CHANNEL, MODEL, OPENCLAW_BASE, OPENCLAW_TOKEN, canonical_user, user_slug
from prompts import get_prompt


def build_messages(user: str, history: list[dict] | None = None, message: str | None = None) -> list[dict]:
    user = canonical_user(user)
    messages = [{"role": "system", "content": get_prompt(user)}]

    for item in history or []:
        if not isinstance(item, dict):
            continue
        role = item.get("role")
        content = str(item.get("content") or "").strip()
        if role in {"system", "user", "assistant"} and content:
            messages.append({"role": role, "content": content})

    if message:
        messages.append({"role": "user", "content": message})

    return messages


def extract_reply(data: dict) -> str:
    try:
        choices = data.get("choices") or []
        if choices:
            message = choices[0].get("message") or {}
            content = message.get("content")
            if isinstance(content, str) and content.strip():
                return content.strip()
            if isinstance(content, list):
                parts = []
                for part in content:
                    if isinstance(part, dict) and part.get("text"):
                        parts.append(part["text"])
                joined = "\n".join(parts).strip()
                if joined:
                    return joined
    except Exception:
        pass

    error_message = data.get("error")
    if error_message:
        return f"OpenClaw error: {error_message}"

    return "OpenClaw returned no complete reply. The generation may have been interrupted."


def call_openclaw(user: str, message: str | None = None, history: list[dict] | None = None) -> str:
    user = canonical_user(user)

    headers = {
        "Content-Type": "application/json",
        "x-openclaw-message-channel": CHANNEL,
    }
    if OPENCLAW_TOKEN:
        headers["Authorization"] = f"Bearer {OPENCLAW_TOKEN}"

    payload = {
        "model": MODEL,
        "stream": False,
        "user": f"web-gateway:{user_slug(user)}",
        "messages": build_messages(user=user, history=history, message=message),
    }

    response = requests.post(
        f"{OPENCLAW_BASE}/v1/chat/completions",
        headers=headers,
        json=payload,
        timeout=180,
    )
    response.raise_for_status()
    return extract_reply(response.json())


path: run.sh
content: #!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")"

if [ -f .venv/bin/activate ]; then
  # shellcheck source=/dev/null
  source .venv/bin/activate
fi

exec python app.py


path: skill.json
content: {
  "name": "openclaw-web-gateway",
  "version": "1.0.0",
  "description": "Flask web gateway for OpenClaw with multi-user support and memory",
  "entrypoint": "app.py",
  "author": "Romain Jeanneret",
  "license": "MIT",
  "repository": "https://github.com/jeanne0r/openclaw-web-gateway",
  "install": {
    "type": "pip",
    "requirements": "requirements.txt"
  },
  "run": "python app.py"
}


path: config.py
content: from __future__ import annotations

import json
import os
import re
from pathlib import Path

from dotenv import load_dotenv


BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env")


def _bool_env(name: str, default: bool = False) -> bool:
    value = os.getenv(name)
    if value is None:
        return default
    return value.strip().lower() in {"1", "true", "yes", "on"}


def _slugify(value: str) -> str:
    value = str(value or "").strip().lower()
    value = re.sub(r"[^\w\-]+", "_", value, flags=re.UNICODE)
    value = re.sub(r"_+", "_", value).strip("_")
    return value or "unknown"


class Settings:
    def __init__(self):
        participants_path = BASE_DIR / "config" / "participants.json"

        self.app_title = os.getenv("APP_TITLE", "OpenClaw Web Gateway")
        self.app_subtitle = os.getenv("APP_SUBTITLE", "Multi-user household interface")

        self.host = os.getenv("HOST", "0.0.0.0")
        self.port = int(os.getenv("PORT", "5002"))
        self.debug = _bool_env("DEBUG", False)

        self.openclaw_base = os.getenv("OPENCLAW_BASE", "http://127.0.0.1:18789").rstrip("/")
        self.agent = os.getenv("OPENCLAW_AGENT", "main")
        self.[REDACTED_SECRET]"OPENCLAW_TOKEN", "")

        self.channel = os.getenv("OPENCLAW_CHANNEL", "web-gateway")
        self.model = os.getenv("OPENCLAW_MODEL", "default")

        self.google_maps_embed_api_key = os.getenv("GOOGLE_MAPS_EMBED_API_KEY", "")

        self.memory_root = Path(os.getenv("MEMORY_ROOT", str(BASE_DIR / "memory")))
        self.users_dir = self.memory_root / "users"
        self.facts_file = self.memory_root / "facts.json"
        self.state_file = self.memory_root / "state.json"

        self.participants = self._load_participants(participants_path)
        if not self.participants:
            raise RuntimeError("No participants found in config/participants.json")

        self.users = [p["display_name"] for p in self.participants]

        self.display_to_participant = {
            p["display_name"].lower(): p for p in self.participants
        }

        self.alias_to_display: dict[str, str] = {}
        self.key_to_display: dict[str, str] = {}

        for participant in self.participants:
            display_name = participant["display_name"]
            key = participant["key"]

            self.key_to_display[key.lower()] = display_name
            self.alias_to_display[display_name.lower()] = display_name
            self.alias_to_display[key.lower()] = display_name

            for alias in participant.get("aliases", []):
                self.alias_to_display[str(alias).strip().lower()] = display_name

        self.default_user = self._resolve_default_user(
            os.getenv("DEFAULT_USER", self.participants[0]["display_name"])
        )

    def _load_participants(self, path: Path) -> list[dict]:
        if not path.exists():
            raise RuntimeError(f"participants.json not found: {path}")

        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)

        if isinstance(data, dict):
            participants = data.get("participants", [])
        elif isinstance(data, list):
            participants = data
        else:
            raise RuntimeError(
                "participants.json must contain either a list or a dict with a 'participants' key"
            )

        normalized = []
        for i, participant in enumerate(participants):
            if not isinstance(participant, dict):
                raise RuntimeError(f"Participant entry #{i} is not an object")

            display_name = str(
                participant.get("display_name") or participant.get("name") or ""
            ).strip()
            if not display_name:
                raise RuntimeError(f"Participant entry #{i} is missing 'display_name'")

            key = str(participant.get("key") or _slugify(display_name)).strip()
            aliases = participant.get("aliases", [])
            if not isinstance(aliases, list):
                aliases = []

            normalized.append(
                {
                    "key": key,
                    "display_name": display_name,
                    "aliases": [str(a).strip() for a in aliases if str(a).strip()],
                }
            )

        return normalized

    def _resolve_default_user(self, raw: str) -> str:
        if not raw:
            return self.participants[0]["display_name"]

        canonical = self.alias_to_display.get(raw.strip().lower())
        if canonical:
            return canonical

        return str(raw).strip()


settings = Settings()

APP_TITLE = settings.app_title
APP_SUBTITLE = settings.app_subtitle

HOST = settings.host
PORT = settings.port
DEBUG = settings.debug

OPENCLAW_BASE = settings.openclaw_base
OPENCLAW_AGENT = settings.agent
OPENCLAW_TOKEN = settings.token
OPENCLAW_CHANNEL = settings.channel
OPENCLAW_MODEL = settings.model

CHANNEL = OPENCLAW_CHANNEL
MODEL = OPENCLAW_MODEL

GOOGLE_MAPS_EMBED_API_KEY = settings.google_maps_embed_api_key

MEMORY_ROOT = settings.memory_root
USERS_DIR = settings.users_dir
FACTS_FILE = settings.facts_file
STATE_FILE = settings.state_file

DEFAULT_USER = settings.default_user
USERS = settings.users
PARTICIPANTS = settings.participants
DISPLAY_TO_PARTICIPANT = settings.display_to_participant
ALIAS_TO_DISPLAY = settings.alias_to_display


def canonical_user(value: str | None) -> str:
    if not value:
        return DEFAULT_USER
    raw = str(value).strip()
    return ALIAS_TO_DISPLAY.get(raw.lower(), raw)


def normalize_user_key(value: str | None) -> str:
    canonical = canonical_user(value)
    participant = DISPLAY_TO_PARTICIPANT.get(canonical.lower())
    if participant:
        return participant["key"]
    return _slugify(canonical)


def user_slug(value: str | None) -> str:
    return normalize_user_key(value)


def participant_aliases() -> dict[str, str]:
    return dict(ALIAS_TO_DISPLAY)


path: app.py
content: from flask import Flask, render_template

from config import (
    APP_SUBTITLE,
    APP_TITLE,
    DEFAULT_USER,
    GOOGLE_MAPS_EMBED_API_KEY,
    HOST,
    OPENCLAW_BASE,
    OPENCLAW_TOKEN,
    PORT,
    USERS,
)
from routes.chat import chat_bp
from routes.state import state_bp


app = Flask(__name__)
app.register_blueprint(chat_bp)
app.register_blueprint(state_bp)


@app.route("/")
def index():
    return render_template(
        "index.html",
        app_title=APP_TITLE,
        app_subtitle=APP_SUBTITLE,
        openclaw_base=OPENCLAW_BASE,
        token_loaded=bool(OPENCLAW_TOKEN),
        default_user=DEFAULT_USER,
        users=USERS,
        google_maps_embed_api_key=GOOGLE_MAPS_EMBED_API_KEY,
    )


@app.route("/favicon.ico")
def favicon():
    return ("", 204)


if __name__ == "__main__":
    print(f"{APP_TITLE} running on http://127.0.0.1:{PORT}")
    app.run(host=HOST, port=PORT, debug=False)


path: state_manager.py
content: from __future__ import annotations

import json

from config import DEFAULT_USER, STATE_FILE, canonical_user


def sanitize_state(data):
    if not isinstance(data, dict):
        return {"activeUser": DEFAULT_USER, "histories": {}}

    active_user = canonical_user(data.get("activeUser", DEFAULT_USER))
    raw_histories = data.get("histories", {})
    histories = {}

    if isinstance(raw_histories, dict):
        for raw_user, items in raw_histories.items():
            user = canonical_user(raw_user)
            if not isinstance(items, list):
                continue
            cleaned_items = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                role = item.get("role")
                content = str(item.get("content") or "").strip()
                if role in {"user", "assistant"} and content:
                    cleaned_items.append({"role": role, "content": content})
            histories[user] = cleaned_items

    return {"activeUser": active_user, "histories": histories}


def load_state():
    if not STATE_FILE.exists():
        return {"activeUser": DEFAULT_USER, "histories": {}}
    try:
        return sanitize_state(json.loads(STATE_FILE.read_text(encoding="utf-8")))
    except Exception:
        return {"activeUser": DEFAULT_USER, "histories": {}}


def save_state(data):
    cleaned = sanitize_state(data)
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")


path: static/js/chat.js
content: (() => {
  function waitForUI(retries = 80) {
    return new Promise((resolve, reject) => {
      function check() {
        if (window.GatewayUI) {
          resolve(window.GatewayUI);
          return;
        }
        if (retries <= 0) {
          reject(new Error("GatewayUI not found"));
          return;
        }
        retries -= 1;
        setTimeout(check, 50);
      }
      check();
    });
  }

  async function init() {
    let ui;
    try {
      ui = await waitForUI();
    } catch (err) {
      console.error(err);
      return;
    }

    const dom = ui.dom;
    const state = ui.state;
    const typingRow = document.getElementById("typingRow");

    function showTyping(flag) {
      if (typingRow) typingRow.style.display = flag ? "flex" : "none";
    }

    async function sendMessage() {
      if (!dom.messageInput) return;
      const text = (dom.messageInput.value || "").trim();
      if (!text || state.thinking) return;

      const userAtSendTime = state.activeUser;
      ui.appendMessage("user", text, userAtSendTime);
      dom.messageInput.value = "";
      ui.autoResizeTextarea();
      ui.setThinking(true);
      showTyping(true);

      try {
        const response = await fetch("/api/chat", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            user: userAtSendTime,
            message: text,
            history: ui.state.histories[userAtSendTime] || [],
          }),
        });

        const data = await response.json().catch(() => ({}));
        if (!response.ok) {
          ui.appendMessage("assistant", data.error || `Server error (${response.status})`, userAtSendTime);
          return;
        }

        ui.appendMessage("assistant", data.reply || "No reply received.", userAtSendTime);

        if (data.action?.type === "open_route") {
          ui.openRoute(data.action.origin || "", data.action.destination || "");
        }
        if (data.action?.type === "open_route_search") {
          ui.openRouteSearch(data.action.query || "");
        }
        if (data.action?.type === "open_web") {
          ui.openWebSearch(data.action.query || "");
        }
        if (data.action?.type === "open_map") {
          ui.openMapSearch(data.action.query || "");
        }
      } catch (err) {
        console.error("Fetch error /api/chat:", err);
        ui.appendMessage("assistant", "Connection error.", userAtSendTime);
      } finally {
        ui.setThinking(false);
        showTyping(false);
        dom.messageInput?.focus();
      }
    }

    dom.sendBtn?.addEventListener("click", (e) => {
      e.preventDefault();
      sendMessage();
    });

    dom.messageInput?.addEventListener("keydown", (e) => {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault();
        sendMessage();
      }
    });

    dom.messageInput?.addEventListener("focus", () => setTimeout(ui.autoScroll, 150));
    window.addEventListener("load", () => ui.autoScroll());
    window.addEventListener("resize", () => setTimeout(ui.autoScroll, 100));
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init, { once: true });
  } else {
    init();
  }
})();


path: static/js/background.js
content: (function () {
  const ui = window.JarvisUI;
  const canvas = document.getElementById("jarvis-bg");

  if (!canvas) return;

  const ctx = canvas.getContext("2d");

  let w = 0;
  let h = 0;
  let dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
  let particles = [];
  let t = 0;

  function resizeCanvas() {
    w = window.innerWidth;
    h = window.innerHeight;
    dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));

    canvas.width = Math.floor(w * dpr);
    canvas.height = Math.floor(h * dpr);
    canvas.style.width = `${w}px`;
    canvas.style.height = `${h}px`;

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    initParticles();
  }

  function initParticles() {
    const count = Math.min(160, Math.max(70, Math.floor((w * h) / 18000)));

    particles = Array.from({ length: count }, () => ({
      x: Math.random() * w,
      y: Math.random() * h,
      vx: (Math.random() - 0.5) * 0.22,
      vy: (Math.random() - 0.5) * 0.22,
      size: Math.random() * 1.8 + 0.4,
      alpha: Math.random() * 0.6 + 0.2,
    }));
  }

  function drawGrid() {
    ctx.save();
    ctx.strokeStyle = "rgba(70, 210, 255, 0.06)";
    ctx.lineWidth = 1;

    const spacing = 78;

    for (let x = ((t * 8) % spacing) - spacing; x < w + spacing; x += spacing) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, h);
      ctx.stroke();
    }

    for (let y = ((t * 6) % spacing) - spacing; y < h + spacing; y += spacing) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(w, y);
      ctx.stroke();
    }

    ctx.restore();
  }

  function drawReactor() {
    const cx = w * 0.5;
    const cy = h * 0.56;
    const thinkingBoost = ui && ui.state.thinking ? 1 : 0;
    const pulse = 1 + Math.sin(t * 2.4) * (0.018 + thinkingBoost * 0.03);

    ctx.save();
    ctx.translate(cx, cy);
    ctx.scale(pulse, pulse);

    const halo = ctx.createRadialGradient(0, 0, 10, 0, 0, 280 + thinkingBoost * 90);
    halo.addColorStop(0, "rgba(110, 242, 255, 0.24)");
    halo.addColorStop(0.2, "rgba(57, 216, 255, 0.11)");
    halo.addColorStop(0.55, "rgba(30, 130, 255, 0.05)");
    halo.addColorStop(1, "rgba(0, 0, 0, 0)");

    ctx.fillStyle = halo;
    ctx.beginPath();
    ctx.arc(0, 0, 320 + thinkingBoost * 60, 0, Math.PI * 2);
    ctx.fill();

    for (let i = 0; i < 4; i++) {
      ctx.save();
      ctx.rotate(t * (0.12 + i * 0.03) * (i % 2 === 0 ? 1 : -1));
      ctx.strokeStyle = `rgba(95, 232, 255, ${0.12 - i * 0.02 + thinkingBoost * 0.04})`;
      ctx.lineWidth = i === 0 ? 2 : 1;
      ctx.beginPath();
      ctx.arc(0, 0, 70 + i * 28, 0, Math.PI * 1.55);
      ctx.stroke();
      ctx.restore();
    }

    ctx.strokeStyle = `rgba(130, 242, 255, ${0.22 + thinkingBoost * 0.2})`;
    ctx.lineWidth = 1;

    for (let i = 0; i < 18; i++) {
      const ang = (Math.PI * 2 / 18) * i + t * 0.15;
      const r1 = 34;
      const r2 = 120;

      ctx.beginPath();
      ctx.moveTo(Math.cos(ang) * r1, Math.sin(ang) * r1);
      ctx.lineTo(Math.cos(ang) * r2, Math.sin(ang) * r2);
      ctx.stroke();
    }

    const core = ctx.createRadialGradient(0, 0, 0, 0, 0, 54 + thinkingBoost * 10);
    core.addColorStop(0, "rgba(255,255,255,0.95)");
    core.addColorStop(0.14, "rgba(140,244,255,0.95)");
    core.addColorStop(0.4, "rgba(0,214,255,0.4)");
    core.addColorStop(1, "rgba(0,0,0,0)");

    ctx.fillStyle = core;
    ctx.beginPath();
    ctx.arc(0, 0, 58 + thinkingBoost * 12, 0, Math.PI * 2);
    ctx.fill();

    ctx.restore();
  }

  function drawSweep() {
    const thinking = ui && ui.state.thinking;
    const y = ((t * 120) % (h + 260)) - 130;
    const grad = ctx.createLinearGradient(0, y - 80, 0, y + 80);

    grad.addColorStop(0, "rgba(80, 220, 255, 0)");
    grad.addColorStop(0.5, `rgba(80, 220, 255, ${thinking ? 0.1 : 0.05})`);
    grad.addColorStop(1, "rgba(80, 220, 255, 0)");

    ctx.fillStyle = grad;
    ctx.fillRect(0, y - 80, w, 160);
  }

  function drawParticles() {
    const thinking = ui && ui.state.thinking;

    for (const p of particles) {
      p.x += p.vx * (thinking ? 1.35 : 1);
      p.y += p.vy * (thinking ? 1.35 : 1);

      if (p.x < -20) p.x = w + 20;
      if (p.x > w + 20) p.x = -20;
      if (p.y < -20) p.y = h + 20;
      if (p.y > h + 20) p.y = -20;

      ctx.beginPath();
      ctx.fillStyle = `rgba(120, 240, 255, ${p.alpha})`;
      ctx.arc(p.x, p.y, p.size + (thinking ? 0.15 : 0), 0, Math.PI * 2);
      ctx.fill();
    }

    ctx.save();
    ctx.strokeStyle = `rgba(100, 235, 255, ${thinking ? 0.12 : 0.06})`;
    ctx.lineWidth = 1;

    for (let i = 0; i < particles.length; i++) {
      for (let j = i + 1; j < particles.length; j++) {
        const a = particles[i];
        const b = particles[j];
        const dx = a.x - b.x;
        const dy = a.y - b.y;
        const dist = Math.hypot(dx, dy);
        const max = 95;

        if (dist < max) {
          ctx.globalAlpha = 1 - dist / max;
          ctx.beginPath();
          ctx.moveTo(a.x, a.y);
          ctx.lineTo(b.x, b.y);
          ctx.stroke();
        }
      }
    }

    ctx.restore();
    ctx.globalAlpha = 1;
  }

  function drawHudCorners() {
    ctx.save();
    ctx.strokeStyle = "rgba(112, 238, 255, 0.18)";
    ctx.lineWidth = 2;

    const pad = 18;
    const len = 40;
    const corners = [
      [pad, pad],
      [w - pad, pad],
      [pad, h - pad],
      [w - pad, h - pad],
    ];

    corners.forEach(([x, y], idx) => {
      ctx.beginPath();

      if (idx === 0) {
        ctx.moveTo(x + len, y);
        ctx.lineTo(x, y);
        ctx.lineTo(x, y + len);
      } else if (idx === 1) {
        ctx.moveTo(x - len, y);
        ctx.lineTo(x, y);
        ctx.lineTo(x, y + len);
      } else if (idx === 2) {
        ctx.moveTo(x + len, y);
        ctx.lineTo(x, y);
        ctx.lineTo(x, y - len);
      } else {
        ctx.moveTo(x - len, y);
        ctx.lineTo(x, y);
        ctx.lineTo(x, y - len);
      }

      ctx.stroke();
    });

    ctx.restore();
  }

  function animate() {
    const thinking = ui && ui.state.thinking;
    t += thinking ? 0.024 : 0.012;

    ctx.clearRect(0, 0, w, h);
    drawGrid();
    drawSweep();
    drawParticles();
    drawReactor();
    drawHudCorners();

    requestAnimationFrame(animate);
  }

  window.addEventListener("resize", resizeCanvas);
  resizeCanvas();
  animate();
})();


path: static/js/ui.js
content: (function () {
  const CONFIG = window.GATEWAY_CONFIG || {
    openclawBase: "http://127.0.0.1:18789",
    tokenLoaded: false,
    defaultUser: "Alex",
    users: ["Alex", "Sam", "Family"],
    googleMapsEmbedApiKey: "",
  };

  const state = {
    activeUser: CONFIG.defaultUser,
    histories: {},
    thinking: false,
  };

  const shortcutPrompts = {
    agenda: "What are my events today?",
    home: "Give me a global status of the house.",
    weather: "What is the current weather and today's forecast?",
    diag: "Run a quick diagnostic of OpenClaw and the gateway.",
  };

  const dom = {
    body: document.body,
    messagesEl: document.getElementById("messages"),
    chatScroll: document.getElementById("chatScroll"),
    messageInput: document.getElementById("messageInput"),
    sendBtn: document.getElementById("sendBtn"),
    inputState: document.getElementById("inputState"),
    userPills: document.getElementById("userPills"),
    baseUrlLabel: document.getElementById("baseUrlLabel"),
    tokenLoadedLabel: document.getElementById("tokenLoadedLabel"),
    activeUserLabel: document.getElementById("activeUserLabel"),
    hudUserChip: document.getElementById("hudUserChip"),
    clearChatBtn: document.getElementById("clearChatBtn"),
    floatingWindows: document.getElementById("floatingWindows"),
  };

  let zCounter = 100;

  function hasCoreDom() {
    return !!(dom.messagesEl && dom.chatScroll && dom.messageInput && dom.sendBtn && dom.userPills && dom.baseUrlLabel && dom.tokenLoadedLabel && dom.activeUserLabel && dom.hudUserChip && dom.clearChatBtn && dom.floatingWindows);
  }

  function escapeHtml(text) {
    return String(text ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  }

  function ensureHistory(user) {
    if (!state.histories[user]) state.histories[user] = [];
    return state.histories[user];
  }

  function autoScroll() {
    if (!dom.chatScroll) return;
    requestAnimationFrame(() => {
      dom.chatScroll.scrollTop = 0;
    });
  }

  function autoResizeTextarea() {
    if (!dom.messageInput) return;
    dom.messageInput.style.height = "auto";
    dom.messageInput.style.height = `${Math.min(dom.messageInput.scrollHeight, 220)}px`;
  }

  function setThinking(flag) {
    state.thinking = !!flag;
    dom.body?.classList.toggle("thinking", state.thinking);
    if (dom.inputState) dom.inputState.textContent = state.thinking ? "Thinking..." : "Ready";
    if (dom.sendBtn) dom.sendBtn.disabled = state.thinking;
    autoScroll();
  }

  function renderMessages() {
    if (!dom.messagesEl) return;
    const history = [...ensureHistory(state.activeUser)].reverse();
    dom.messagesEl.innerHTML = history.map((msg) => `
      <div class="message-row ${msg.role === "user" ? "user" : "assistant"}">
        <div class="bubble">${escapeHtml(msg.content).replace(/\n/g, "<br>")}</div>
      </div>
    `).join("");

    if (dom.activeUserLabel) dom.activeUserLabel.textContent = state.activeUser;
    if (dom.hudUserChip) dom.hudUserChip.textContent = `USER: ${String(state.activeUser).toUpperCase()}`;
    autoScroll();
  }

  function setActiveUser(user) {
    state.activeUser = user;
    ensureHistory(user);
    renderUserPills();
    renderMessages();
    if (dom.messageInput) {
      dom.messageInput.value = "";
      autoResizeTextarea();
      dom.messageInput.focus();
    }
    persistState();
  }

  function renderUserPills() {
    if (!dom.userPills) return;
    dom.userPills.innerHTML = CONFIG.users.map((user) => `<button type="button" class="user-pill ${user === state.activeUser ? "active" : ""}" data-user="${escapeHtml(user)}">${escapeHtml(user)}</button>`).join("");
    dom.userPills.querySelectorAll(".user-pill").forEach((btn) => btn.addEventListener("click", () => setActiveUser(btn.dataset.user)));
  }

  async function persistState() {
    try {
      await fetch("/api/state", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ activeUser: state.activeUser, histories: state.histories }),
      });
    } catch (err) {
      console.warn("persist error", err);
    }
  }

  async function loadState() {
    try {
      const res = await fetch("/api/state");
      const data = await res.json();
      if (data && typeof data === "object") {
        state.activeUser = data.activeUser || CONFIG.defaultUser;
        state.histories = data.histories || {};
      }
    } catch (err) {
      console.warn("load state error", err);
    }
    ensureHistory(state.activeUser);
    renderUserPills();
    renderMessages();
  }

  function appendMessage(role, content, user = state.activeUser) {
    ensureHistory(user).push({ role, content });
    if (user === state.activeUser) renderMessages();
    persistState();
  }

  function initHeader() {
    if (dom.baseUrlLabel) dom.baseUrlLabel.textContent = CONFIG.openclawBase || "-";
    if (dom.tokenLoadedLabel) dom.tokenLoadedLabel.textContent = CONFIG.tokenLoaded ? "yes" : "no";
    if (dom.activeUserLabel) dom.activeUserLabel.textContent = state.activeUser;
    if (dom.hudUserChip) dom.hudUserChip.textContent = `USER: ${String(state.activeUser).toUpperCase()}`;
  }

  function bindShortcuts() {
    document.querySelectorAll("[data-shortcut]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const prompt = shortcutPrompts[btn.dataset.shortcut];
        if (prompt && dom.messageInput) {
          dom.messageInput.value = prompt;
          autoResizeTextarea();
          dom.messageInput.focus();
        }
      });
    });
  }

  function focusWindow(win) {
    win.style.zIndex = String(++zCounter);
  }

  function applyWindowSizing(win) {
    win.style.width = `${Math.min(window.innerWidth - 48, 920)}px`;
    win.style.height = `${Math.min(window.innerHeight - 48, 640)}px`;
  }

  function makeDraggable(win, handle) {
    let isDragging = false;
    let offsetX = 0;
    let offsetY = 0;

    handle?.addEventListener("mousedown", (event) => {
      isDragging = true;
      focusWindow(win);
      offsetX = event.clientX - win.offsetLeft;
      offsetY = event.clientY - win.offsetTop;
    });

    document.addEventListener("mousemove", (event) => {
      if (!isDragging) return;
      win.style.left = `${Math.max(12, event.clientX - offsetX)}px`;
      win.style.top = `${Math.max(12, event.clientY - offsetY)}px`;
    });

    document.addEventListener("mouseup", () => {
      isDragging = false;
    });
  }

  function buildFloatingWindow(id, title) {
    const existing = document.getElementById(id);
    if (existing) {
      focusWindow(existing);
      return existing;
    }

    const win = document.createElement("div");
    win.className = "floating-window active";
    win.id = id;
    win.style.left = `${Math.max(24, Math.round((window.innerWidth - 920) / 2))}px`;
    win.style.top = `${Math.max(24, Math.round((window.innerHeight - 640) / 2))}px`;
    win.style.zIndex = String(++zCounter);
    applyWindowSizing(win);
    win.innerHTML = `
      <div class="floating-window-header">
        <div class="floating-window-title">
          <span class="floating-window-dot"></span>
          <span>${escapeHtml(title)}</span>
        </div>
        <div class="floating-window-actions">
          <button type="button" class="floating-window-btn" data-action="reload">↻</button>
          <button type="button" class="floating-window-btn" data-action="close">✕</button>
        </div>
      </div>
      <div class="floating-window-body"></div>
    `;
    dom.floatingWindows.appendChild(win);

    const header = win.querySelector(".floating-window-header");
    makeDraggable(win, header);
    win.addEventListener("mousedown", () => focusWindow(win));
    win.querySelector('[data-action="close"]')?.addEventListener("click", () => win.remove());
    win.querySelector('[data-action="reload"]')?.addEventListener("click", () => {
      if (typeof win.__reloadHandler__ === "function") win.__reloadHandler__();
    });
    focusWindow(win);
    return win;
  }

  function setWindowHtml(win, html) {
    const body = win.querySelector(".floating-window-body");
    if (body) body.innerHTML = html;
  }

  function setWindowFallback(win, title, launchUrl, extraHtml = "") {
    setWindowHtml(win, `
      <div class="floating-window-fallback" style="padding:20px; height:100%; overflow:auto;">
        <h3 style="margin-top:0;">${escapeHtml(title)}</h3>
        <p>The integrated view is not available for this page.</p>
        ${extraHtml}
        <p style="margin-top:16px;"><a class="floating-window-link" href="${launchUrl}" target="_blank" rel="noreferrer">Open in Google Maps</a></p>
      </div>
    `);
  }

  function setWindowFrame(win, url, title, launchUrl, extraHtml = "") {
    setWindowHtml(win, `<iframe class="floating-window-frame" src="${url}" referrerpolicy="no-referrer-when-downgrade" loading="lazy" style="width:100%;height:100%;border:0;"></iframe>`);
    const iframe = win.querySelector("iframe");
    const timer = setTimeout(() => {
      try {
        const currentUrl = iframe?.contentWindow?.location?.href;
        if (!currentUrl || currentUrl === "about:blank") setWindowFallback(win, title, launchUrl, extraHtml);
      } catch (_err) {
      }
    }, 3000);
    iframe?.addEventListener("load", () => clearTimeout(timer));
  }

  function openIntegratedWindow({ id, title, url, launchUrl = "", extraHtml = "" }) {
    const win = buildFloatingWindow(id, title);
    setWindowFrame(win, url, title, launchUrl || url, extraHtml);
    win.__reloadHandler__ = () => setWindowFrame(win, url, title, launchUrl || url, extraHtml);
    return win;
  }

  function buildGoogleMapsLaunchUrl(origin = "", destination = "") {
    if (!String(origin).trim() || !String(destination).trim()) return "https://www.google.com/maps";
    return `https://www.google.com/maps/dir/?api=1&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}`;
  }

  function buildGoogleMapsEmbedUrl(origin = "", destination = "", mode = "driving") {
    const key = String(CONFIG.googleMapsEmbedApiKey || "").trim();
    if (!key || !String(origin).trim() || !String(destination).trim()) return "";
    return `https://www.google.com/maps/embed/v1/directions?key=${encodeURIComponent(key)}&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&mode=${encodeURIComponent(mode)}`;
  }

  function openWebSearch(query = "") {
    const url = query ? `https://www.google.com/search?q=${encodeURIComponent(query)}` : "https://www.google.com";
    return openIntegratedWindow({ id: "gateway-window-web", title: query ? `Web · ${query}` : "Web", url, launchUrl: url });
  }

  function openMapSearch(query = "") {
    const launchUrl = query ? `https://www.google.com/maps?q=${encodeURIComponent(query)}` : "https://www.google.com/maps";
    const key = String(CONFIG.googleMapsEmbedApiKey || "").trim();
    const embedUrl = key && query ? `https://www.google.com/maps/embed/v1/search?key=${encodeURIComponent(key)}&q=${encodeURIComponent(query)}` : "";
    if (embedUrl) return openIntegratedWindow({ id: "gateway-window-map", title: query ? `Map · ${query}` : "Map", url: embedUrl, launchUrl });
    const win = buildFloatingWindow("gateway-window-map", query ? `Map · ${query}` : "Map");
    setWindowFallback(win, query ? `Map · ${query}` : "Map", launchUrl, query ? `<div style="margin-top:14px; padding:12px; border:1px solid rgba(255,255,255,0.12); border-radius:12px;"><strong>Search</strong><br>${escapeHtml(query)}</div>` : "");
    win.__reloadHandler__ = () => setWindowFallback(win, query ? `Map · ${query}` : "Map", launchUrl);
    return win;
  }

  function openRoute(origin = "", destination = "") {
    const launchUrl = buildGoogleMapsLaunchUrl(origin, destination);
    const embedUrl = buildGoogleMapsEmbedUrl(origin, destination);
    const hasRoute = String(origin).trim() && String(destination).trim();
    const title = hasRoute ? "Route" : "Map";
    const routeHtml = hasRoute ? `<div style="margin-top:14px; padding:12px; border:1px solid rgba(255,255,255,0.12); border-radius:12px;"><div style="margin-bottom:10px;"><strong>Origin</strong><br>${escapeHtml(origin)}</div><div><strong>Destination</strong><br>${escapeHtml(destination)}</div></div>` : "";
    if (embedUrl) return openIntegratedWindow({ id: "gateway-window-route", title, url: embedUrl, launchUrl, extraHtml: routeHtml });
    const win = buildFloatingWindow("gateway-window-route", title);
    setWindowFallback(win, title, launchUrl, `<p>Missing Google Maps Embed API key or incomplete route.</p>${routeHtml}`);
    win.__reloadHandler__ = () => setWindowFallback(win, title, launchUrl, `<p>Missing Google Maps Embed API key or incomplete route.</p>${routeHtml}`);
    return win;
  }

  function openRouteSearch(query = "") {
    const launchUrl = query ? `https://www.google.com/maps/dir/${encodeURIComponent(query)}` : "https://www.google.com/maps";
    const key = String(CONFIG.googleMapsEmbedApiKey || "").trim();
    if (key && query) {
      const embedUrl = `https://www.google.com/maps/embed/v1/search?key=${encodeURIComponent(key)}&q=${encodeURIComponent(query)}`;
      return openIntegratedWindow({ id: "gateway-window-route-search", title: `Route · ${query}`, url: embedUrl, launchUrl, extraHtml: `<div style="margin-top:14px; padding:12px; border:1px solid rgba(255,255,255,0.12); border-radius:12px;"><strong>Query</strong><br>${escapeHtml(query)}</div>` });
    }
    const win = buildFloatingWindow("gateway-window-route-search", query ? `Route · ${query}` : "Route");
    setWindowFallback(win, query ? `Route · ${query}` : "Route", launchUrl, query ? `<div style="margin-top:14px; padding:12px; border:1px solid rgba(255,255,255,0.12); border-radius:12px;"><strong>Query</strong><br>${escapeHtml(query)}</div>` : "");
    win.__reloadHandler__ = () => setWindowFallback(win, query ? `Route · ${query}` : "Route", launchUrl);
    return win;
  }

  function bindWindowButtons() {
    dom.clearChatBtn?.addEventListener("click", async () => {
      state.histories[state.activeUser] = [];
      renderMessages();
      await persistState();
    });
  }

  function bindTextarea() {
    dom.messageInput?.addEventListener("input", autoResizeTextarea);
  }

  if (!hasCoreDom()) {
    console.error("Incomplete DOM for GatewayUI", dom);
    return;
  }

  window.GatewayUI = { CONFIG, state, dom, escapeHtml, appendMessage, autoResizeTextarea, autoScroll, setThinking, loadState, persistState, renderMessages, renderUserPills, setActiveUser, openWebSearch, openMapSearch, openRouteSearch, openRoute };

  initHeader();
  bindShortcuts();
  bindWindowButtons();
  bindTextarea();
  loadState();
  autoResizeTextarea();
  dom.messageInput?.focus();
})();


path: static/css/app.css
content: :root {
  --bg-0: #01070c;
  --bg-1: #03111b;
  --bg-2: #06263c;
  --cyan: #67e8f9;
  --cyan-2: #22d3ee;
  --blue: #38bdf8;
  --line: rgba(80, 220, 255, 0.16);
  --line-strong: rgba(80, 220, 255, 0.34);
  --panel: rgba(3, 14, 25, 0.72);
  --panel-2: rgba(5, 20, 34, 0.86);
  --text: #d9f7ff;
  --muted: #8cb8c6;
  --good: #4ade80;
  --warn: #fbbf24;
  --danger: #fb7185;
  --shadow: 0 0 40px rgba(0, 208, 255, 0.12), inset 0 0 28px rgba(0, 180, 255, 0.04);
  --radius: 24px;
  --sidebar-w: 290px;
  --header-h: 88px;
  --thinking-glow: 0;
}

* {
  box-sizing: border-box;
}

html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: var(--text);
  background: radial-gradient(circle at center, #08253a 0%, #02131d 28%, #01070c 72%);
  overflow: hidden;
}

body.thinking {
  --thinking-glow: 1;
}

#jarvis-bg {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  display: block;
  opacity: 0.92;
  filter: saturate(1.1);
}

.background-vignette,
.background-vignette::before,
.background-vignette::after {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 1;
  content: "";
}

.background-vignette {
  background:
    radial-gradient(circle at 50% 48%, rgba(45, 183, 255, 0.10), transparent 18%),
    radial-gradient(circle at 50% 50%, rgba(38, 240, 255, 0.06), transparent 36%),
    radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 48%, rgba(0, 0, 0, 0.22) 100%);
}

.background-vignette::before {
  background:
    linear-gradient(180deg, rgba(0, 217, 255, 0.04), transparent 18%, transparent 82%, rgba(0, 217, 255, 0.04)),
    repeating-linear-gradient(
      180deg,
      rgba(120, 220, 255, 0.024) 0px,
      rgba(120, 220, 255, 0.024) 1px,
      transparent 2px,
      transparent 7px
    );
  mix-blend-mode: screen;
  opacity: 0.4;
  animation: scanlineDrift 14s linear infinite;
}

.background-vignette::after {
  background:
    radial-gradient(circle at 50% 50%, transparent 55%, rgba(0, 0, 0, 0.08) 78%, rgba(0, 0, 0, 0.25) 100%);
}

@keyframes scanlineDrift {
  0% {
    transform: translateY(-18px);
  }
  100% {
    transform: translateY(18px);
  }
}

.app-shell {
  position: relative;
  z-index: 2;
  height: 100vh;
  width: 100vw;
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  gap: 18px;
  padding: 18px;
}

.panel {
  position: relative;
  background: linear-gradient(180deg, rgba(6, 20, 34, 0.84), rgba(2, 10, 18, 0.78));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
  backdrop-filter: blur(8px);
}

.panel::before {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(135deg, rgba(90, 220, 255, 0.06), transparent 24%, transparent 74%, rgba(90, 220, 255, 0.05)),
    radial-gradient(circle at top right, rgba(80, 210, 255, 0.08), transparent 26%);
  pointer-events: none;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-height: 0;
}

.brand-card {
  padding: 16px;
  min-height: 106px;
  display: flex;
  align-items: center;
  gap: 16px;
}

.jarvis-orb {
  width: 74px;
  height: 74px;
  border-radius: 50%;
  position: relative;
  flex: 0 0 auto;
  background:
    radial-gradient(circle at 35% 35%, rgba(255, 255, 255, 0.95), rgba(135, 239, 255, 0.85) 18%, rgba(0, 207, 255, 0.32) 42%, rgba(0, 40, 65, 0.10) 62%, rgba(0, 0, 0, 0) 74%),
    radial-gradient(circle at 50% 50%, rgba(50, 230, 255, 0.32), rgba(2, 20, 30, 0.1) 58%, transparent 70%);
  box-shadow:
    0 0 calc(26px + 28px * var(--thinking-glow)) rgba(66, 235, 255, 0.48),
    0 0 calc(52px + 55px * var(--thinking-glow)) rgba(40, 180, 255, 0.18),
    inset 0 0 18px rgba(255, 255, 255, 0.25);
  animation: orbPulse 3.4s ease-in-out infinite;
}

.jarvis-orb::before,
.jarvis-orb::after {
  content: "";
  position: absolute;
  inset: -12px;
  border-radius: 50%;
  border: 1px solid rgba(88, 235, 255, 0.18);
  box-shadow: 0 0 18px rgba(0, 208, 255, 0.12);
  animation: rotateRing 12s linear infinite;
}

.jarvis-orb::after {
  inset: -21px;
  border-style: dashed;
  border-color: rgba(88, 235, 255, 0.12);
  animation-direction: reverse;
  animation-duration: 20s;
}

body.thinking .jarvis-orb {
  animation-duration: 1.2s;
  transform: scale(1.04);
}

@keyframes rotateRing {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

@keyframes orbPulse {
  0%,
  100% {
    transform: scale(0.98);
    filter: brightness(1);
  }
  50% {
    transform: scale(1.03);
    filter: brightness(calc(1 + 0.18 * (1 + var(--thinking-glow))));
  }
}

.brand-title {
  font-weight: 800;
  font-size: 18px;
  line-height: 1.05;
  letter-spacing: 0.05em;
}

.brand-sub {
  margin-top: 8px;
  color: var(--muted);
  font-size: 13px;
}

.section {
  padding: 14px;
}

.section h3 {
  margin: 0 0 12px;
  font-size: 13px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #9fd9e7;
}

.status-block {
  display: grid;
  gap: 10px;
  font-size: 13px;
  color: var(--muted);
}

.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  width: fit-content;
  border-radius: 999px;
  background: rgba(20, 255, 160, 0.08);
  color: #a7ffd2;
  border: 1px solid rgba(74, 222, 128, 0.24);
  box-shadow: 0 0 14px rgba(74, 222, 128, 0.08);
  font-weight: 600;
}

.dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 10px currentColor;
}

.kv {
  display: grid;
  gap: 2px;
  line-height: 1.25;
}

.shortcut-grid,
.window-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
}

.window-grid {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.shortcut-btn,
.window-btn,
.send-btn,
.user-pill,
.floating-window-btn,
.floating-window-link {
  appearance: none;
  border: 1px solid rgba(90, 220, 255, 0.18);
  background: linear-gradient(180deg, rgba(7, 36, 54, 0.95), rgba(4, 21, 35, 0.95));
  color: var(--text);
  border-radius: 16px;
  cursor: pointer;
  transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, background 0.18s ease;
  box-shadow: inset 0 0 14px rgba(83, 225, 255, 0.04), 0 0 0 rgba(0, 0, 0, 0);
}

.shortcut-btn:hover,
.window-btn:hover,
.send-btn:hover,
.user-pill:hover,
.floating-window-btn:hover,
.floating-window-link:hover {
  transform: translateY(-1px);
  border-color: rgba(90, 220, 255, 0.38);
  box-shadow: 0 0 20px rgba(50, 190, 255, 0.12), inset 0 0 18px rgba(83, 225, 255, 0.07);
}

.shortcut-btn {
  text-align: left;
  padding: 14px 14px 13px;
  min-height: 74px;
}

.shortcut-btn .big {
  font-weight: 700;
  margin-bottom: 3px;
}

.shortcut-btn .small {
  font-size: 12px;
  color: var(--muted);
}

.window-btn {
  padding: 10px 10px;
  font-weight: 600;
}

.main {
  display: grid;
  grid-template-rows: auto 1fr;
  min-height: 0;
}

.topbar {
  height: var(--header-h);
  padding: 14px 18px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 18px;
  margin-bottom: 14px;
}

.title-stack {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

.title-stack h1 {
  margin: 0;
  font-size: 18px;
  line-height: 1.1;
}

.title-stack .sub {
  color: var(--muted);
  font-size: 13px;
}

.user-pills {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  justify-content: flex-end;
}

.user-pill {
  padding: 11px 18px;
  border-radius: 999px;
  font-weight: 600;
  color: #bbf4ff;
}

.user-pill.active {
  background: linear-gradient(180deg, rgba(20, 72, 102, 0.98), rgba(7, 38, 61, 0.98));
  border-color: rgba(109, 232, 255, 0.52);
  box-shadow: 0 0 22px rgba(60, 205, 255, 0.2), inset 0 0 16px rgba(109, 232, 255, 0.14);
}

.workspace {
  display: grid;
  grid-template-rows: auto 1fr;
  min-height: 0;
  padding: 0;
}

.composer {
  padding: 16px 16px 12px;
  border-bottom: 1px solid rgba(80, 220, 255, 0.12);
  position: relative;
  overflow: hidden;
}

.composer::after {
  content: "";
  position: absolute;
  left: -20%;
  right: -20%;
  top: 0;
  height: 1px;
  background: linear-gradient(90deg, transparent, rgba(90, 220, 255, 0.55), transparent);
  animation: topScan 6s linear infinite;
}

@keyframes topScan {
  0% {
    transform: translateX(-20%);
    opacity: 0.4;
  }
  50% {
    opacity: 1;
  }
  100% {
    transform: translateX(20%);
    opacity: 0.4;
  }
}

.composer-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 14px;
  align-items: end;
}

.input-shell {
  position: relative;
  border-radius: 22px;
  border: 1px solid rgba(96, 233, 255, 0.3);
  background: rgba(1, 7, 14, 0.92);
  box-shadow:
    inset 0 0 20px rgba(0, 204, 255, 0.05),
    0 0 24px rgba(0, 204, 255, 0.08);
  overflow: hidden;
}

.input-shell::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(135deg, rgba(100, 240, 255, 0.08), transparent 20%),
    radial-gradient(circle at right center, rgba(50, 190, 255, 0.09), transparent 26%);
}

textarea {
  width: 100%;
  min-height: 106px;
  max-height: 220px;
  resize: none;
  border: 0;
  outline: none;
  padding: 20px 22px;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 15px;
  line-height: 1.5;
}

textarea::placeholder {
  color: rgba(196, 236, 244, 0.58);
}

.send-col {
  display: grid;
  gap: 10px;
  align-items: end;
  justify-items: end;
  min-width: 120px;
}

.send-btn {
  min-width: 122px;
  min-height: 62px;
  padding: 14px 18px;
  border-radius: 18px;
  font-size: 16px;
  font-weight: 700;
}

.status-line {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 10px;
  color: var(--muted);
  font-size: 13px;
}

.thinking-indicator {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.2s ease, transform 0.2s ease;
  color: #9fe9ff;
}

body.thinking .thinking-indicator {
  opacity: 1;
  transform: translateY(0);
}

.wave {
  display: inline-flex;
  align-items: end;
  gap: 3px;
  height: 16px;
}

.wave span {
  width: 3px;
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(130, 243, 255, 1), rgba(34, 211, 238, 0.35));
  box-shadow: 0 0 10px rgba(86, 234, 255, 0.24);
  animation: waveBounce 0.95s ease-in-out infinite;
}

.wave span:nth-child(1) {
  height: 8px;
  animation-delay: 0s;
}
.wave span:nth-child(2) {
  height: 14px;
  animation-delay: 0.12s;
}
.wave span:nth-child(3) {
  height: 10px;
  animation-delay: 0.22s;
}
.wave span:nth-child(4) {
  height: 16px;
  animation-delay: 0.32s;
}
.wave span:nth-child(5) {
  height: 11px;
  animation-delay: 0.42s;
}

@keyframes waveBounce {
  0%,
  100% {
    transform: scaleY(0.65);
    opacity: 0.65;
  }
  50% {
    transform: scaleY(1.15);
    opacity: 1;
  }
}

.chat {
  position: relative;
  min-height: 0;
  overflow: auto;
  padding: 20px 18px 28px;
  scroll-behavior: smooth;
}

.chat::before {
  content: "";
  position: sticky;
  top: -20px;
  display: block;
  height: 28px;
  margin: -20px -18px 0;
  background: linear-gradient(180deg, rgba(2, 11, 18, 0.82), rgba(2, 11, 18, 0));
  z-index: 2;
  pointer-events: none;
}

.messages {
  display: flex;
  flex-direction: column;
  gap: 18px;
  position: relative;
  z-index: 1;
}

.message-row {
  display: flex;
  width: 100%;
  flex: 0 0 auto;
}

.message-row.user {
  justify-content: flex-end;
}

.message-row.assistant {
  justify-content: flex-start;
}

.bubble {
  display: inline-block;
  width: auto;
  max-width: min(70%, 760px);
  padding: 16px 18px;
  border-radius: 22px;
  position: relative;
  white-space: pre-wrap;
  line-height: 1.5;
  font-size: 15px;
  border: 1px solid rgba(98, 225, 255, 0.14);
  overflow: hidden;
  flex: 0 0 auto;
}

.bubble::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(135deg, rgba(88, 235, 255, 0.05), transparent 30%);
}

.message-row.user .bubble {
  background: linear-gradient(180deg, rgba(7, 50, 72, 0.92), rgba(5, 29, 44, 0.95));
  box-shadow: 0 0 18px rgba(39, 186, 255, 0.08), inset 0 0 18px rgba(99, 235, 255, 0.04);
  color: #dffbff;
  border-top-right-radius: 10px;
}

.message-row.assistant .bubble {
  background: linear-gradient(180deg, rgba(2, 12, 22, 0.92), rgba(2, 10, 17, 0.97));
  box-shadow: 0 0 18px rgba(39, 186, 255, 0.06), inset 0 0 18px rgba(99, 235, 255, 0.03);
  border-top-left-radius: 10px;
}

/* Correction de la bulle "Jarvis réfléchit" */
#typingRow,
.typing-row {
  display: none;
  width: 100%;
  flex: 0 0 auto;
  align-items: flex-start;
  justify-content: flex-start;
  margin: 0;
  padding: 0;
}

body.thinking #typingRow,
body.thinking .typing-row {
  display: flex;
}

#typingRow .message-row,
.typing-row.message-row {
  display: flex;
  width: 100%;
  flex: 0 0 auto;
  align-items: flex-start;
  justify-content: flex-start;
}

.typing-bubble,
#typingRow .typing-bubble,
.typing-row .typing-bubble {
  display: inline-flex;
  width: auto;
  max-width: min(75%, 560px);
  min-height: 0;
  flex: 0 0 auto;
  align-items: center;
  gap: 10px;
  white-space: normal;
  background: linear-gradient(180deg, rgba(2, 12, 22, 0.92), rgba(2, 10, 17, 0.97));
  padding: 14px 18px;
  border-radius: 20px 20px 20px 10px;
  border: 1px solid rgba(98, 225, 255, 0.14);
  color: #b8f4ff;
  box-shadow: 0 0 18px rgba(39, 186, 255, 0.06), inset 0 0 18px rgba(99, 235, 255, 0.03);
}

.typing-dots {
  display: inline-flex;
  gap: 6px;
  align-items: center;
}

.typing-dots span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #77ecff;
  box-shadow: 0 0 12px rgba(119, 236, 255, 0.35);
  animation: typingDots 1.2s ease-in-out infinite;
}

.typing-dots span:nth-child(2) {
  animation-delay: 0.15s;
}
.typing-dots span:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes typingDots {
  0%,
  80%,
  100% {
    transform: translateY(0) scale(0.8);
    opacity: 0.45;
  }
  40% {
    transform: translateY(-4px) scale(1);
    opacity: 1;
  }
}

.footer-hud {
  position: absolute;
  right: 26px;
  bottom: 18px;
  display: flex;
  gap: 14px;
  align-items: center;
  color: rgba(160, 226, 241, 0.7);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  pointer-events: none;
}

.footer-chip {
  padding: 6px 10px;
  border-radius: 999px;
  border: 1px solid rgba(96, 233, 255, 0.18);
  background: rgba(4, 18, 28, 0.55);
}

.floating-windows {
  position: fixed;
  inset: 0;
  z-index: 30;
  pointer-events: none;
}

.floating-window {
  position: absolute;
  width: 920px;
  height: 640px;
  min-width: 420px;
  min-height: 260px;
  border-radius: 18px;
  border: 1px solid rgba(90, 220, 255, 0.28);
  background: linear-gradient(180deg, rgba(4, 18, 30, 0.97), rgba(2, 10, 18, 0.98));
  box-shadow: 0 0 30px rgba(0, 200, 255, 0.16), inset 0 0 22px rgba(80, 220, 255, 0.04);
  overflow: hidden;
  pointer-events: auto;
  backdrop-filter: blur(10px);
}

.floating-window.active {
  border-color: rgba(109, 232, 255, 0.5);
}

.floating-window-header {
  height: 52px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 14px 0 16px;
  cursor: move;
  user-select: none;
  border-bottom: 1px solid rgba(90, 220, 255, 0.14);
  background: linear-gradient(180deg, rgba(7, 36, 54, 0.94), rgba(4, 21, 35, 0.94));
}

.floating-window-title {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #c9f8ff;
}

.floating-window-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #67e8f9;
  box-shadow: 0 0 12px rgba(103, 232, 249, 0.8);
}

.floating-window-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.floating-window-btn {
  border-radius: 10px;
  min-width: 34px;
  height: 34px;
  font-weight: 700;
}

.floating-window-body {
  position: absolute;
  inset: 52px 0 0 0;
  background: rgba(2, 10, 18, 0.96);
}

.floating-window-frame,
.floating-window-fallback {
  width: 100%;
  height: 100%;
  border: 0;
}

.floating-window-fallback {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 24px;
  text-align: center;
  color: #bfeef8;
}

.floating-window-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 42px;
  padding: 0 16px;
  border-radius: 12px;
  text-decoration: none;
  font-weight: 600;
}

.chat::-webkit-scrollbar {
  width: 10px;
}
.chat::-webkit-scrollbar-track {
  background: rgba(255, 255, 255, 0.02);
}
.chat::-webkit-scrollbar-thumb {
  background: linear-gradient(180deg, rgba(93, 233, 255, 0.34), rgba(35, 144, 197, 0.2));
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: padding-box;
}

@media (max-width: 1100px) {
  :root {
    --sidebar-w: 250px;
  }
  .bubble {
    max-width: 82%;
  }
  .typing-bubble,
  #typingRow .typing-bubble,
  .typing-row .typing-bubble {
    max-width: 82%;
  }
}

@media (max-width: 920px) {
  .app-shell {
    grid-template-columns: 1fr;
    grid-template-rows: auto 1fr;
    overflow: auto;
    height: auto;
    min-height: 100vh;
  }

  .sidebar {
    order: 2;
  }

  .main {
    order: 1;
  }

  body {
    overflow: auto;
  }

  .floating-window {
    width: calc(100vw - 24px) !important;
    height: calc(100vh - 24px) !important;
    left: 12px !important;
    top: 12px !important;
  }
}


path: config/participants.json
content: [
  
  {
    "key": "romain",
    "display_name": "Romain",
    "aliases": ["romain"]
  },
  {
    "key": "amelie",
    "display_name": "Amélie",
    "aliases": ["amelie"]
  },
  {
    "key": "theo",
    "display_name": "Théo",
    "aliases": ["theo"]
  },
  {
    "key": "malik",
    "display_name": "Malik",
    "aliases": ["malik"]
  }
]


path: config/participants.example.json
content: [
  {
    "key": "alex",
    "display_name": "Alex",
    "aliases": ["alex"]
  },
  {
    "key": "sam",
    "display_name": "Sam",
    "aliases": ["sam"]
  },
  {
    "key": "family",
    "display_name": "Family",
    "aliases": ["family"]
  }
]


path: templates/index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>{{ app_title }}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body>
  <canvas id="jarvis-bg"></canvas>
  <div class="background-vignette"></div>

  <div class="app-shell">
    <aside class="sidebar">
      <section class="panel brand-card">
        <div class="jarvis-orb"></div>
        <div>
          <div class="brand-title">OPENCLAW<br>WEB GATEWAY</div>
          <div class="brand-sub">{{ app_subtitle }}</div>
        </div>
      </section>

      <section class="panel section">
        <h3>System status</h3>
        <div class="status-block">
          <div style="font-size: 18px; font-weight: 800; color: #e3fbff;">OpenClaw HTTP mode</div>
          <div class="status-pill"><span class="dot"></span>Connection active</div>
          <div class="kv"><strong style="color:#d9f7ff;">Base URL:</strong> <span id="baseUrlLabel"></span></div>
          <div class="kv"><strong style="color:#d9f7ff;">Token loaded:</strong> <span id="tokenLoadedLabel"></span></div>
          <div class="kv"><strong style="color:#d9f7ff;">Active user:</strong> <span id="activeUserLabel"></span></div>
        </div>
      </section>

      <section class="panel section">
        <h3>Shortcuts</h3>
        <div class="shortcut-grid">
          <button class="shortcut-btn" data-shortcut="agenda">
            <div class="big">Agenda</div>
            <div class="small">Upcoming events</div>
          </button>
          <button class="shortcut-btn" data-shortcut="home">
            <div class="big">Home</div>
            <div class="small">Global home status</div>
          </button>
          <button class="shortcut-btn" data-shortcut="weather">
            <div class="big">Weather</div>
            <div class="small">Current conditions</div>
          </button>
          <button class="shortcut-btn" data-shortcut="diag">
            <div class="big">OpenClaw</div>
            <div class="small">Quick diagnostic</div>
          </button>
        </div>
      </section>

      <section class="panel section" style="flex:1; min-height: 220px;">
        <h3>Windows</h3>
        <div class="window-grid">
          <button class="window-btn" id="clearChatBtn">Clear</button>
        </div>
      </section>
    </aside>

    <main class="main">
      <header class="panel topbar">
        <div class="title-stack">
          <h1>{{ app_title }}</h1>
          <div class="sub">Local chat with OpenClaw and integrated windows</div>
        </div>
        <div class="user-pills" id="userPills"></div>
      </header>

      <section class="panel workspace">
        <div class="composer">
          <div class="composer-row">
            <div class="input-shell">
              <textarea id="messageInput" placeholder="Example: what events do I have today?"></textarea>
            </div>
            <div class="send-col">
              <button class="send-btn" id="sendBtn">Send</button>
              <div id="inputState" style="color:#9fd9e7; font-size:13px;">Ready</div>
            </div>
          </div>
          <div class="status-line">
            <div>Press Enter to send · Shift+Enter for a new line</div>
            <div class="thinking-indicator">
              <div class="wave"><span></span><span></span><span></span><span></span><span></span></div>
              <span>Thinking…</span>
            </div>
          </div>
        </div>

        <div class="typing-row" id="typingRow">
          <div class="typing-bubble">
            <span>Assistant is thinking</span>
            <div class="typing-dots"><span></span><span></span><span></span></div>
          </div>
        </div>

        <div class="chat" id="chatScroll">
          <div class="messages" id="messages"></div>

          <div class="footer-hud">
            <div class="footer-chip">HUD</div>
            <div class="footer-chip" id="hudUserChip">USER: -</div>
          </div>
        </div>
      </section>
    </main>
  </div>

  <div id="floatingWindows" class="floating-windows"></div>

  <script>
    window.GATEWAY_CONFIG = {
      appTitle: {{ app_title|tojson }},
      openclawBase: {{ openclaw_base|tojson }},
      tokenLoaded: {{ token_loaded|tojson }},
      defaultUser: {{ default_user|tojson }},
      users: {{ users|tojson }},
      googleMapsEmbedApiKey: {{ google_maps_embed_api_key|tojson }}
    };
  </script>

  <script src="{{ url_for('static', filename='js/ui.js') }}"></script>
  <script src="{{ url_for('static', filename='js/chat.js') }}"></script>
  <script src="{{ url_for('static', filename='js/background.js') }}"></script>
</body>
</html>


path: routes/chat.py
content: from __future__ import annotations

import re
from urllib.parse import parse_qs, unquote_plus, urlparse

from flask import Blueprint, jsonify, request

from config import DEFAULT_USER, PARTICIPANTS, canonical_user, normalize_user_key, participant_aliases
from memory_store import MemoryStore
from openclaw_client import call_openclaw


chat_bp = Blueprint("chat", __name__)
memory = MemoryStore()
ALIASES = participant_aliases()
DISPLAY_NAMES = [item["display_name"] for item in PARTICIPANTS]

KNOWN_PLACES = {
    "home": "__HOME__",
    "house": "__HOME__",
    "domicile": "__HOME__",
    "maison": "__HOME__",
    "work": "__WORK__",
    "office": "__WORK__",
    "job": "__WORK__",
    "travail": "__WORK__",
    "parking du flon": "Parking du Flon, Lausanne",
    "flon": "Parking du Flon, Lausanne",
}

FIELD_LABELS = {
    "aquarium": "aquarium",
    "work": "work",
    "home": "home",
    "address": "address",
    "likes": "likes",
    "lessons": "lessons",
    "instrument": "instrument",
    "activity": "activity",
}


def normalize_history(raw_history):
    if not isinstance(raw_history, list):
        return []
    cleaned = []
    for item in raw_history:
        if not isinstance(item, dict):
            continue
        role = item.get("role")
        content = str(item.get("content") or "").strip()
        if role in {"system", "user", "assistant"} and content:
            cleaned.append({"role": role, "content": content})
    return cleaned



def detect_user_from_message(message: str):
    if not message:
        return None, ""
    msg = message.strip()
    lower = msg.lower()
    for alias, display_name in ALIASES.items():
        if lower.startswith(alias + ":") or lower.startswith(alias + ","):
            return display_name, msg[len(alias) + 1 :].strip()
    return None, msg



def _normalize_subject_name(subject: str, active_user: str) -> str:
    s = str(subject or "").strip()
    if not s:
        return active_user
    lowered = s.lower()

    pronouns = {
        "i": active_user,
        "me": active_user,
        "my": active_user,
        "je": active_user,
        "moi": active_user,
        "mon": active_user,
        "ma": active_user,
        "mes": active_user,
        "papa": "father",
        "dad": "father",
        "maman": "mother",
        "mom": "mother",
        "mum": "mother",
        "mother": "mother",
        "father": "father",
    }

    if lowered in pronouns:
        [REDACTED_SECRET]
        if token == "father":
            for display_name in DISPLAY_NAMES:
                if display_name != active_user:
                    return display_name
        if token == "mother":
            for display_name in DISPLAY_NAMES:
                if display_name != active_user:
                    return display_name
        return token

    return ALIASES.get(lowered, s)



def should_auto_memorize(message: str) -> bool:
    msg = str(message or "").strip()
    lower = msg.lower()
    if len(msg) < 8 or len(msg) > 180 or "?" in msg:
        return False
    blocked_starts = [
        "hello",
        "hi",
        "thanks",
        "thank you",
        "ok",
        "okay",
        "bonjour",
        "salut",
        "merci",
        "can you",
        "could you",
        "peux-tu",
        "tu peux",
        "what",
        "when",
        "where",
        "why",
        "how",
        "quel",
        "quelle",
        "quand",
        "où",
        "ou ",
        "pourquoi",
        "comment",
    ]
    if any(lower.startswith(x) for x in blocked_starts):
        return False

    durable_patterns = [
        r"\bi like\b",
        r"\bi play\b",
        r"\bi work\b",
        r"\blives? at\b",
        r"\baddress\b",
        r"\bhome\b",
        r"\bdomicile\b",
        r"\btravaille\b",
        r"\bhabite\b",
        r"\baime\b",
        r"\bpratique\b",
        r"\baquarium\b",
    ]
    return any(re.search(pattern, lower) for pattern in durable_patterns)



def canonicalize_fact(message: str, user: str) -> str | None:
    msg = " ".join(str(message or "").strip().split())
    lower = msg.lower()

    if lower.startswith("i "):
        return f"{user} {msg[2:]}".strip()
    if lower.startswith("je "):
        return f"{user} {msg[3:]}".strip()
    if lower.startswith(("he ", "she ", "il ", "elle ")):
        return None

    for alias, display_name in ALIASES.items():
        if lower.startswith(alias + " "):
            return display_name + msg[len(alias) :]

    return msg if should_auto_memorize(msg) else None



def extract_structured_fact(text: str, active_user: str):
    msg = " ".join(str(text or "").strip().split())
    patterns = [
        (r"^(.+?)\s+has\s+an?\s+aquarium(?:\s+of\s+(freshwater|saltwater))?$", lambda m: (_normalize_subject_name(m.group(1), active_user), "aquarium", (m.group(2) or "yes").strip())),
        (r"^(.+?)\s+a\s+un[e]?\s+aquarium(?:\s+d['’]?(eau douce|eau de mer))?$", lambda m: (_normalize_subject_name(m.group(1), active_user), "aquarium", (m.group(2) or "yes").strip())),
        (r"^(.+?)\s+works?\s+at\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "work", m.group(2).strip())),
        (r"^(.+?)\s+travaille\s+chez\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "work", m.group(2).strip())),
        (r"^(.+?)\s+lives?\s+at\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "home", m.group(2).strip())),
        (r"^(.+?)\s+habite\s+(?:à|au)\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "home", m.group(2).strip())),
        (r"^(.+?)\s+address\s*:\s*(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "address", m.group(2).strip())),
        (r"^(.+?)\s+adresse\s*:\s*(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "address", m.group(2).strip())),
        (r"^(.+?)\s+likes\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "likes", m.group(2).strip())),
        (r"^(.+?)\s+aime\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "likes", m.group(2).strip())),
        (r"^(.+?)\s+takes?\s+lessons?\s+in\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "lessons", m.group(2).strip())),
        (r"^(.+?)\s+prend\s+des\s+cours\s+de\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "lessons", m.group(2).strip())),
        (r"^(.+?)\s+plays?\s+the\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "instrument", m.group(2).strip())),
        (r"^(.+?)\s+joue\s+du\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "instrument", m.group(2).strip())),
        (r"^(.+?)\s+does\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "activity", m.group(2).strip())),
        (r"^(.+?)\s+(?:fait\s+de|pratique)\s+(.+?)$", lambda m: (_normalize_subject_name(m.group(1), active_user), "activity", m.group(2).strip())),
    ]

    for pattern, builder in patterns:
        match = re.match(pattern, msg, flags=re.IGNORECASE)
        if match:
            subject, field, value = builder(match)
            return {"subject": subject, "field": field, "value": value}
    return None



def build_memory_context(message: str, user: str) -> str:
    memory_user = normalize_user_key(user)
    parts = []
    user_context = memory.build_context_for_user(memory_user)
    if user_context:
        parts.append(user_context)
    memories = memory.search(query=message, limit=5)
    if memories:
        lines = [f"- {hit.subject} | {hit.field}: {hit.value}" for hit in memories]
        parts.append("Relevant persistent memory for this conversation:\n" + "\n".join(lines))
    return "\n\n".join(parts).strip()



def build_last_subject_from_history(history, user):
    user_messages = [str(item.get("content") or "").strip() for item in history if item.get("role") == "user" and str(item.get("content") or "").strip()]
    for text in reversed(user_messages):
        fact = extract_structured_fact(text, user)
        if fact:
            return fact["subject"]
        match = re.match(r"^(?:what\s+do\s+you\s+know\s+about|que\s+sais-tu\s+de|est-ce\s+que)\s+(.+?)\??$", text, flags=re.IGNORECASE)
        if match:
            return _normalize_subject_name(match.group(1), user)
    return None



def format_subject_summary(subject: str) -> str:
    summary = memory.get_subject_summary(subject)
    if not summary.get("found"):
        return f"I do not have persistent information about {subject} yet."
    facts = summary.get("facts", {})
    if not facts:
        return f"I do not have detailed information about {subject} yet."
    lines = [f"Here is what I know about {summary['subject']}: "]
    for key, value in facts.items():
        lines.append(f"- {FIELD_LABELS.get(key, key)}: {value}")
    return "\n".join(lines)



def format_field_answer(subject: str, field: str) -> str:
    summary = memory.get_subject_summary(subject)
    if not summary.get("found"):
        return f"I do not have that information for {subject}."
    facts = summary.get("facts", {})
    if field not in facts:
        return f"I do not have that information for {subject}."
    value = facts[field]
    if field == "aquarium":
        if value in {"yes", "oui"}:
            return f"Yes, {subject} has an aquarium."
        return f"Yes, {subject} has an aquarium, more specifically: {value}."
    return f"For {subject}, {FIELD_LABELS.get(field, field)}: {value}."



def is_route_request(message: str) -> bool:
    lower = str(message or "").lower()
    keywords = [
        "route",
        "directions",
        "trip",
        "travel time",
        "how long",
        "trajet",
        "itinéraire",
        "itineraire",
        "temps de trajet",
        "combien de temps",
    ]
    return any(keyword in lower for keyword in keywords)



def route_system_instruction(user: str) -> str:
    return f"""
You are helping open a Google Maps route inside the web gateway.
Do not call tools.
Do not say that a tool is missing.
If you can infer an origin and destination from the user message, persistent memory, or the current user ({user}), start your answer with exactly:
ROUTE: <origin> || <destination>
Then add a short human reply.
If you cannot resolve both places, reply normally without a ROUTE line.
""".strip()



def extract_route_from_reply(text: str):
    raw = str(text or "").strip()
    if not raw:
        return None
    route_line = re.search(r"^\s*ROUTE:\s*(.+?)\s*\|\|\s*(.+?)\s*$", raw, flags=re.IGNORECASE | re.MULTILINE)
    if route_line:
        origin = route_line.group(1).strip()
        destination = route_line.group(2).strip()
        if origin and destination:
            return origin, destination
    url_match = re.search(r"https?://www\.google\.com/maps/dir/\?api=1[^\s]+", raw, flags=re.IGNORECASE)
    if url_match:
        parsed = urlparse(url_match.group(0))
        qs = parse_qs(parsed.query)
        origin = unquote_plus((qs.get("origin") or [""])[0]).strip()
        destination = unquote_plus((qs.get("destination") or [""])[0]).strip()
        if origin and destination:
            return origin, destination
    return None



def clean_reply_text(text: str) -> str:
    cleaned = str(text or "").strip()
    cleaned = re.sub(r"^\s*ROUTE:\s*.+?\s*\|\|\s*.+?\s*$", "", cleaned, flags=re.IGNORECASE | re.MULTILINE)
    cleaned = re.sub(r"https?://www\.google\.com/maps/dir/\?api=1[^\s]+", "", cleaned, flags=re.IGNORECASE)
    cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
    return cleaned



def _get_fact_value(subject: str, field: str) -> str:
    summary = memory.get_subject_summary(subject)
    if not summary.get("found"):
        return ""
    return str(summary.get("facts", {}).get(field) or "").strip()



def resolve_special_place(token: str, user: str) -> str:
    [REDACTED_SECRET] or "").strip()
    if not [REDACTED_SECRET] ""
    lowered = token.lower()
    mapped = KNOWN_PLACES.get(lowered)
    if mapped == "__HOME__":
        return _get_fact_value(user, "home") or _get_fact_value(user, "address")
    if mapped == "__WORK__":
        return _get_fact_value(user, "work")
    if mapped:
        return mapped
    return token



def extract_route_from_message(message: str, user: str):
    msg = " ".join(str(message or "").strip().split())
    lower = msg.lower()

    match = re.search(r"\bfrom\s+(.+?)\s+to\s+(.+)$", msg, flags=re.IGNORECASE)
    if match:
        origin = resolve_special_place(match.group(1), user)
        destination = resolve_special_place(match.group(2), user)
        if origin and destination:
            return origin, destination

    match = re.search(r"\bde\s+(.+?)\s+à\s+(.+)$", msg, flags=re.IGNORECASE)
    if match:
        origin = resolve_special_place(match.group(1), user)
        destination = resolve_special_place(match.group(2), user)
        if origin and destination:
            return origin, destination

    match = re.search(r"\b(?:go|going|route|directions|trip|trajet|itinéraire|itineraire).*(?:to|au|à|a)\s+(.+)$", msg, flags=re.IGNORECASE)
    if match:
        destination = resolve_special_place(match.group(1), user)
        origin = resolve_special_place("home", user)
        if origin and destination:
            return origin, destination

    match = re.search(r"\b(?:travel time|how long|temps de trajet|combien de temps).*(?:to|vers|pour aller à|pour aller au|jusqu['’]a|jusqu['’]à)\s+(.+)$", lower, flags=re.IGNORECASE)
    if match:
        destination = resolve_special_place(match.group(1), user)
        origin = resolve_special_place("home", user)
        if origin and destination:
            return origin, destination

    for key, mapped in KNOWN_PLACES.items():
        if key in lower and mapped not in {"__HOME__", "__WORK__"}:
            destination = resolve_special_place(key, user)
            origin = resolve_special_place("home", user)
            if origin and destination:
                return origin, destination

    return None


@chat_bp.route("/api/chat", methods=["POST"])
def api_chat():
    data = request.get_json(silent=True) or {}
    user = canonical_user(data.get("user", DEFAULT_USER))
    history = normalize_history(data.get("history", []))
    message = str(data.get("message") or "").strip()

    detected_user, cleaned_message = detect_user_from_message(message)
    if detected_user:
        user = detected_user
        message = cleaned_message

    if not message:
        return jsonify({"error": "Empty message"}), 400

    try:
        lower = message.lower().strip()
        memory_user = normalize_user_key(user)

        memory_prefixes = [
            "remember that ",
            "note that ",
            "memorise que ",
            "mémorise que ",
            "souviens-toi que ",
            "souviens toi que ",
        ]

        memory_text = None
        for prefix in memory_prefixes:
            if lower.startswith(prefix):
                memory_text = message[len(prefix) :].strip()
                break

        if memory_text:
            fact = extract_structured_fact(memory_text, user)
            if fact:
                already_exists = memory.fact_exists(fact["subject"], fact["field"], fact["value"])
                if not already_exists:
                    memory.remember_fact(fact["subject"], fact["field"], fact["value"], source_user=memory_user)
                    return jsonify({"reply": f"Saved for {fact['subject']}: {fact['field']} = {fact['value']}.", "user": user})
                return jsonify({"reply": "That information is already present in persistent memory.", "user": user})

            fallback_fact = canonicalize_fact(memory_text, user) or memory_text
            memory.append_note(memory_user, fallback_fact)
            return jsonify({"reply": "The information was added to persistent memory.", "user": user})

        summary_match = re.match(r"^(?:what\s+do\s+you\s+know\s+about|que\s+sais-tu\s+de|qu'est-ce\s+que\s+tu\s+sais\s+de)\s+(.+?)\s*\??$", message, flags=re.IGNORECASE)
        if summary_match:
            subject = _normalize_subject_name(summary_match.group(1), user)
            return jsonify({"reply": format_subject_summary(subject), "user": user})

        aquarium_check = re.match(r"^(?:does|est-ce\s+que|es[- ]tu\s+s[ûu]r\s+que)\s+(.+?)\s+(?:have|a)\s+an?\s+aquarium\s*\??$", message, flags=re.IGNORECASE)
        if aquarium_check:
            subject = _normalize_subject_name(aquarium_check.group(1), user)
            return jsonify({"reply": format_field_answer(subject, "aquarium"), "user": user})

        if re.match(r"^(?:what\s+kind|which\s+kind|quel\s+genre|quel\s+type)\s*\??$", message, flags=re.IGNORECASE) or re.match(r"^(?:freshwater\s+or\s+saltwater|eau\s+douce\s+ou\s+eau\s+de\s+mer)\s*\??$", message, flags=re.IGNORECASE):
            last_subject = build_last_subject_from_history(history, user)
            if last_subject:
                return jsonify({"reply": format_field_answer(last_subject, "aquarium"), "user": user})

        auto_fact_text = canonicalize_fact(message, user)
        if auto_fact_text and should_auto_memorize(message):
            structured = extract_structured_fact(auto_fact_text, user)
            if structured:
                if not memory.fact_exists(structured["subject"], structured["field"], structured["value"]):
                    memory.remember_fact(structured["subject"], structured["field"], structured["value"], source_user=memory_user)
            else:
                memory.append_note(memory_user, auto_fact_text)

        if is_route_request(message):
            direct_route = extract_route_from_message(message, user)
            if direct_route:
                origin, destination = direct_route
                return jsonify({"reply": "Here is the route.", "user": user, "action": {"type": "open_route", "origin": origin, "destination": destination}})

        memory_context = build_memory_context(message=message, user=user)
        augmented_history = list(history)
        if is_route_request(message):
            augmented_history.insert(0, {"role": "system", "content": route_system_instruction(user)})
        if memory_context:
            augmented_history.insert(0, {"role": "system", "content": memory_context})

        reply = call_openclaw(user=user, message=message, history=augmented_history)
        route = extract_route_from_reply(reply)
        cleaned_reply = clean_reply_text(reply)

        if route:
            origin, destination = route
            return jsonify({"reply": cleaned_reply or "Here is the route.", "user": user, "action": {"type": "open_route", "origin": origin, "destination": destination}})

        return jsonify({"reply": cleaned_reply or reply, "user": user})
    except Exception as exc:
        return jsonify({"error": str(exc)}), 500


path: routes/state.py
content: from flask import Blueprint, jsonify, request

from state_manager import load_state, save_state


state_bp = Blueprint("state", __name__)


@state_bp.route("/api/state", methods=["GET"])
def api_get_state():
    return jsonify(load_state())


@state_bp.route("/api/state", methods=["POST"])
def api_post_state():
    save_state(request.get_json(silent=True) or {})
    return jsonify({"ok": True})


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)