F4bC0d3 commited on
Commit
5f92103
·
0 Parent(s):

Initial release: HuggingMes + Hermes WebUI integration

Browse files
Files changed (11) hide show
  1. .dockerignore +14 -0
  2. .env.example +38 -0
  3. .gitignore +11 -0
  4. Dockerfile +111 -0
  5. LICENSE +36 -0
  6. README.md +202 -0
  7. cloudflare-keepalive-setup.py +225 -0
  8. cloudflare-proxy-setup.py +203 -0
  9. health-server.js +928 -0
  10. hermes-sync.py +337 -0
  11. start.sh +428 -0
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .dockerignore
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ .kiro
8
+ .venv
9
+ venv
10
+ __pycache__
11
+ *.pyc
12
+ *.pyo
13
+ *.log
14
+ node_modules
.env.example ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Required ──────────────────────────────────────────────────────────
2
+ GATEWAY_TOKEN=change-me-to-a-strong-random-string
3
+ LLM_API_KEY=your-llm-provider-api-key
4
+ LLM_MODEL=openrouter/anthropic/claude-sonnet-4
5
+
6
+ # Examples for other providers:
7
+ # LLM_MODEL=openai/gpt-4o
8
+ # LLM_MODEL=anthropic/claude-sonnet-4-6
9
+ # LLM_MODEL=google/gemini-2.5-flash
10
+ # LLM_MODEL=deepseek/deepseek-chat
11
+ # LLM_MODEL=huggingface/Qwen/Qwen3-235B-A22B-Thinking-2507
12
+
13
+ # ── HF Dataset persistence (recommended) ──────────────────────────────
14
+ # Write-scope token: https://huggingface.co/settings/tokens
15
+ # HF_TOKEN=hf_xxx
16
+ # BACKUP_DATASET_NAME=huggingmes-backup
17
+ # SYNC_INTERVAL=600
18
+
19
+ # ── Optional: Cloudflare proxy + keep-alive ───────────────────────────
20
+ # CLOUDFLARE_WORKERS_TOKEN=cf_xxx
21
+ # CLOUDFLARE_ACCOUNT_ID=
22
+ # CLOUDFLARE_KEEPALIVE_CRON=*/10 * * * *
23
+
24
+ # ── Optional: Telegram bridge ─────────────────────────────────────────
25
+ # TELEGRAM_BOT_TOKEN=123456:ABC
26
+ # TELEGRAM_ALLOWED_USERS=11111111,22222222
27
+ # TELEGRAM_MODE=webhook
28
+
29
+ # ── Optional: swap the landing page to the HuggingMes status page ─────
30
+ # PRIMARY_UI=dashboard
31
+
32
+ # ── Optional: custom OpenAI-compatible endpoint ───────────────────────
33
+ # CUSTOM_BASE_URL=http://localhost:11434/v1
34
+ # CUSTOM_MODEL_CONTEXT_LENGTH=131072
35
+ # CUSTOM_MODEL_MAX_TOKENS=8192
36
+
37
+ # ── Reproducibility: pin the Hermes Agent base image ──────────────────
38
+ # HERMES_AGENT_VERSION=latest
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ .venv/
8
+ .cache/
9
+ *.log
10
+ *.pid
11
+ *.tmp
Dockerfile ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingMes + Hermes WebUI — merged deployment for Hugging Face Spaces
2
+ # Base: NousResearch Hermes Agent (ships Hermes CLI, gateway, dashboard, Python venv)
3
+
4
+ ARG HERMES_AGENT_VERSION=latest
5
+ FROM nousresearch/hermes-agent:${HERMES_AGENT_VERSION}
6
+
7
+ ARG WEBUI_REF=master
8
+
9
+ USER root
10
+
11
+ # System deps (mirrors HuggingMes) + git/nodejs for WebUI checkout + router
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ ca-certificates \
14
+ curl \
15
+ jq \
16
+ git \
17
+ python3 \
18
+ nodejs \
19
+ npm \
20
+ chromium \
21
+ libnss3 \
22
+ libatk1.0-0 \
23
+ libatk-bridge2.0-0 \
24
+ libdrm2 \
25
+ libgbm1 \
26
+ libxcomposite1 \
27
+ libxdamage1 \
28
+ libxrandr2 \
29
+ libxkbcommon0 \
30
+ libx11-6 \
31
+ libxext6 \
32
+ libxfixes3 \
33
+ libasound2 \
34
+ fonts-dejavu-core \
35
+ fonts-liberation \
36
+ fonts-noto-color-emoji \
37
+ && rm -rf /var/lib/apt/lists/* \
38
+ && uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir \
39
+ huggingface_hub hf_transfer pyyaml
40
+
41
+ # Clone nesquena/hermes-webui (install deps into the agent venv so imports resolve)
42
+ RUN git clone --depth 1 --branch ${WEBUI_REF} \
43
+ https://github.com/nesquena/hermes-webui.git /opt/hermes-webui \
44
+ && ( [ -f /opt/hermes-webui/requirements.txt ] \
45
+ && /opt/hermes/.venv/bin/pip install --no-cache-dir -r /opt/hermes-webui/requirements.txt \
46
+ || true ) \
47
+ && chown -R hermes:hermes /opt/hermes-webui
48
+
49
+ # HuggingMes-style integration scripts (vendored from somratpro/HuggingMes)
50
+ COPY --chown=hermes:hermes start.sh /opt/huggingmes/start.sh
51
+ COPY --chown=hermes:hermes health-server.js /opt/huggingmes/health-server.js
52
+ COPY --chown=hermes:hermes hermes-sync.py /opt/huggingmes/hermes-sync.py
53
+ COPY --chown=hermes:hermes cloudflare-proxy-setup.py /opt/huggingmes/cloudflare-proxy-setup.py
54
+ COPY --chown=hermes:hermes cloudflare-keepalive-setup.py /opt/huggingmes/cloudflare-keepalive-setup.py
55
+
56
+ RUN chmod +x \
57
+ /opt/huggingmes/start.sh \
58
+ /opt/huggingmes/hermes-sync.py \
59
+ /opt/huggingmes/cloudflare-proxy-setup.py \
60
+ /opt/huggingmes/cloudflare-keepalive-setup.py
61
+
62
+ # Idempotent kanban migration patch (same workaround HuggingMes ships)
63
+ RUN python3 - <<'PY'
64
+ from pathlib import Path
65
+ import sys
66
+ p = Path("/opt/hermes/hermes_cli/kanban_db.py")
67
+ if not p.exists():
68
+ sys.exit(0)
69
+ src = p.read_text(encoding="utf-8")
70
+ sentinel = "# huggingmes-webui: idempotent-alter"
71
+ if sentinel in src:
72
+ sys.exit(0)
73
+ old = (
74
+ ' conn.execute(\n'
75
+ ' "ALTER TABLE tasks ADD COLUMN consecutive_failures "\n'
76
+ ' "INTEGER NOT NULL DEFAULT 0"\n'
77
+ ' )'
78
+ )
79
+ new = (
80
+ f' try: {sentinel}\n'
81
+ ' conn.execute(\n'
82
+ ' "ALTER TABLE tasks ADD COLUMN consecutive_failures "\n'
83
+ ' "INTEGER NOT NULL DEFAULT 0"\n'
84
+ ' )\n'
85
+ ' except Exception:\n'
86
+ ' pass'
87
+ )
88
+ if old in src:
89
+ p.write_text(src.replace(old, new), encoding="utf-8")
90
+ print("kanban patch: applied")
91
+ PY
92
+
93
+ # Keep hermes CLI on PATH for all shell types (login/interactive/non-interactive)
94
+ RUN echo 'export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH"' \
95
+ > /etc/profile.d/hermes-venv.sh
96
+
97
+ ENV HERMES_HOME=/opt/data \
98
+ HUGGINGMES_APP_DIR=/opt/huggingmes \
99
+ HERMES_WEBUI_REPO=/opt/hermes-webui \
100
+ HERMES_AGENT_VERSION=${HERMES_AGENT_VERSION} \
101
+ PYTHONUNBUFFERED=1 \
102
+ HF_HUB_ENABLE_HF_TRANSFER=1 \
103
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
104
+
105
+ EXPOSE 7861
106
+
107
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=120s \
108
+ CMD curl -fsS http://localhost:7861/health || exit 1
109
+
110
+ USER hermes
111
+ CMD ["/opt/huggingmes/start.sh"]
LICENSE ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 F4bC0d3
4
+
5
+ This project is a deployment recipe that combines three upstream open source
6
+ projects:
7
+ - Hermes Agent by Nous Research (MIT) — https://github.com/NousResearch/hermes-agent
8
+ - Hermes WebUI by Nicolas Esquerre (MIT) — https://github.com/nesquena/hermes-webui
9
+ - HuggingMes by somratpro (MIT) — https://github.com/somratpro/HuggingMes
10
+
11
+ The integration layer (Dockerfile, health-server.js routing additions for
12
+ Hermes WebUI, start.sh launch sequence for the WebUI subprocess, README) is
13
+ released under the MIT License below. The vendored files
14
+ (hermes-sync.py, cloudflare-proxy-setup.py, cloudflare-keepalive-setup.py,
15
+ plus large parts of start.sh and health-server.js) remain under their
16
+ original MIT licenses from the upstream HuggingMes project.
17
+
18
+ ---
19
+
20
+ Permission is hereby granted, free of charge, to any person obtaining a copy
21
+ of this software and associated documentation files (the "Software"), to deal
22
+ in the Software without restriction, including without limitation the rights
23
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24
+ copies of the Software, and to permit persons to whom the Software is
25
+ furnished to do so, subject to the following conditions:
26
+
27
+ The above copyright notice and this permission notice shall be included in all
28
+ copies or substantial portions of the Software.
29
+
30
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: HuggingMes Hermes WebUI
3
+ emoji: ??
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7861
8
+ pinned: true
9
+ license: mit
10
+ ---
11
+ # 🪽 HuggingMes + Hermes WebUI
12
+
13
+ > A merged Hugging Face Space that runs [Hermes Agent](https://github.com/NousResearch/hermes-agent) with [Hermes WebUI](https://github.com/nesquena/hermes-webui) as the primary chat interface. Your own self-hosted AI agent, on free HF Space hardware.
14
+
15
+ **This project is not original work.** It's a deployment recipe that combines three excellent existing projects into a single Space:
16
+
17
+ - **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** by **[Nous Research](https://nousresearch.com)** — the actual AI agent (memory, tools, scheduling, multi-provider LLM support). All the intelligence comes from here.
18
+ - **[Hermes WebUI](https://github.com/nesquena/hermes-webui)** by **[@nesquena](https://github.com/nesquena)** — the three-panel browser UI (sessions, chat, workspace files) with SSE streaming, slash commands, profiles, themes, voice input, file browser, and 100+ other features. Used as the primary chat surface.
19
+ - **[HuggingMes](https://github.com/somratpro/HuggingMes)** by **[@somratpro](https://github.com/somratpro)** — the HF Space wrapper: Docker image, gateway auth, HF Dataset persistence, Cloudflare proxy/keep-alive, Telegram bridge.
20
+
21
+ This repo just glues them together so both UIs share one port, one auth token, and one persistence layer on a single HF Space. Full credit goes to the upstream maintainers.
22
+
23
+ ---
24
+
25
+ ## Try it in 30 seconds
26
+
27
+ I keep a live demo at [huggingface.co/spaces/f4b404/hermes](https://huggingface.co/spaces/f4b404/hermes). Click **Duplicate this Space** at the top right to fork it into your own account.
28
+
29
+ After duplicating, you only need to set 3 secrets to get running. See the next section.
30
+
31
+ ## Setting up your own Space
32
+
33
+ ### 1. Duplicate the Space
34
+
35
+ Go to [huggingface.co/spaces/f4b404/hermes](https://huggingface.co/spaces/f4b404/hermes) → click the **⋮** menu (top-right corner) → **Duplicate this Space**. Name it whatever you want, pick CPU basic free hardware, decide public/private. HF copies all files automatically.
36
+
37
+ If you'd rather start from this repo, create a new Space with **SDK = Docker** at [huggingface.co/new-space](https://huggingface.co/new-space) and upload everything in this repo to its `main` branch.
38
+
39
+ ### 2. Add secrets (Settings → Variables and secrets)
40
+
41
+ **Required** (the Space will not start without these):
42
+
43
+ | Secret | What it is | Example |
44
+ | --- | --- | --- |
45
+ | `GATEWAY_TOKEN` | Your password — gates the WebUI login and the `/v1/*` API. Pick anything strong. | A 32-char random string (`openssl rand -base64 32`) |
46
+ | `LLM_API_KEY` | API key for whichever LLM provider you want to use | `sk-or-v1-...` for OpenRouter, `sk-...` for OpenAI, etc. |
47
+ | `LLM_MODEL` | HuggingMes-style model identifier | `openrouter/anthropic/claude-sonnet-4`, `openai/gpt-4o`, `google/gemini-2.5-flash`, `huggingface/Qwen/Qwen3-235B-A22B-Thinking-2507`, etc. |
48
+
49
+ **Recommended**:
50
+
51
+ | Secret | Why | How to get |
52
+ | --- | --- | --- |
53
+ | `HF_TOKEN` | Persists your sessions, profiles, skills, cron jobs, memory, and workspace files across Space restarts by syncing to a private HF Dataset every 10 min. **Without this, restarts wipe everything.** | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) → New token → **Write** scope |
54
+ | `BACKUP_DATASET_NAME` | If you run multiple instances of this Space (or any other HuggingMes-derived project), set a unique name here so they don't overwrite each other's backups. | e.g. `my-hermes-backup` |
55
+
56
+ **Optional advanced features**:
57
+
58
+ | Secret | What it does |
59
+ | --- | --- |
60
+ | `CLOUDFLARE_WORKERS_TOKEN` | Auto-provisions two Cloudflare Workers: one as an outbound proxy (needed for Telegram, sometimes for blocked LLM providers) and one as a cron keep-alive worker that pings `/health` every 10 min so the Space doesn't sleep on free tier |
61
+ | `CLOUDFLARE_ACCOUNT_ID` | Explicit Cloudflare account ID if you have multiple |
62
+ | `TELEGRAM_BOT_TOKEN` | Enables the Telegram bridge so you can chat with Hermes from Telegram |
63
+ | `TELEGRAM_ALLOWED_USERS` | Comma-separated numeric Telegram user IDs allowed to use the bot |
64
+ | `PRIMARY_UI` | Set to `dashboard` to make `/` show the HuggingMes status page instead of the chat UI. Default is `webui`. |
65
+ | `SYNC_INTERVAL` | Backup cadence in seconds (default 600, range 60–86400) |
66
+ | `HERMES_AGENT_VERSION` | Pin the upstream Hermes Agent base image to a specific tag for reproducibility (default `latest`) |
67
+
68
+ ### 3. Restart and open
69
+
70
+ Settings → **Restart this Space** (or **Factory reboot** if you changed the Dockerfile). Wait ~5–8 minutes for the first build. Watch the **Logs** tab — when you see this, you're ready:
71
+
72
+ ```
73
+ HuggingMes + Hermes WebUI router listening on 0.0.0.0:7861
74
+ ```
75
+
76
+ Open the Space URL (`https://<you>-<name>.hf.space`) in a **new tab** (the embedded HF iframe sometimes blocks the login cookie). You'll see a login page → enter your `GATEWAY_TOKEN` → the chat UI loads.
77
+
78
+ > **Tip**: bookmark the direct `*.hf.space` URL rather than the HF page — much smoother on mobile and avoids iframe quirks.
79
+
80
+ ## What you can do once it's running
81
+
82
+ | URL | What's there |
83
+ | --- | --- |
84
+ | `/` | **Hermes WebUI** — three-panel chat with sessions, file browser, slash commands, profiles, themes, voice input, mermaid diagrams, syntax highlighting, tool cards, and everything else hermes-webui ships |
85
+ | `/hm` | HuggingMes status dashboard — gateway/WebUI/backup/Telegram/keepalive tiles |
86
+ | `/hm/app/` | Hermes's built-in dashboard — manage providers, profiles, cron jobs |
87
+ | `/v1/*` | OpenAI-compatible API — point any OpenAI SDK at it with `GATEWAY_TOKEN` as the API key |
88
+ | `/health` | JSON health probe — no auth, used by HF Spaces and the Cloudflare keepalive worker |
89
+ | `/telegram` | Telegram bot webhook (only if `TELEGRAM_BOT_TOKEN` is set) |
90
+
91
+ ### Using the API from code
92
+
93
+ ```bash
94
+ curl https://<you>-<name>.hf.space/v1/chat/completions \
95
+ -H "Authorization: Bearer $GATEWAY_TOKEN" \
96
+ -H "Content-Type: application/json" \
97
+ -d '{
98
+ "model": "hermes",
99
+ "messages": [{"role": "user", "content": "hello"}]
100
+ }'
101
+ ```
102
+
103
+ ```python
104
+ from openai import OpenAI
105
+ client = OpenAI(
106
+ base_url="https://<you>-<name>.hf.space/v1",
107
+ api_key="<your GATEWAY_TOKEN>",
108
+ )
109
+ resp = client.chat.completions.create(
110
+ model="hermes",
111
+ messages=[{"role": "user", "content": "hello"}],
112
+ )
113
+ ```
114
+
115
+ ### Adding MCP servers
116
+
117
+ Open `/hm/app/config` (the Hermes config editor) and add an `mcp` block. No SSH needed:
118
+
119
+ ```yaml
120
+ mcp:
121
+ servers:
122
+ fetch:
123
+ command: uvx
124
+ args: ["mcp-server-fetch"]
125
+ filesystem:
126
+ command: npx
127
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/opt/data/workspace"]
128
+ ```
129
+
130
+ `uvx` and `npx` are both pre-installed in the image.
131
+
132
+ ## Persistence and how it works
133
+
134
+ When `HF_TOKEN` is set:
135
+
136
+ - **On boot**, the Space downloads the latest snapshot from your private HF Dataset (default name `huggingmes-backup`) and restores it into `/opt/data/`.
137
+ - **Every `SYNC_INTERVAL` seconds** (default 600), it detects state changes and uploads a new snapshot.
138
+ - **On graceful shutdown** (SIGTERM), it does one final sync before exit.
139
+
140
+ What gets backed up: chat sessions, agent memory, workspace files, profiles, skills, cron jobs, Hermes config. The dataset is private to your HF account.
141
+
142
+ ## Architecture
143
+
144
+ Single port (7861) Node.js router fronts four backends:
145
+
146
+ ```
147
+ HF Space port 7861
148
+
149
+
150
+ health-server.js (router + auth + status page)
151
+
152
+ ├─► / → Hermes WebUI (127.0.0.1:8787)
153
+ ├─► /hm → HuggingMes status (in-process)
154
+ ├─► /hm/app/* → Hermes dashboard (127.0.0.1:9119) [SPA-rewritten]
155
+ ├─► /v1/* → Hermes gateway API (127.0.0.1:8642) [bearer auth]
156
+ ├─► /telegram → Telegram webhook (127.0.0.1:8765)
157
+ └─► /health, /status → in-process JSON
158
+ ```
159
+
160
+ `start.sh` boots Hermes Agent's gateway + dashboard + WebUI as subprocesses, then the router on top. `hermes-sync.py` runs the periodic HF Dataset upload loop. Cloudflare and Telegram setup runs once at boot if their respective secrets are set.
161
+
162
+ ## Local testing
163
+
164
+ ```bash
165
+ git clone https://github.com/F4bC0d3/huggingmes-hermes-webui.git
166
+ cd huggingmes-hermes-webui
167
+ cp .env.example .env
168
+ # edit .env with real GATEWAY_TOKEN, LLM_API_KEY, LLM_MODEL
169
+ docker build -t huggingmes-hermes-webui .
170
+ docker run --rm -p 7861:7861 --env-file .env huggingmes-hermes-webui
171
+ # open http://localhost:7861
172
+ ```
173
+
174
+ ## Troubleshooting
175
+
176
+ | Symptom | Cause / Fix |
177
+ | --- | --- |
178
+ | Build fails on `nousresearch/hermes-agent:latest` | Set `HERMES_AGENT_VERSION` to a specific tag and restart |
179
+ | Container Running but `/` returns 502 | Hermes WebUI didn't bind. Check Logs tab for `webui.log` output — usually missing/wrong `LLM_API_KEY` |
180
+ | `/v1/*` returns 401 | Need `Authorization: Bearer <GATEWAY_TOKEN>` header |
181
+ | `/api/status` 404s in logs | Cosmetic — old browser tab polling. Ignored. |
182
+ | Login loops on `/login` | Browser embedded in HF iframe blocks cookies. Open the Space in a new tab. |
183
+ | `Dashboard pages blank or 404 on refresh` | Should be fixed by the SPA rewriter in health-server.js. Hard-refresh and unregister service worker if cached: DevTools → Application → Service Workers → Unregister |
184
+ | Space sleeps after a few hours | Free tier limitation. Add `CLOUDFLARE_WORKERS_TOKEN` to provision a keep-alive cron worker |
185
+ | Telegram bot doesn't respond | HF Spaces blocks `api.telegram.org` egress. Add `CLOUDFLARE_WORKERS_TOKEN` to auto-provision an outbound proxy |
186
+ | Two Spaces overwriting each other's backup | Set different `BACKUP_DATASET_NAME` on each |
187
+
188
+ ## Want a native Android app?
189
+
190
+ I have a companion Android wrapper at **[F4bC0d3/hermes-mobile](https://github.com/F4bC0d3/hermes-mobile)** — same auth flow, sessions drawer, ChatGPT/Claude-style top bar, all hermes-webui features inside. Just point it at your Space URL.
191
+
192
+ ## Credits
193
+
194
+ - **[Nous Research](https://nousresearch.com)** for **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** — the agent runtime, the persistent memory system, the multi-provider LLM routing, the cron and skills systems. None of this exists without their work.
195
+ - **[@nesquena](https://github.com/nesquena)** for **[Hermes WebUI](https://github.com/nesquena/hermes-webui)** — the chat interface you actually see and use. Three-panel layout, SSE streaming, slash commands, profile management, theme system, mobile responsive design — all theirs.
196
+ - **[@somratpro](https://github.com/somratpro)** for **[HuggingMes](https://github.com/somratpro/HuggingMes)** — the HF Space packaging, the HF Dataset backup engine (`hermes-sync.py`), the Cloudflare proxy and keepalive setup, the Telegram integration, and the gateway auth wrapper. This repo is largely a fork of HuggingMes with WebUI added as the primary surface.
197
+
198
+ This repo's only contribution is the integration layer: a Node.js router that fronts both UIs on a single HF Space port, unified auth where one `GATEWAY_TOKEN` gates everything, and minor tweaks to `start.sh` to launch hermes-webui alongside the existing HuggingMes processes. If you find this useful, star the upstream projects, not this one.
199
+
200
+ ## License
201
+
202
+ MIT — same as both upstream projects.
cloudflare-keepalive-setup.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Create or reuse a Cloudflare Worker for Space keep-awake.
5
+
6
+ Vendored verbatim from github.com/somratpro/HuggingMes.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+ import urllib.request
15
+ import urllib.error
16
+ from pathlib import Path
17
+
18
+ API_BASE = "https://api.cloudflare.com/client/v4"
19
+ KEEPALIVE_STATUS_FILE = Path("/tmp/huggingmes-cloudflare-keepalive-status.json")
20
+
21
+
22
+ def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
23
+ req = urllib.request.Request(
24
+ f"{API_BASE}{path}",
25
+ data=body,
26
+ method=method,
27
+ headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
28
+ )
29
+ try:
30
+ with urllib.request.urlopen(req, timeout=30) as response:
31
+ payload = json.loads(response.read().decode("utf-8"))
32
+ except urllib.error.HTTPError as e:
33
+ try:
34
+ error_body = json.loads(e.read().decode("utf-8"))
35
+ errors = error_body.get("errors") or [{"message": "Unknown error"}]
36
+ error_msg = errors[0].get("message", "Unknown error") if errors else "Unknown error"
37
+ except Exception:
38
+ error_msg = f"HTTP {e.code}: {e.reason}"
39
+ raise RuntimeError(f"Cloudflare API {e.code}: {error_msg}")
40
+ if not payload.get("success"):
41
+ errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
42
+ raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
43
+ return payload["result"]
44
+
45
+
46
+ def slugify(value: str) -> str:
47
+ cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
48
+ cleaned = re.sub(r"-{2,}", "-", cleaned)
49
+ return (cleaned or "huggingmes-keepalive")[:63].rstrip("-")
50
+
51
+
52
+ def get_space_host() -> str:
53
+ space_host = os.environ.get("SPACE_HOST", "").strip()
54
+ if space_host:
55
+ return space_host
56
+
57
+ author = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
58
+ repo = os.environ.get("SPACE_REPO_NAME", "").strip()
59
+ if author and repo:
60
+ return f"{author}-{repo}.hf.space".lower()
61
+
62
+ return ""
63
+
64
+
65
+ def derive_keepalive_worker_name() -> str:
66
+ explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
67
+ if explicit:
68
+ return slugify(explicit)
69
+ space_host = get_space_host()
70
+ if space_host:
71
+ return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
72
+ return "huggingmes-keepalive"
73
+
74
+
75
+ def render_keepalive_worker(target_url: str) -> str:
76
+ return f"""addEventListener("fetch", (event) => {{
77
+ event.respondWith(handleRequest(event.request));
78
+ }});
79
+
80
+ addEventListener("scheduled", (event) => {{
81
+ event.waitUntil(ping("cron"));
82
+ }});
83
+
84
+ const TARGET_URL = {json.dumps(target_url)};
85
+
86
+ async function ping(source) {{
87
+ const startedAt = new Date().toISOString();
88
+ try {{
89
+ const response = await fetch(TARGET_URL, {{
90
+ method: "GET",
91
+ headers: {{
92
+ "user-agent": "HuggingMes Cloudflare KeepAlive",
93
+ "cache-control": "no-cache"
94
+ }},
95
+ cf: {{ cacheTtl: 0, cacheEverything: false }}
96
+ }});
97
+ return {{
98
+ ok: response.ok,
99
+ status: response.status,
100
+ source,
101
+ target: TARGET_URL,
102
+ timestamp: startedAt
103
+ }};
104
+ }} catch (error) {{
105
+ return {{
106
+ ok: false,
107
+ status: 0,
108
+ source,
109
+ target: TARGET_URL,
110
+ timestamp: startedAt,
111
+ error: error.message
112
+ }};
113
+ }}
114
+ }}
115
+
116
+ async function handleRequest(request) {{
117
+ const url = new URL(request.url);
118
+ if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
119
+ const result = await ping("manual");
120
+ return new Response(JSON.stringify(result, null, 2), {{
121
+ status: result.ok ? 200 : 502,
122
+ headers: {{ "content-type": "application/json; charset=utf-8" }}
123
+ }});
124
+ }}
125
+ return new Response("Not found", {{ status: 404 }});
126
+ }}
127
+ """
128
+
129
+
130
+ def write_keepalive_status(payload: dict) -> None:
131
+ payload = {
132
+ **payload,
133
+ "timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
134
+ }
135
+ KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
136
+ try:
137
+ KEEPALIVE_STATUS_FILE.chmod(0o600)
138
+ except OSError:
139
+ pass
140
+
141
+
142
+ def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
143
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
144
+ if not account_id:
145
+ accounts = cf_request("GET", "/accounts", api_token)
146
+ if not accounts:
147
+ raise RuntimeError("No Cloudflare account is available for this token.")
148
+ account_id = accounts[0]["id"]
149
+
150
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
151
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
152
+ if not subdomain:
153
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
154
+ return account_id, subdomain
155
+
156
+
157
+ def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
158
+ enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
159
+ if enabled in {"0", "false", "no", "off"}:
160
+ write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
161
+ return
162
+
163
+ space_host = get_space_host()
164
+ if not space_host:
165
+ write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST could not be determined."})
166
+ return
167
+
168
+ cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
169
+ space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
170
+ target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
171
+ worker_name = derive_keepalive_worker_name()
172
+ worker_source = render_keepalive_worker(target_url)
173
+
174
+ cf_request(
175
+ "PUT",
176
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
177
+ api_token,
178
+ body=worker_source.encode("utf-8"),
179
+ content_type="application/javascript",
180
+ )
181
+ cf_request(
182
+ "POST",
183
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
184
+ api_token,
185
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
186
+ )
187
+ cf_request(
188
+ "PUT",
189
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
190
+ api_token,
191
+ body=json.dumps([{"cron": cron}]).encode("utf-8"),
192
+ )
193
+
194
+ worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
195
+ write_keepalive_status(
196
+ {
197
+ "configured": True,
198
+ "status": "configured",
199
+ "workerName": worker_name,
200
+ "workerUrl": worker_url,
201
+ "targetUrl": target_url,
202
+ "cron": cron,
203
+ "message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
204
+ }
205
+ )
206
+
207
+
208
+ def main() -> int:
209
+ api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
210
+
211
+ if not api_token:
212
+ return 0
213
+
214
+ try:
215
+ account_id, subdomain = resolve_account_and_subdomain(api_token)
216
+ setup_keepalive_worker(api_token, account_id, subdomain)
217
+ return 0
218
+ except Exception as exc:
219
+ print(f"Cloudflare keepalive setup failed: {exc}", file=sys.stderr)
220
+ write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
221
+ return 1
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
cloudflare-proxy-setup.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ """Create or reuse Cloudflare Workers for Telegram proxy and Space keep-awake.
5
+
6
+ Vendored verbatim from github.com/somratpro/HuggingMes.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import secrets
13
+ import sys
14
+ import time
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ API_BASE = "https://api.cloudflare.com/client/v4"
19
+ ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
20
+ DEFAULT_ALLOWED = [
21
+ "api.telegram.org",
22
+ "discord.com",
23
+ "discordapp.com",
24
+ "gateway.discord.gg",
25
+ "status.discord.com",
26
+ "slack.com",
27
+ "api.slack.com",
28
+ "web.whatsapp.com",
29
+ "graph.facebook.com",
30
+ "graph.instagram.com",
31
+ "api.openai.com",
32
+ "googleapis.com",
33
+ "google.com",
34
+ "googleusercontent.com",
35
+ "gstatic.com",
36
+ ]
37
+
38
+
39
+ def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
40
+ req = urllib.request.Request(
41
+ f"{API_BASE}{path}",
42
+ data=body,
43
+ method=method,
44
+ headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
45
+ )
46
+ with urllib.request.urlopen(req, timeout=30) as response:
47
+ payload = json.loads(response.read().decode("utf-8"))
48
+ if not payload.get("success"):
49
+ errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
50
+ raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
51
+ return payload["result"]
52
+
53
+
54
+ def slugify(value: str) -> str:
55
+ cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
56
+ cleaned = re.sub(r"-{2,}", "-", cleaned)
57
+ return (cleaned or "huggingmes-proxy")[:63].rstrip("-")
58
+
59
+
60
+ def derive_worker_name() -> str:
61
+ explicit = os.environ.get("CLOUDFLARE_WORKER_NAME", "").strip()
62
+ if explicit:
63
+ return slugify(explicit)
64
+ space_host = os.environ.get("SPACE_HOST", "").strip()
65
+ if space_host:
66
+ return slugify(f"{space_host.replace('.hf.space', '')}-proxy")
67
+ return "huggingmes-proxy"
68
+
69
+
70
+ def render_worker(secret_value: str, allowed_targets: list[str], allow_proxy_all: bool) -> str:
71
+ return f"""addEventListener("fetch", (event) => {{
72
+ event.respondWith(handleRequest(event.request));
73
+ }});
74
+
75
+ const PROXY_SHARED_SECRET = {json.dumps(secret_value)};
76
+ const ALLOW_PROXY_ALL = {"true" if allow_proxy_all else "false"};
77
+ const ALLOWED_TARGETS = {json.dumps(allowed_targets)};
78
+
79
+ function isAllowedHost(hostname) {{
80
+ const normalized = String(hostname || "").trim().toLowerCase();
81
+ if (!normalized) return false;
82
+ if (ALLOW_PROXY_ALL) return true;
83
+ return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${{domain}}`));
84
+ }}
85
+
86
+ async function handleRequest(request) {{
87
+ const url = new URL(request.url);
88
+ const queryTarget = url.searchParams.get("proxy_target");
89
+ const targetHost = request.headers.get("x-target-host") || queryTarget;
90
+
91
+ if (PROXY_SHARED_SECRET) {{
92
+ const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
93
+ const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
94
+ if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {{
95
+ return new Response("Unauthorized: Invalid proxy key", {{ status: 401 }});
96
+ }}
97
+ }}
98
+
99
+ let targetBase = "";
100
+ if (targetHost) {{
101
+ if (!isAllowedHost(targetHost)) {{
102
+ return new Response(`Forbidden: Host ${{targetHost}} is not allowed.`, {{ status: 403 }});
103
+ }}
104
+ targetBase = `https://${{targetHost}}`;
105
+ }} else if (url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot")) {{
106
+ targetBase = "https://api.telegram.org";
107
+ }} else {{
108
+ return new Response("Invalid request: No target host provided.", {{ status: 400 }});
109
+ }}
110
+
111
+ const cleanSearch = new URLSearchParams(url.search);
112
+ cleanSearch.delete("proxy_target");
113
+ cleanSearch.delete("proxy_key");
114
+ const searchStr = cleanSearch.toString();
115
+ const targetUrl = targetBase + url.pathname + (searchStr ? `?${{searchStr}}` : "");
116
+
117
+ const headers = new Headers(request.headers);
118
+ for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {{
119
+ headers.delete(header);
120
+ }}
121
+
122
+ try {{
123
+ return await fetch(new Request(targetUrl, {{
124
+ method: request.method,
125
+ headers,
126
+ body: request.body,
127
+ redirect: "follow",
128
+ }}));
129
+ }} catch (error) {{
130
+ return new Response(`Proxy Error: ${{error.message}}`, {{ status: 502 }});
131
+ }}
132
+ }}
133
+ """
134
+
135
+
136
+ def write_env(proxy_url: str, proxy_secret: str) -> None:
137
+ ENV_FILE.write_text(
138
+ f'export CLOUDFLARE_PROXY_URL="{proxy_url}"\nexport CLOUDFLARE_PROXY_SECRET="{proxy_secret}"\n',
139
+ encoding="utf-8",
140
+ )
141
+ ENV_FILE.chmod(0o600)
142
+
143
+
144
+ def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
145
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
146
+ if not account_id:
147
+ accounts = cf_request("GET", "/accounts", api_token)
148
+ if not accounts:
149
+ raise RuntimeError("No Cloudflare account is available for this token.")
150
+ account_id = accounts[0]["id"]
151
+
152
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
153
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
154
+ if not subdomain:
155
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
156
+ return account_id, subdomain
157
+
158
+
159
+ def main() -> int:
160
+ existing_url = os.environ.get("CLOUDFLARE_PROXY_URL", "").strip()
161
+ existing_secret = os.environ.get("CLOUDFLARE_PROXY_SECRET", "").strip()
162
+ api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
163
+
164
+ if existing_url:
165
+ write_env(existing_url, existing_secret)
166
+
167
+ if not api_token:
168
+ return 0
169
+
170
+ try:
171
+ account_id, subdomain = resolve_account_and_subdomain(api_token)
172
+
173
+ if not existing_url:
174
+ allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
175
+ allow_proxy_all = allowed_raw == "*"
176
+ extra = [] if allow_proxy_all else [v.strip() for v in allowed_raw.split(",") if v.strip()]
177
+ allowed = list(dict.fromkeys(DEFAULT_ALLOWED + extra))
178
+ worker_name = derive_worker_name()
179
+ proxy_secret = existing_secret or secrets.token_urlsafe(24)
180
+
181
+ cf_request(
182
+ "PUT",
183
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
184
+ api_token,
185
+ body=render_worker(proxy_secret, allowed, allow_proxy_all).encode("utf-8"),
186
+ content_type="application/javascript",
187
+ )
188
+ cf_request(
189
+ "POST",
190
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
191
+ api_token,
192
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
193
+ )
194
+ write_env(f"https://{worker_name}.{subdomain}.workers.dev", proxy_secret)
195
+
196
+ return 0
197
+ except Exception as exc:
198
+ print(f"Cloudflare proxy setup failed: {exc}", file=sys.stderr)
199
+ return 1
200
+
201
+
202
+ if __name__ == "__main__":
203
+ raise SystemExit(main())
health-server.js ADDED
@@ -0,0 +1,928 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
+ /**
4
+ * HuggingMes + Hermes WebUI — single-port router on HF Space port 7861.
5
+ *
6
+ * Routes:
7
+ * /login -> HuggingMes login (password = GATEWAY_TOKEN)
8
+ * /health /status -> JSON health (unauthenticated — for HF probes + keepalive)
9
+ * /hm /hm/* -> HuggingMes status page + app (auth-gated)
10
+ * /dashboard -> redirect to /hm
11
+ * /v1 /v1/* -> Hermes gateway (bearer auth; HTML => login redirect)
12
+ * /telegram /telegram/*-> Telegram webhook (unauthenticated; Telegram needs to reach it)
13
+ * everything else -> Hermes WebUI (nesquena/hermes-webui) as the primary UI
14
+ * WebUI handles its own login at /login-... no, wait: WebUI
15
+ * also exposes /login. We keep HuggingMes' login at /login
16
+ * so the shared GATEWAY_TOKEN gates both.
17
+ *
18
+ * Based on github.com/somratpro/HuggingMes with added WebUI routing as the
19
+ * primary UI.
20
+ */
21
+
22
+ const http = require("http");
23
+ const fs = require("fs");
24
+ const net = require("net");
25
+ const crypto = require("crypto");
26
+
27
+ const PORT = Number(process.env.PORT || 7861);
28
+ const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
29
+ const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
30
+ const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
31
+ const WEBUI_PORT = Number(process.env.HERMES_WEBUI_PORT || 8787);
32
+ const GATEWAY_HOST = "127.0.0.1";
33
+ const startTime = Date.now();
34
+ const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
35
+ const HM_PREFIX = "/hm";
36
+ const LOGIN_PATH = "/hm/login";
37
+ const SESSION_COOKIE = "huggingmes_session";
38
+ const PRIMARY_UI = (process.env.PRIMARY_UI || "webui").toLowerCase();
39
+
40
+ const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
41
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
42
+ "/tmp/huggingmes-cloudflare-keepalive-status.json";
43
+
44
+ /* ── Port probing + auth ──────────────────────────────────────────── */
45
+
46
+ function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
47
+ return new Promise((resolve) => {
48
+ const socket = net.createConnection({ port, host });
49
+ const done = (ok) => {
50
+ socket.removeAllListeners();
51
+ socket.destroy();
52
+ resolve(ok);
53
+ };
54
+ socket.setTimeout(timeoutMs);
55
+ socket.once("connect", () => done(true));
56
+ socket.once("timeout", () => done(false));
57
+ socket.once("error", () => done(false));
58
+ });
59
+ }
60
+
61
+ function readJson(path, fallback = null) {
62
+ try {
63
+ if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
64
+ } catch {}
65
+ return fallback;
66
+ }
67
+
68
+ function timingSafeEqualString(left, right) {
69
+ if (!left || !right) return false;
70
+ const a = Buffer.from(left);
71
+ const b = Buffer.from(right);
72
+ if (a.length !== b.length) return false;
73
+ return crypto.timingSafeEqual(a, b);
74
+ }
75
+
76
+ function expectedSessionValue() {
77
+ if (!API_SERVER_KEY) return "";
78
+ return crypto
79
+ .createHmac("sha256", API_SERVER_KEY)
80
+ .update("huggingmes-session-v1")
81
+ .digest("hex");
82
+ }
83
+
84
+ function parseCookies(req) {
85
+ const header = req.headers.cookie || "";
86
+ const cookies = {};
87
+ for (const item of header.split(";")) {
88
+ const sep = item.indexOf("=");
89
+ if (sep < 0) continue;
90
+ const name = item.slice(0, sep).trim();
91
+ const value = item.slice(sep + 1).trim();
92
+ if (!name) continue;
93
+ try {
94
+ cookies[name] = decodeURIComponent(value);
95
+ } catch {
96
+ cookies[name] = value;
97
+ }
98
+ }
99
+ return cookies;
100
+ }
101
+
102
+ function isHttpsRequest(req) {
103
+ return req.headers["x-forwarded-proto"] === "https";
104
+ }
105
+
106
+ function buildSessionCookie(req) {
107
+ const secure = isHttpsRequest(req) ? "; Secure" : "";
108
+ return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
109
+ }
110
+
111
+ function getBearerToken(req) {
112
+ const value = req.headers.authorization || "";
113
+ const match = /^Bearer\s+(.+)$/i.exec(value);
114
+ return match ? match[1] : "";
115
+ }
116
+
117
+ function isAuthorized(req) {
118
+ if (!API_SERVER_KEY) return true;
119
+ return (
120
+ timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
121
+ timingSafeEqualString(
122
+ parseCookies(req)[SESSION_COOKIE],
123
+ expectedSessionValue(),
124
+ )
125
+ );
126
+ }
127
+
128
+ function sanitizeNext(value, fallback = "/") {
129
+ if (!value || typeof value !== "string") return fallback;
130
+ if (!value.startsWith("/") || value.startsWith("//")) return fallback;
131
+ return value;
132
+ }
133
+
134
+ function loginUrl(nextPath) {
135
+ return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
136
+ }
137
+
138
+ function wantsHtml(req) {
139
+ const accept = String(req.headers.accept || "");
140
+ return accept.includes("text/html");
141
+ }
142
+
143
+ function escapeHtml(value) {
144
+ return String(value)
145
+ .replace(/&/g, "&amp;")
146
+ .replace(/</g, "&lt;")
147
+ .replace(/>/g, "&gt;")
148
+ .replace(/"/g, "&quot;");
149
+ }
150
+
151
+ function readRequestBody(req, limit = 64 * 1024) {
152
+ return new Promise((resolve, reject) => {
153
+ let body = "";
154
+ req.on("data", (chunk) => {
155
+ body += chunk;
156
+ if (body.length > limit) {
157
+ reject(new Error("Request body is too large."));
158
+ req.destroy();
159
+ }
160
+ });
161
+ req.on("end", () => resolve(body));
162
+ req.on("error", reject);
163
+ });
164
+ }
165
+
166
+ /* ── Login page ───────────────────────────────────────────────────── */
167
+
168
+ function renderLoginPage(nextPath, errorMessage = "") {
169
+ const safeNext = sanitizeNext(nextPath, "/");
170
+ const errorHtml = errorMessage
171
+ ? `<div class="error">${escapeHtml(errorMessage)}</div>`
172
+ : "";
173
+ return `<!doctype html>
174
+ <html lang="en">
175
+ <head>
176
+ <meta charset="utf-8" />
177
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
178
+ <title>HuggingMes + Hermes WebUI — Login</title>
179
+ <style>
180
+ :root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --bad:#ef4444; --accent:#38bdf8; }
181
+ * { box-sizing:border-box; }
182
+ body { margin:0; min-height:100vh; display:grid; place-items:center; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); padding:20px; }
183
+ main { width:min(440px, 100%); border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:28px; }
184
+ h1 { margin:0 0 8px; font-size:1.55rem; }
185
+ p { margin:0 0 22px; color:var(--muted); line-height:1.5; }
186
+ label { display:block; color:var(--muted); font-size:.82rem; margin-bottom:8px; }
187
+ input { width:100%; min-height:46px; border:1px solid var(--line); border-radius:7px; background:#0b0f18; color:var(--text); padding:0 12px; font:inherit; }
188
+ button { width:100%; min-height:44px; margin-top:16px; border:0; border-radius:7px; color:#07111f; background:var(--accent); font:inherit; font-weight:750; cursor:pointer; }
189
+ .error { border:1px solid rgba(239,68,68,.4); background:rgba(239,68,68,.1); color:#fecaca; border-radius:7px; padding:10px 12px; margin-bottom:16px; }
190
+ </style>
191
+ </head>
192
+ <body>
193
+ <main>
194
+ <h1>HuggingMes Admin</h1>
195
+ <p>Enter the <code>GATEWAY_TOKEN</code> from your Space secrets to access the status dashboard.<br>For the Hermes chat UI, go to <a href="/" style="color:var(--accent)">/</a>.</p>
196
+ ${errorHtml}
197
+ <form method="post" action="${LOGIN_PATH}">
198
+ <input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
199
+ <label for="token">GATEWAY_TOKEN</label>
200
+ <input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
201
+ <button type="submit">Continue</button>
202
+ </form>
203
+ </main>
204
+ </body>
205
+ </html>`;
206
+ }
207
+
208
+ async function handleLogin(req, res, parsed) {
209
+ const nextPath = sanitizeNext(parsed.searchParams.get("next") || "/", "/");
210
+
211
+ if (!API_SERVER_KEY) {
212
+ redirect(res, nextPath);
213
+ return;
214
+ }
215
+
216
+ if (req.method === "GET") {
217
+ res.writeHead(200, {
218
+ "content-type": "text/html; charset=utf-8",
219
+ "cache-control": "no-store",
220
+ });
221
+ res.end(renderLoginPage(nextPath));
222
+ return;
223
+ }
224
+
225
+ if (req.method !== "POST") {
226
+ res.writeHead(405, { allow: "GET, POST" });
227
+ res.end("Method not allowed");
228
+ return;
229
+ }
230
+
231
+ try {
232
+ const body = await readRequestBody(req);
233
+ const params = new URLSearchParams(body);
234
+ const submittedToken = params.get("token") || "";
235
+ const submittedNext = sanitizeNext(params.get("next") || nextPath, "/");
236
+
237
+ if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
238
+ res.writeHead(401, {
239
+ "content-type": "text/html; charset=utf-8",
240
+ "cache-control": "no-store",
241
+ });
242
+ res.end(
243
+ renderLoginPage(
244
+ submittedNext,
245
+ "That token did not match GATEWAY_TOKEN.",
246
+ ),
247
+ );
248
+ return;
249
+ }
250
+
251
+ res.writeHead(302, {
252
+ location: submittedNext,
253
+ "set-cookie": buildSessionCookie(req),
254
+ "cache-control": "no-store",
255
+ });
256
+ res.end();
257
+ } catch (error) {
258
+ res.writeHead(400, {
259
+ "content-type": "text/plain; charset=utf-8",
260
+ "cache-control": "no-store",
261
+ });
262
+ res.end(error.message || "Invalid login request.");
263
+ }
264
+ }
265
+
266
+ function requireAuth(req, res) {
267
+ if (isAuthorized(req)) return true;
268
+ const parsed = new URL(req.url, "http://localhost");
269
+ redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
270
+ return false;
271
+ }
272
+
273
+ /* ── Upstream proxy ────────────────────────────────────────────────── */
274
+
275
+ function proxyRequest(
276
+ req,
277
+ res,
278
+ targetPort,
279
+ rewritePath = (path) => path,
280
+ headerOverrides = {},
281
+ ) {
282
+ const parsed = new URL(req.url, "http://localhost");
283
+ const targetPath = rewritePath(parsed.pathname) + parsed.search;
284
+ const headers = {
285
+ ...req.headers,
286
+ ...headerOverrides,
287
+ host: `${GATEWAY_HOST}:${targetPort}`,
288
+ "x-forwarded-host": req.headers.host || "",
289
+ "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
290
+ };
291
+
292
+ const proxy = http.request(
293
+ {
294
+ hostname: GATEWAY_HOST,
295
+ port: targetPort,
296
+ method: req.method,
297
+ path: targetPath,
298
+ headers,
299
+ },
300
+ (upstream) => {
301
+ res.writeHead(upstream.statusCode || 502, upstream.headers);
302
+ upstream.pipe(res);
303
+ },
304
+ );
305
+
306
+ proxy.on("error", (error) => {
307
+ res.writeHead(502, { "content-type": "application/json" });
308
+ res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
309
+ });
310
+
311
+ req.pipe(proxy);
312
+ }
313
+
314
+ function redirect(res, location, statusCode = 302) {
315
+ res.writeHead(statusCode, { location });
316
+ res.end();
317
+ }
318
+
319
+ /* ── Dashboard SPA proxy with HTML rewriting ──────────────────────────
320
+ *
321
+ * The Hermes dashboard is a Vite React app built for root-path deployment.
322
+ * Its HTML hardcodes window.__HERMES_BASE_PATH__="" and absolute src/href
323
+ * paths like /assets/index-XXX.js. Under /hm/app, React's router wouldn't
324
+ * know its basename and client-side routes (/config, /sessions, etc.) 404
325
+ * on refresh.
326
+ *
327
+ * This proxy:
328
+ * - serves the dashboard's index.html for any non-asset /hm/app/* path
329
+ * (SPA fallback, so /config, /profiles etc. work on direct load)
330
+ * - rewrites the returned HTML so React router uses /hm/app as its
331
+ * basename and absolute asset paths get prefixed with /hm/app
332
+ */
333
+ function proxyDashboard(req, res) {
334
+ const parsed = new URL(req.url, "http://localhost");
335
+ const inner = parsed.pathname.replace(`${HM_PREFIX}/app`, "") || "/";
336
+
337
+ const isAssetLike =
338
+ inner.startsWith("/assets/") ||
339
+ inner.startsWith("/api/") ||
340
+ inner.startsWith("/dashboard-plugins/") ||
341
+ inner.startsWith("/ds-assets/") ||
342
+ /\.[a-z0-9]{1,6}$/i.test(inner);
343
+
344
+ // SPA routes → serve index.html; everything else → forward as-is.
345
+ const targetPath =
346
+ (isAssetLike || inner === "/" ? inner : "/") + parsed.search;
347
+
348
+ const headers = {
349
+ ...req.headers,
350
+ host: `${GATEWAY_HOST}:${DASHBOARD_PORT}`,
351
+ "x-forwarded-host": req.headers.host || "",
352
+ "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
353
+ // Disable upstream compression so we can rewrite text responses.
354
+ "accept-encoding": "identity",
355
+ };
356
+
357
+ const upstream = http.request(
358
+ {
359
+ hostname: GATEWAY_HOST,
360
+ port: DASHBOARD_PORT,
361
+ method: req.method,
362
+ path: targetPath,
363
+ headers,
364
+ },
365
+ (upRes) => {
366
+ const contentType = String(upRes.headers["content-type"] || "");
367
+ const shouldRewrite =
368
+ contentType.includes("text/html") ||
369
+ contentType.includes("application/xhtml");
370
+
371
+ if (!shouldRewrite) {
372
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
373
+ upRes.pipe(res);
374
+ return;
375
+ }
376
+
377
+ const chunks = [];
378
+ upRes.on("data", (chunk) => chunks.push(chunk));
379
+ upRes.on("end", () => {
380
+ let body = Buffer.concat(chunks).toString("utf8");
381
+
382
+ // Tell the React router its basename.
383
+ body = body.replace(
384
+ /window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"/g,
385
+ `window.__HERMES_BASE_PATH__="${HM_PREFIX}/app"`,
386
+ );
387
+
388
+ // Prefix absolute asset URLs so they stay under /hm/app.
389
+ const prefix = `${HM_PREFIX}/app`;
390
+ body = body.replace(
391
+ /\b(src|href)="\/(?!\/|http)([^"]*)"/g,
392
+ (match, attr, rest) => {
393
+ if (
394
+ ("/" + rest).startsWith(prefix + "/") ||
395
+ "/" + rest === prefix
396
+ ) {
397
+ return match;
398
+ }
399
+ return `${attr}="${prefix}/${rest}"`;
400
+ },
401
+ );
402
+
403
+ const buf = Buffer.from(body, "utf8");
404
+ const outHeaders = { ...upRes.headers };
405
+ delete outHeaders["content-length"];
406
+ delete outHeaders["transfer-encoding"];
407
+ delete outHeaders["content-encoding"];
408
+ outHeaders["content-length"] = String(buf.length);
409
+
410
+ res.writeHead(upRes.statusCode || 502, outHeaders);
411
+ res.end(buf);
412
+ });
413
+ upRes.on("error", () => {
414
+ try {
415
+ res.writeHead(502);
416
+ res.end();
417
+ } catch {}
418
+ });
419
+ },
420
+ );
421
+
422
+ upstream.on("error", (error) => {
423
+ res.writeHead(502, { "content-type": "application/json" });
424
+ res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
425
+ });
426
+
427
+ req.pipe(upstream);
428
+ }
429
+
430
+ /* ── Status JSON + HuggingMes status page ─────────────────────────── */
431
+
432
+ function formatUptime(ms) {
433
+ const total = Math.floor(ms / 1000);
434
+ const days = Math.floor(total / 86400);
435
+ const hours = Math.floor((total % 86400) / 3600);
436
+ const minutes = Math.floor((total % 3600) / 60);
437
+ if (days) return `${days}d ${hours}h ${minutes}m`;
438
+ if (hours) return `${hours}h ${minutes}m`;
439
+ return `${minutes}m`;
440
+ }
441
+
442
+ async function statusPayload() {
443
+ const gateway = await canConnect(GATEWAY_PORT);
444
+ const dashboard = await canConnect(DASHBOARD_PORT);
445
+ const webui = await canConnect(WEBUI_PORT);
446
+ const telegramWebhook =
447
+ !!process.env.TELEGRAM_WEBHOOK_URL &&
448
+ (await canConnect(TELEGRAM_WEBHOOK_PORT));
449
+ const sync = readJson(
450
+ SYNC_STATUS_FILE,
451
+ process.env.HF_TOKEN
452
+ ? { status: "configured", message: "Backup enabled; waiting for first sync." }
453
+ : { status: "disabled", message: "HF_TOKEN is not configured." },
454
+ );
455
+
456
+ return {
457
+ ok: gateway && webui,
458
+ uptime: formatUptime(Date.now() - startTime),
459
+ startedAt: new Date(startTime).toISOString(),
460
+ gateway,
461
+ dashboard,
462
+ webui,
463
+ authConfigured: !!API_SERVER_KEY,
464
+ primaryUi: PRIMARY_UI,
465
+ ports: {
466
+ public: PORT,
467
+ gateway: GATEWAY_PORT,
468
+ dashboard: DASHBOARD_PORT,
469
+ webui: WEBUI_PORT,
470
+ telegramWebhook: TELEGRAM_WEBHOOK_PORT,
471
+ },
472
+ telegram: {
473
+ configured: !!process.env.TELEGRAM_BOT_TOKEN,
474
+ webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
475
+ webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
476
+ webhookListening: telegramWebhook,
477
+ proxy: process.env.CLOUDFLARE_PROXY_URL || "",
478
+ },
479
+ model:
480
+ process.env.MODEL_FOR_CONFIG ||
481
+ process.env.HERMES_MODEL ||
482
+ process.env.LLM_MODEL ||
483
+ "",
484
+ provider:
485
+ process.env.PROVIDER_FOR_CONFIG ||
486
+ process.env.HERMES_INFERENCE_PROVIDER ||
487
+ "auto",
488
+ backup: sync,
489
+ keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null),
490
+ };
491
+ }
492
+
493
+ function toneBadge(label, tone = "neutral") {
494
+ return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
495
+ }
496
+
497
+ function valueOrUnset(value, fallback = "Not set") {
498
+ return value
499
+ ? escapeHtml(value)
500
+ : `<span class="muted">${escapeHtml(fallback)}</span>`;
501
+ }
502
+
503
+ function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
504
+ return `<article class="tile ${tone}">
505
+ <div class="tile-head">
506
+ <span class="tile-title">${escapeHtml(title)}</span>
507
+ <span class="tile-dot"></span>
508
+ </div>
509
+ <div class="tile-value">${value}</div>
510
+ ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
511
+ ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
512
+ </article>`;
513
+ }
514
+
515
+ function renderStatusPage(data) {
516
+ const syncStatus = String(data.backup?.status || "unknown");
517
+ const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
518
+ ? "ok"
519
+ : syncStatus === "disabled"
520
+ ? "warn"
521
+ : "neutral";
522
+ const telegramTone = data.telegram.configured
523
+ ? data.telegram.webhookListening || !data.telegram.webhook
524
+ ? "ok"
525
+ : "warn"
526
+ : "warn";
527
+ const keepaliveConfigured = data.keepalive?.configured === true;
528
+ const keepaliveStatus = String(
529
+ data.keepalive?.status ||
530
+ (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
531
+ );
532
+ const keepAliveTone = keepaliveConfigured
533
+ ? "ok"
534
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
535
+ ? "warn"
536
+ : "neutral";
537
+ const telegramDetail = data.telegram.configured
538
+ ? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}`
539
+ : "Not configured";
540
+ const backupDetail = data.backup?.message
541
+ ? escapeHtml(data.backup.message)
542
+ : "No status yet";
543
+ const keepAliveDetail = keepaliveConfigured
544
+ ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
545
+ : keepaliveStatus === "error" && data.keepalive?.message
546
+ ? escapeHtml(data.keepalive.message)
547
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
548
+ ? "Worker pending or failed"
549
+ : "Not configured";
550
+
551
+ const tiles = [
552
+ renderTile({
553
+ title: "WebUI",
554
+ value: toneBadge(data.webui ? "Online" : "Offline", data.webui ? "ok" : "off"),
555
+ detail: data.webui ? `Port ${data.ports.webui}` : "Unreachable",
556
+ tone: data.webui ? "ok" : "off",
557
+ }),
558
+ renderTile({
559
+ title: "Gateway",
560
+ value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
561
+ detail: data.gateway ? `API on port ${data.ports.gateway}` : "Unreachable",
562
+ tone: data.gateway ? "ok" : "off",
563
+ meta: data.authConfigured ? "Protected" : "Unprotected",
564
+ }),
565
+ renderTile({
566
+ title: "Model",
567
+ value: `<code>${valueOrUnset(data.model)}</code>`,
568
+ detail: `Provider: ${valueOrUnset(data.provider || "auto")}`,
569
+ tone: data.model ? "ok" : "warn",
570
+ }),
571
+ renderTile({
572
+ title: "Runtime",
573
+ value: escapeHtml(data.uptime),
574
+ detail: `Port ${data.ports.public}`,
575
+ tone: "neutral",
576
+ }),
577
+ renderTile({
578
+ title: "Telegram",
579
+ value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone),
580
+ detail: telegramDetail,
581
+ tone: telegramTone,
582
+ }),
583
+ renderTile({
584
+ title: "Backup",
585
+ value: toneBadge(syncStatus.toUpperCase(), syncTone),
586
+ detail: backupDetail,
587
+ tone: syncTone,
588
+ meta: data.backup?.timestamp
589
+ ? `<span class="local-time" data-iso="${data.backup.timestamp}"></span>`
590
+ : "",
591
+ }),
592
+ renderTile({
593
+ title: "Keep Awake",
594
+ value: toneBadge(
595
+ keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
596
+ keepAliveTone,
597
+ ),
598
+ detail: keepAliveDetail,
599
+ tone: keepAliveTone,
600
+ }),
601
+ ].join("");
602
+
603
+ return `<!doctype html>
604
+ <html lang="en">
605
+ <head>
606
+ <meta charset="utf-8" />
607
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
608
+ <title>HuggingMes + Hermes WebUI</title>
609
+ <style>
610
+ :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; }
611
+ * { box-sizing:border-box; }
612
+ body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
613
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
614
+ header { text-align:center; margin-bottom:22px; }
615
+ h1 { margin:0; font-size:1.65rem; }
616
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
617
+ .row { display:flex; gap:10px; margin:24px 0 20px; flex-wrap:wrap; }
618
+ .hero-action { flex:1 1 200px; min-height:46px; display:flex; align-items:center; justify-content:center; border-radius:8px; background:#ffffff; color:#000000; text-decoration:none; font-weight:850; font-size:.98rem; }
619
+ .hero-action.secondary { background:#232234; color:var(--text); border:1px solid var(--line); }
620
+ .hero-action:hover { opacity:.9; }
621
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
622
+ .tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
623
+ .tile.ok { border-color:rgba(34,197,94,.22); }
624
+ .tile.warn { border-color:rgba(245,197,66,.24); }
625
+ .tile.off { border-color:rgba(251,113,133,.28); }
626
+ .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
627
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
628
+ .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
629
+ .tile.ok .tile-dot { background:var(--good); }
630
+ .tile.warn .tile-dot { background:var(--warn); }
631
+ .tile.off .tile-dot { background:var(--bad); }
632
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
633
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
634
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
635
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
636
+ .badge { display:inline-flex; align-items:center; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
637
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
638
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
639
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
640
+ .badge.neutral { color:var(--soft); }
641
+ .muted { color:var(--muted); }
642
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
643
+ @media (max-width: 700px) { .overview { grid-template-columns:1fr; } }
644
+ </style>
645
+ </head>
646
+ <body>
647
+ <main>
648
+ <header>
649
+ <h1>HuggingMes + Hermes WebUI</h1>
650
+ <div class="subtitle">Self-hosted Hermes Agent on HF Spaces</div>
651
+ </header>
652
+ <div class="row">
653
+ <a class="hero-action" href="/" target="_blank" rel="noopener">Open Hermes WebUI -&gt;</a>
654
+ <a class="hero-action secondary" href="${HM_PREFIX}/app/" target="_blank" rel="noopener">Open Hermes Dashboard</a>
655
+ </div>
656
+ <section class="overview">
657
+ ${tiles}
658
+ </section>
659
+ <footer>Built on <a href="https://github.com/somratpro/HuggingMes" style="color:var(--accent)">HuggingMes</a> + <a href="https://github.com/nesquena/hermes-webui" style="color:var(--accent)">Hermes WebUI</a></footer>
660
+ </main>
661
+ <script>
662
+ document.querySelectorAll('.local-time').forEach(el => {
663
+ const date = new Date(el.getAttribute('data-iso'));
664
+ if (!isNaN(date)) el.textContent = 'At ' + date.toLocaleTimeString();
665
+ });
666
+ </script>
667
+ </body>
668
+ </html>`;
669
+ }
670
+
671
+ /* ── Server ───────────────────────────────────────────────────────── */
672
+
673
+ const server = http.createServer(async (req, res) => {
674
+ const parsed = new URL(req.url, "http://localhost");
675
+ const path = parsed.pathname;
676
+
677
+ // 1. /hm/login — HuggingMes admin login (cookie-based, gates /hm/*).
678
+ // hermes-webui handles its own /login at the catch-all below.
679
+ if (path === LOGIN_PATH) {
680
+ await handleLogin(req, res, parsed);
681
+ return;
682
+ }
683
+
684
+ // 2. /health — unauthenticated; HF Spaces probes + Cloudflare keepalive.
685
+ if (path === "/health") {
686
+ const data = await statusPayload();
687
+ res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
688
+ res.end(
689
+ JSON.stringify({
690
+ ok: data.ok,
691
+ gateway: data.gateway,
692
+ webui: data.webui,
693
+ uptime: data.uptime,
694
+ }),
695
+ );
696
+ return;
697
+ }
698
+
699
+ // 3. /status — unauthenticated JSON status dump.
700
+ if (path === "/status" || path === "/api/status") {
701
+ const data = await statusPayload();
702
+ res.writeHead(200, { "content-type": "application/json" });
703
+ res.end(JSON.stringify(data, null, 2));
704
+ return;
705
+ }
706
+
707
+ // 4. /telegram — webhook endpoint; no auth (Telegram can't do our cookie).
708
+ if (path === "/telegram" || path.startsWith("/telegram/")) {
709
+ proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
710
+ return;
711
+ }
712
+
713
+ // 5. /v1/* — Hermes gateway OpenAI-compatible API.
714
+ if (path === "/v1" || path.startsWith("/v1/")) {
715
+ if (!isAuthorized(req)) {
716
+ if (wantsHtml(req)) {
717
+ redirect(res, loginUrl(`${path}${parsed.search}`));
718
+ return;
719
+ }
720
+ res.writeHead(401, {
721
+ "content-type": "application/json",
722
+ "cache-control": "no-store",
723
+ });
724
+ res.end(
725
+ JSON.stringify({
726
+ error: "unauthorized",
727
+ message: "Use Authorization: Bearer <GATEWAY_TOKEN>.",
728
+ }),
729
+ );
730
+ return;
731
+ }
732
+ const upstreamHeaders =
733
+ getBearerToken(req) || !API_SERVER_KEY
734
+ ? {}
735
+ : { authorization: `Bearer ${API_SERVER_KEY}` };
736
+ proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders);
737
+ return;
738
+ }
739
+
740
+ // 6. /hm — HuggingMes status page.
741
+ if (path === HM_PREFIX || path === `${HM_PREFIX}/`) {
742
+ if (!requireAuth(req, res)) return;
743
+ const data = await statusPayload();
744
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
745
+ res.end(renderStatusPage(data));
746
+ return;
747
+ }
748
+
749
+ // /hm/app/* -> Hermes dashboard (SPA with HTML rewriting for base path)
750
+ if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
751
+ if (!requireAuth(req, res)) return;
752
+ proxyDashboard(req, res);
753
+ return;
754
+ }
755
+
756
+ // /hm/status -> JSON
757
+ if (path === `${HM_PREFIX}/status`) {
758
+ if (!requireAuth(req, res)) return;
759
+ const data = await statusPayload();
760
+ res.writeHead(200, { "content-type": "application/json" });
761
+ res.end(JSON.stringify(data, null, 2));
762
+ return;
763
+ }
764
+
765
+ // Legacy /dashboard -> /hm
766
+ if (path === "/dashboard" || path === "/dashboard/") {
767
+ redirect(res, `${HM_PREFIX}${parsed.search}`);
768
+ return;
769
+ }
770
+
771
+ // Root-path dashboard routes (config, env, providers, etc.) that users
772
+ // type or bookmark without the /hm/app prefix. Redirect them there.
773
+ const dashboardRootRoutes = new Set([
774
+ "/config",
775
+ "/env",
776
+ "/models",
777
+ "/providers",
778
+ "/profiles",
779
+ "/sessions",
780
+ "/skills",
781
+ "/cron",
782
+ "/analytics",
783
+ "/logs",
784
+ "/plugins",
785
+ "/chat",
786
+ "/docs",
787
+ ]);
788
+ if (dashboardRootRoutes.has(path) || [...dashboardRootRoutes].some((r) => path.startsWith(r + "/"))) {
789
+ redirect(res, `${HM_PREFIX}/app${path}${parsed.search}`);
790
+ return;
791
+ }
792
+
793
+ // 6b. Root-path requests whose Referer came from /hm/app/* must go to
794
+ // the dashboard, not WebUI. This covers:
795
+ // - Absolute assets (/assets/*, /ds-assets/*, /dashboard-plugins/*)
796
+ // - API calls (/api/*) when dashboard code uses absolute paths
797
+ // - Favicon (/favicon.ico)
798
+ // - WebSocket upgrades from dashboard pages
799
+ // - File downloads (any extensioned path referenced by dashboard)
800
+ // Both the Hermes dashboard AND hermes-webui use /api/* internally,
801
+ // so the Referer is the only reliable way to disambiguate.
802
+ const refererPath = (() => {
803
+ const ref = String(req.headers.referer || "");
804
+ if (!ref) return "";
805
+ try {
806
+ return new URL(ref).pathname;
807
+ } catch {
808
+ return "";
809
+ }
810
+ })();
811
+ const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`);
812
+
813
+ if (refererIsDashboard) {
814
+ // Anything with a Referer from the dashboard goes to the dashboard,
815
+ // *except* requests that explicitly start with /webui (escape hatch).
816
+ if (!path.startsWith("/webui")) {
817
+ if (!requireAuth(req, res)) return;
818
+ // Assets must NOT get the SPA fallback; pass them through as-is.
819
+ const parsed2 = new URL(req.url, "http://localhost");
820
+ const looksLikeAsset =
821
+ path.startsWith("/assets/") ||
822
+ path.startsWith("/ds-assets/") ||
823
+ path.startsWith("/dashboard-plugins/") ||
824
+ path.startsWith("/api/") ||
825
+ path === "/favicon.ico" ||
826
+ /\.[a-z0-9]{1,6}$/i.test(path);
827
+ if (looksLikeAsset) {
828
+ proxyRequest(req, res, DASHBOARD_PORT);
829
+ } else {
830
+ // Unlikely: a dashboard-referrer request for a non-asset, non-/hm
831
+ // path. Treat as a dashboard sub-route.
832
+ proxyDashboard(req, res);
833
+ }
834
+ return;
835
+ }
836
+ }
837
+
838
+ // 6c. /api/* routes — these are WebUI API calls when Referer isn't the
839
+ // dashboard. Fall through to the catch-all below.
840
+
841
+ // 7. Anything else -> Hermes WebUI (primary UI) OR HuggingMes status page.
842
+ // WebUI handles its own auth internally via HERMES_WEBUI_PASSWORD.
843
+ if (PRIMARY_UI === "dashboard" && path === "/") {
844
+ if (!requireAuth(req, res)) return;
845
+ const data = await statusPayload();
846
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
847
+ res.end(renderStatusPage(data));
848
+ return;
849
+ }
850
+
851
+ // Catch-all -> WebUI. Don't gate at the router level: WebUI has its own
852
+ // password login. GATEWAY_TOKEN *is* the WebUI password (start.sh sets
853
+ // HERMES_WEBUI_PASSWORD=$GATEWAY_TOKEN).
854
+ proxyRequest(req, res, WEBUI_PORT);
855
+ });
856
+
857
+ server.listen(PORT, "0.0.0.0", () => {
858
+ console.log(`HuggingMes + Hermes WebUI router listening on 0.0.0.0:${PORT}`);
859
+ });
860
+
861
+ /* ── WebSocket upgrade handling ─────────────────────────────────────
862
+ *
863
+ * Both the Hermes dashboard and hermes-webui can open WebSocket
864
+ * connections for live updates. Route the upgrade to the correct
865
+ * upstream based on path prefix + referer, same as HTTP requests.
866
+ */
867
+ server.on("upgrade", (req, clientSocket, head) => {
868
+ const parsed = new URL(req.url, "http://localhost");
869
+ const path = parsed.pathname;
870
+
871
+ let targetPort = WEBUI_PORT;
872
+ let targetPath = req.url;
873
+
874
+ const refererPath = (() => {
875
+ const ref = String(req.headers.referer || "");
876
+ if (!ref) return "";
877
+ try {
878
+ return new URL(ref).pathname;
879
+ } catch {
880
+ return "";
881
+ }
882
+ })();
883
+ const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`);
884
+
885
+ if (path === "/v1" || path.startsWith("/v1/")) {
886
+ targetPort = GATEWAY_PORT;
887
+ } else if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
888
+ targetPort = DASHBOARD_PORT;
889
+ targetPath = path.replace(`${HM_PREFIX}/app`, "") || "/";
890
+ if (parsed.search) targetPath += parsed.search;
891
+ } else if (refererIsDashboard && !path.startsWith("/webui")) {
892
+ targetPort = DASHBOARD_PORT;
893
+ } else if (path.startsWith("/webui/") || path === "/webui") {
894
+ targetPort = WEBUI_PORT;
895
+ targetPath = path.replace(/^\/webui/, "") || "/";
896
+ if (parsed.search) targetPath += parsed.search;
897
+ }
898
+
899
+ const upstream = net.createConnection(targetPort, GATEWAY_HOST, () => {
900
+ // Forward the HTTP upgrade handshake verbatim
901
+ const headerLines = [
902
+ `${req.method} ${targetPath} HTTP/1.1`,
903
+ ];
904
+ for (const [name, value] of Object.entries(req.headers)) {
905
+ if (Array.isArray(value)) {
906
+ for (const v of value) headerLines.push(`${name}: ${v}`);
907
+ } else {
908
+ headerLines.push(`${name}: ${value}`);
909
+ }
910
+ }
911
+ headerLines.push("", "");
912
+ upstream.write(headerLines.join("\r\n"));
913
+ if (head && head.length) upstream.write(head);
914
+ upstream.pipe(clientSocket);
915
+ clientSocket.pipe(upstream);
916
+ });
917
+
918
+ upstream.on("error", () => {
919
+ try {
920
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
921
+ } catch {}
922
+ });
923
+ clientSocket.on("error", () => {
924
+ try {
925
+ upstream.destroy();
926
+ } catch {}
927
+ });
928
+ });
hermes-sync.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """HuggingMes Hermes state backup via Hugging Face Datasets.
3
+
4
+ Vendored verbatim from github.com/somratpro/HuggingMes.
5
+ Backs up HERMES_HOME (which includes /opt/data/webui — the hermes-webui state dir)
6
+ so sessions, profiles, skills, cron, memory, and workspace all survive restarts.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import signal
15
+ import random
16
+ import socket
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+
23
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
24
+ os.environ.setdefault("HF_HUB_VERBOSITY", "error")
25
+ os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")
26
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
27
+
28
+ from huggingface_hub import HfApi, snapshot_download, upload_folder
29
+ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
30
+
31
+ logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
32
+
33
+ HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data"))
34
+ STATUS_FILE = Path("/tmp/huggingmes-sync-status.json")
35
+ STATE_FILE = HERMES_HOME / ".huggingmes-sync-state.json"
36
+ INTERVAL = int(os.environ.get("SYNC_INTERVAL", "600"))
37
+ INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
38
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
39
+ HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
40
+ SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
41
+ BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "huggingmes-backup").strip()
42
+ INCLUDE_ENV = os.environ.get("SYNC_INCLUDE_ENV", "").strip().lower() in {"1", "true", "yes"}
43
+ MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
44
+
45
+ EXCLUDED_DIRS = {
46
+ ".cache",
47
+ ".git",
48
+ ".npm",
49
+ ".venv",
50
+ "__pycache__",
51
+ "node_modules",
52
+ "venv",
53
+ }
54
+ EXCLUDED_TOP_LEVEL = {"logs", STATE_FILE.name}
55
+ if not INCLUDE_ENV:
56
+ EXCLUDED_TOP_LEVEL.add(".env")
57
+
58
+ HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
59
+ STOP_EVENT = threading.Event()
60
+ _REPO_ID_CACHE: str | None = None
61
+
62
+
63
+ def write_status(status: str, message: str, fingerprint: str | None = None, marker: tuple[int, int, int] | None = None) -> None:
64
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
65
+ payload = {"status": status, "message": message, "timestamp": timestamp}
66
+
67
+ tmp_path = STATUS_FILE.with_suffix(".tmp")
68
+ try:
69
+ tmp_path.write_text(json.dumps(payload), encoding="utf-8")
70
+ tmp_path.replace(STATUS_FILE)
71
+ except OSError:
72
+ pass
73
+
74
+ if fingerprint or marker:
75
+ state = {}
76
+ if STATE_FILE.exists():
77
+ try:
78
+ state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
79
+ except Exception:
80
+ pass
81
+ if fingerprint:
82
+ state["last_fingerprint"] = fingerprint
83
+ if marker:
84
+ state["last_marker"] = list(marker)
85
+ state["last_sync"] = timestamp
86
+ try:
87
+ STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
88
+ except OSError:
89
+ pass
90
+
91
+
92
+ def resolve_backup_repo() -> str:
93
+ global _REPO_ID_CACHE
94
+ if _REPO_ID_CACHE:
95
+ return _REPO_ID_CACHE
96
+
97
+ namespace = HF_USERNAME or SPACE_AUTHOR_NAME
98
+ if not namespace and HF_API is not None:
99
+ whoami = HF_API.whoami()
100
+ namespace = whoami.get("name") or whoami.get("user") or ""
101
+
102
+ namespace = str(namespace).strip()
103
+ if not namespace:
104
+ raise RuntimeError("Could not determine HF username. Set HF_USERNAME or use an account HF_TOKEN.")
105
+
106
+ _REPO_ID_CACHE = f"{namespace}/{BACKUP_DATASET_NAME}"
107
+ return _REPO_ID_CACHE
108
+
109
+
110
+ def ensure_repo_exists() -> str:
111
+ repo_id = resolve_backup_repo()
112
+ try:
113
+ HF_API.repo_info(repo_id=repo_id, repo_type="dataset")
114
+ except RepositoryNotFoundError:
115
+ HF_API.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
116
+ return repo_id
117
+
118
+
119
+ def should_exclude(rel_posix: str, path: Path) -> bool:
120
+ parts = Path(rel_posix).parts
121
+ if not parts:
122
+ return False
123
+ if parts[0] in EXCLUDED_TOP_LEVEL:
124
+ return True
125
+ if any(part in EXCLUDED_DIRS for part in parts):
126
+ return True
127
+ if path.is_file():
128
+ name_lower = path.name.lower()
129
+ if name_lower.endswith((".db-shm", ".db-wal", ".db-journal")):
130
+ return True
131
+ try:
132
+ return path.stat().st_size > MAX_FILE_SIZE_BYTES
133
+ except OSError:
134
+ return True
135
+ return False
136
+
137
+
138
+ def metadata_marker(root: Path) -> tuple[int, int, int]:
139
+ if not root.exists():
140
+ return (0, 0, 0)
141
+ file_count = 0
142
+ total_size = 0
143
+ newest_mtime = 0
144
+ for path in root.rglob("*"):
145
+ if not path.is_file():
146
+ continue
147
+ rel = path.relative_to(root).as_posix()
148
+ if should_exclude(rel, path):
149
+ continue
150
+ try:
151
+ stat = path.stat()
152
+ except OSError:
153
+ continue
154
+ file_count += 1
155
+ total_size += int(stat.st_size)
156
+ newest_mtime = max(newest_mtime, int(stat.st_mtime_ns))
157
+ return (file_count, total_size, newest_mtime)
158
+
159
+
160
+ def fingerprint_dir(root: Path) -> str:
161
+ hasher = hashlib.sha256()
162
+ if not root.exists():
163
+ return hasher.hexdigest()
164
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
165
+ rel = path.relative_to(root).as_posix()
166
+ if should_exclude(rel, path):
167
+ continue
168
+ hasher.update(rel.encode("utf-8"))
169
+ with path.open("rb") as handle:
170
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
171
+ hasher.update(chunk)
172
+ return hasher.hexdigest()
173
+
174
+
175
+ def create_snapshot_dir(source_root: Path) -> Path:
176
+ staging_root = Path(tempfile.mkdtemp(prefix="huggingmes-sync-"))
177
+ for path in sorted(source_root.rglob("*")):
178
+ rel = path.relative_to(source_root)
179
+ rel_posix = rel.as_posix()
180
+ if should_exclude(rel_posix, path):
181
+ continue
182
+ target = staging_root / rel
183
+ if path.is_dir():
184
+ target.mkdir(parents=True, exist_ok=True)
185
+ continue
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ try:
188
+ shutil.copy2(path, target)
189
+ except OSError:
190
+ continue
191
+ return staging_root
192
+
193
+
194
+ def restore() -> bool:
195
+ if not HF_TOKEN:
196
+ write_status("disabled", "HF_TOKEN is not configured.")
197
+ return False
198
+
199
+ repo_id = resolve_backup_repo()
200
+ write_status("restoring", f"Restoring Hermes state from {repo_id}")
201
+ try:
202
+ with tempfile.TemporaryDirectory() as tmpdir:
203
+ snapshot_download(repo_id=repo_id, repo_type="dataset", token=HF_TOKEN, local_dir=tmpdir)
204
+ tmp_path = Path(tmpdir)
205
+ if not any(tmp_path.iterdir()):
206
+ write_status("fresh", "Backup dataset is empty. Starting fresh.")
207
+ return True
208
+
209
+ HERMES_HOME.mkdir(parents=True, exist_ok=True)
210
+ for child in tmp_path.iterdir():
211
+ if should_exclude(child.name, child):
212
+ continue
213
+ target = HERMES_HOME / child.name
214
+ if target.is_dir():
215
+ shutil.rmtree(target, ignore_errors=True)
216
+ elif target.exists():
217
+ target.unlink()
218
+ if child.is_dir():
219
+ shutil.copytree(child, target)
220
+ else:
221
+ shutil.copy2(child, target)
222
+
223
+ write_status("restored", f"Restored Hermes state from {repo_id}")
224
+ return True
225
+ except RepositoryNotFoundError:
226
+ write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
227
+ return True
228
+ except HfHubHTTPError as exc:
229
+ if exc.response is not None and exc.response.status_code == 404:
230
+ write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
231
+ return True
232
+ write_status("error", f"Restore failed: {exc}")
233
+ print(f"Restore failed: {exc}", file=sys.stderr)
234
+ return False
235
+ except Exception as exc:
236
+ write_status("error", f"Restore failed: {exc}")
237
+ print(f"Restore failed: {exc}", file=sys.stderr)
238
+ return False
239
+
240
+
241
+ def sync_once(last_fingerprint: str | None = None, last_marker: tuple[int, int, int] | None = None):
242
+ if last_fingerprint is None and last_marker is None:
243
+ if STATE_FILE.exists():
244
+ try:
245
+ state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
246
+ last_fingerprint = state.get("last_fingerprint")
247
+ m = state.get("last_marker")
248
+ if m and len(m) == 3:
249
+ last_marker = tuple(m)
250
+ except Exception:
251
+ pass
252
+
253
+ repo_id = ensure_repo_exists()
254
+ current_marker = metadata_marker(HERMES_HOME)
255
+ if last_marker is not None and current_marker == last_marker:
256
+ write_status("synced", "No Hermes state changes detected (marker match).")
257
+ return (last_fingerprint or "", current_marker)
258
+
259
+ current_fingerprint = fingerprint_dir(HERMES_HOME)
260
+ if last_fingerprint is not None and current_fingerprint == last_fingerprint:
261
+ write_status("synced", "No Hermes state changes detected (fingerprint match).")
262
+ return (last_fingerprint, current_marker)
263
+
264
+ hostname = socket.gethostname()
265
+ write_status("syncing", f"Uploading Hermes state to {repo_id} from {hostname}")
266
+ snapshot_dir = create_snapshot_dir(HERMES_HOME)
267
+ try:
268
+ upload_folder(
269
+ folder_path=str(snapshot_dir),
270
+ repo_id=repo_id,
271
+ repo_type="dataset",
272
+ token=HF_TOKEN,
273
+ commit_message=f"HuggingMes sync [{hostname}] {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
274
+ ignore_patterns=[".git/*", ".git"],
275
+ )
276
+ finally:
277
+ shutil.rmtree(snapshot_dir, ignore_errors=True)
278
+
279
+ write_status("success", f"Uploaded Hermes state to {repo_id}", fingerprint=current_fingerprint, marker=current_marker)
280
+ return (current_fingerprint, current_marker)
281
+
282
+
283
+ def handle_signal(_sig, _frame) -> None:
284
+ STOP_EVENT.set()
285
+
286
+
287
+ def loop() -> int:
288
+ signal.signal(signal.SIGTERM, handle_signal)
289
+ signal.signal(signal.SIGINT, handle_signal)
290
+ try:
291
+ repo_id = resolve_backup_repo()
292
+ write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
293
+ except Exception as exc:
294
+ write_status("error", str(exc))
295
+ print(f"Hermes sync error: {exc}")
296
+ return 1
297
+
298
+ last_fingerprint = fingerprint_dir(HERMES_HOME)
299
+ last_marker = metadata_marker(HERMES_HOME)
300
+ time.sleep(INITIAL_DELAY)
301
+ print(f"Hermes state sync started: every {INTERVAL}s -> {repo_id}")
302
+
303
+ while not STOP_EVENT.is_set():
304
+ try:
305
+ last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
306
+ except Exception as exc:
307
+ write_status("error", f"Sync failed: {exc}")
308
+ print(f"Hermes sync failed: {exc}")
309
+ jitter = random.uniform(0.9, 1.1)
310
+ if STOP_EVENT.wait(INTERVAL * jitter):
311
+ break
312
+ return 0
313
+
314
+
315
+ def main() -> int:
316
+ HERMES_HOME.mkdir(parents=True, exist_ok=True)
317
+ if len(sys.argv) < 2:
318
+ return loop()
319
+ command = sys.argv[1]
320
+ if command == "restore":
321
+ return 0 if restore() else 1
322
+ if command == "sync-once":
323
+ try:
324
+ sync_once()
325
+ return 0
326
+ except Exception as exc:
327
+ write_status("error", f"Shutdown sync failed: {exc}")
328
+ print(f"Hermes sync: shutdown sync failed: {exc}")
329
+ return 1
330
+ if command == "loop":
331
+ return loop()
332
+ print(f"Unknown command: {command}", file=sys.stderr)
333
+ return 1
334
+
335
+
336
+ if __name__ == "__main__":
337
+ raise SystemExit(main())
start.sh ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ umask 0077
5
+
6
+ # ══════════════════════════════════════════════════════════════════════
7
+ # HuggingMes + Hermes WebUI — integrated Hermes Agent stack for HF Spaces
8
+ # Based on github.com/somratpro/HuggingMes, with Hermes WebUI
9
+ # (github.com/nesquena/hermes-webui) as the primary UI.
10
+ # ══════════════════════════════════════════════════════════════════════
11
+
12
+ APP_DIR="${HUGGINGMES_APP_DIR:-/opt/huggingmes}"
13
+ WEBUI_REPO="${HERMES_WEBUI_REPO:-/opt/hermes-webui}"
14
+ HERMES_HOME="${HERMES_HOME:-/opt/data}"
15
+
16
+ PUBLIC_PORT="${PORT:-7861}"
17
+ GATEWAY_API_PORT="${API_SERVER_PORT:-8642}"
18
+ DASHBOARD_PORT="${DASHBOARD_PORT:-9119}"
19
+ TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"
20
+ WEBUI_PORT="${HERMES_WEBUI_PORT:-8787}"
21
+
22
+ SYNC_INTERVAL="${SYNC_INTERVAL:-600}"
23
+ BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmes-backup}"
24
+ CF_PROXY_ENV_FILE="/tmp/huggingmes-cloudflare-proxy.env"
25
+
26
+ export HERMES_HOME
27
+ export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
28
+ export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}"
29
+ export API_SERVER_PORT="$GATEWAY_API_PORT"
30
+ export GATEWAY_HEALTH_URL="${GATEWAY_HEALTH_URL:-http://127.0.0.1:${GATEWAY_API_PORT}}"
31
+ export TELEGRAM_WEBHOOK_PORT
32
+ export HERMES_WEBUI_PORT="$WEBUI_PORT"
33
+
34
+ echo ""
35
+ echo " ╔══════════════════════════════════════════╗"
36
+ echo " ║ 🪽 HuggingMes + Hermes WebUI Gateway ║"
37
+ echo " ╚══════════════════════════════════════════╝"
38
+ echo ""
39
+
40
+ # ── Unified auth: GATEWAY_TOKEN drives everything ─────────────────────
41
+ if [ -z "${API_SERVER_KEY:-}" ]; then
42
+ if [ -n "${GATEWAY_TOKEN:-}" ]; then
43
+ export API_SERVER_KEY="$GATEWAY_TOKEN"
44
+ else
45
+ API_SERVER_KEY="$(python3 - <<'PY'
46
+ import secrets
47
+ print(secrets.token_urlsafe(32))
48
+ PY
49
+ )"
50
+ export API_SERVER_KEY
51
+ echo "GATEWAY_TOKEN not set - generated an ephemeral token for this boot."
52
+ fi
53
+ fi
54
+
55
+ # Same token becomes Hermes WebUI's login password (unified auth).
56
+ if [ -n "${GATEWAY_TOKEN:-}" ]; then
57
+ export HERMES_WEBUI_PASSWORD="${HERMES_WEBUI_PASSWORD:-$GATEWAY_TOKEN}"
58
+ fi
59
+
60
+ # ── Setup state dirs ──────────────────────────────────────────────────
61
+ mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home,plugins,webui}
62
+
63
+ # Expose hermes CLI to login shells
64
+ mkdir -p "$HERMES_HOME/.local/bin"
65
+ ln -sfn /opt/hermes/.venv/bin/hermes "$HERMES_HOME/.local/bin/hermes"
66
+
67
+ # Redirect Hermes plugin dir into volume
68
+ if [ ! -L "${HOME}/.hermes/plugins" ]; then
69
+ mkdir -p "${HOME}/.hermes"
70
+ rm -rf "${HOME}/.hermes/plugins"
71
+ ln -sfn "$HERMES_HOME/plugins" "${HOME}/.hermes/plugins"
72
+ fi
73
+
74
+ # ── Restore state from HF Dataset ─────────────────────────────────────
75
+ if [ -n "${HF_TOKEN:-}" ]; then
76
+ echo "Restoring Hermes state from HF Dataset..."
77
+ python3 "$APP_DIR/hermes-sync.py" restore || true
78
+ else
79
+ echo "HF_TOKEN not set - dataset persistence is disabled."
80
+ fi
81
+
82
+ # ── Cloudflare proxy (optional) ───────────────────────────────────────
83
+ CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
84
+ export CLOUDFLARE_WORKERS_TOKEN
85
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
86
+ export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
87
+ echo "Preparing Cloudflare Telegram proxy..."
88
+ python3 "$APP_DIR/cloudflare-proxy-setup.py" || true
89
+ if [ -f "$CF_PROXY_ENV_FILE" ]; then
90
+ . "$CF_PROXY_ENV_FILE"
91
+ fi
92
+ fi
93
+
94
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
95
+ echo "Preparing Cloudflare Keepalive worker..."
96
+ python3 "$APP_DIR/cloudflare-keepalive-setup.py" || true
97
+ fi
98
+
99
+ # ── Telegram env normalisation (aliases + webhook URL + secret) ───────
100
+ if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
101
+ export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"
102
+ elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
103
+ export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"
104
+ fi
105
+
106
+ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then
107
+ if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then
108
+ export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"
109
+ fi
110
+ fi
111
+
112
+ if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then
113
+ SECRET_FILE="$HERMES_HOME/.huggingmes-telegram-webhook-secret"
114
+ if [ -f "$SECRET_FILE" ]; then
115
+ TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"
116
+ else
117
+ TELEGRAM_WEBHOOK_SECRET="$(python3 - <<'PY'
118
+ import secrets
119
+ print(secrets.token_hex(32))
120
+ PY
121
+ )"
122
+ printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"
123
+ chmod 600 "$SECRET_FILE"
124
+ fi
125
+ export TELEGRAM_WEBHOOK_SECRET
126
+ fi
127
+
128
+ # ── Provider-prefix mapping (HuggingMes convention) ───────────────────
129
+ MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"
130
+ MODEL_FOR_CONFIG="$MODEL_INPUT"
131
+ PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"
132
+ LLM_API_KEY="${LLM_API_KEY:-}"
133
+
134
+ if [ -n "$MODEL_INPUT" ]; then
135
+ MODEL_PREFIX="${MODEL_INPUT%%/*}"
136
+ else
137
+ MODEL_PREFIX=""
138
+ fi
139
+
140
+ case "$MODEL_PREFIX" in
141
+ openrouter)
142
+ [ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"
143
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"
144
+ MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"
145
+ ;;
146
+ huggingface|hf)
147
+ [ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"
148
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"
149
+ MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"
150
+ ;;
151
+ vercel-ai-gateway|ai-gateway)
152
+ [ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"
153
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"
154
+ MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
155
+ ;;
156
+ anthropic)
157
+ [ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"
158
+ ;;
159
+ openai|openai-codex)
160
+ [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
161
+ ;;
162
+ google|gemini)
163
+ [ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"
164
+ PROVIDER_FOR_CONFIG="gemini"
165
+ MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
166
+ ;;
167
+ deepseek)
168
+ [ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"
169
+ ;;
170
+ kimi-coding|moonshot)
171
+ [ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"
172
+ ;;
173
+ kimi-coding-cn|moonshot-cn|kimi-cn)
174
+ [ -n "$LLM_API_KEY" ] && export KIMI_CN_API_KEY="${KIMI_CN_API_KEY:-$LLM_API_KEY}"
175
+ ;;
176
+ minimax)
177
+ [ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"
178
+ ;;
179
+ minimax-cn)
180
+ [ -n "$LLM_API_KEY" ] && export MINIMAX_CN_API_KEY="${MINIMAX_CN_API_KEY:-$LLM_API_KEY}"
181
+ ;;
182
+ xiaomi)
183
+ [ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"
184
+ ;;
185
+ zai|z-ai|z.ai|glm)
186
+ [ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"
187
+ ;;
188
+ arcee|arcee-ai|arceeai)
189
+ [ -n "$LLM_API_KEY" ] && export ARCEEAI_API_KEY="${ARCEEAI_API_KEY:-$LLM_API_KEY}"
190
+ ;;
191
+ gmi|gmi-cloud|gmicloud)
192
+ [ -n "$LLM_API_KEY" ] && export GMI_API_KEY="${GMI_API_KEY:-$LLM_API_KEY}"
193
+ ;;
194
+ alibaba|alibaba-coding-plan|alibaba_coding)
195
+ [ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"
196
+ ;;
197
+ tencent-tokenhub|tencent|tokenhub|tencentmaas)
198
+ [ -n "$LLM_API_KEY" ] && export TOKENHUB_API_KEY="${TOKENHUB_API_KEY:-$LLM_API_KEY}"
199
+ ;;
200
+ nvidia)
201
+ [ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"
202
+ ;;
203
+ xai|grok)
204
+ [ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"
205
+ ;;
206
+ kilocode)
207
+ [ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"
208
+ ;;
209
+ opencode-zen)
210
+ [ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"
211
+ ;;
212
+ opencode-go)
213
+ [ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"
214
+ ;;
215
+ esac
216
+
217
+ if [ -n "${CUSTOM_BASE_URL:-}" ]; then
218
+ PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"
219
+ [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
220
+ fi
221
+
222
+ export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG
223
+ export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
224
+ export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"
225
+ export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"
226
+ export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"
227
+ export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"
228
+ export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"
229
+
230
+ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
231
+ CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"
232
+ export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"
233
+ export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
234
+ fi
235
+
236
+ # ── Build Hermes config.yaml ──────────────────────────────────────────
237
+ python3 - <<'PY'
238
+ import os
239
+ from pathlib import Path
240
+ import yaml
241
+
242
+ home = Path(os.environ["HERMES_HOME"])
243
+ path = home / "config.yaml"
244
+ try:
245
+ config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
246
+ except FileNotFoundError:
247
+ config = {}
248
+
249
+ model_name = os.environ.get("MODEL_FOR_CONFIG", "").strip()
250
+ provider_name = os.environ.get("PROVIDER_FOR_CONFIG", "").strip()
251
+
252
+ if model_name:
253
+ model = config.setdefault("model", {})
254
+ model["default"] = model_name
255
+ if provider_name and provider_name != "auto":
256
+ model["provider"] = provider_name
257
+ else:
258
+ model.pop("provider", None)
259
+ else:
260
+ model = config.get("model", {})
261
+ print("No LLM_MODEL/HERMES_MODEL set; leaving Hermes model config unchanged.")
262
+
263
+ custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()
264
+ if custom_base and model_name:
265
+ model.setdefault("base_url", custom_base.rstrip("/"))
266
+ if os.environ.get("CUSTOM_API_KEY"):
267
+ model.setdefault("api_key", os.environ["CUSTOM_API_KEY"])
268
+ try:
269
+ model.setdefault("context_length", int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072")))
270
+ model.setdefault("max_tokens", int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192")))
271
+ except ValueError:
272
+ pass
273
+
274
+ config.setdefault("terminal", {}).setdefault("cwd", os.environ.get("MESSAGING_CWD", str(home / "workspace")))
275
+ config.setdefault("compression", {}).setdefault("enabled", True)
276
+ config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))
277
+ config.setdefault("security", {}).setdefault("redact_secrets", True)
278
+
279
+ platforms = config.setdefault("platforms", {})
280
+
281
+ if os.environ.get("TELEGRAM_BOT_TOKEN"):
282
+ telegram = platforms.setdefault("telegram", {})
283
+ telegram.setdefault("enabled", True)
284
+ extra = telegram.setdefault("extra", {})
285
+ if os.environ.get("TELEGRAM_BASE_URL"):
286
+ extra.setdefault("base_url", os.environ["TELEGRAM_BASE_URL"])
287
+ extra.setdefault("base_file_url", os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"])
288
+ if os.environ.get("TELEGRAM_ALLOWED_USERS"):
289
+ config.setdefault("telegram", {}).setdefault("allow_from", [
290
+ item.strip()
291
+ for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")
292
+ if item.strip()
293
+ ])
294
+
295
+ path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
296
+ path.chmod(0o600)
297
+ PY
298
+
299
+ # ── Startup summary ───────────────────────────────────────────────────
300
+ echo ""
301
+ echo "Primary UI : ${PRIMARY_UI:-webui}"
302
+ echo "Model : ${MODEL_FOR_CONFIG:-unset}"
303
+ echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"
304
+ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
305
+ echo "Telegram : enabled"
306
+ else
307
+ echo "Telegram : not configured"
308
+ fi
309
+ if [ -n "${HF_TOKEN:-}" ]; then
310
+ echo "Backup : ${BACKUP_DATASET} (every ${SYNC_INTERVAL:-600}s)"
311
+ else
312
+ echo "Backup : disabled"
313
+ fi
314
+ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
315
+ echo "CF Proxy : ${CLOUDFLARE_PROXY_URL}"
316
+ fi
317
+ echo "Router : 0.0.0.0:${PUBLIC_PORT}"
318
+ echo "WebUI : 127.0.0.1:${WEBUI_PORT}"
319
+ echo "Gateway : 127.0.0.1:${GATEWAY_API_PORT}"
320
+ echo "Dashboard : 127.0.0.1:${DASHBOARD_PORT}"
321
+ echo ""
322
+
323
+ # ── Graceful shutdown ─────────────────────────────────────────────────
324
+ graceful_shutdown() {
325
+ echo "Shutting down..."
326
+ if [ -n "${HF_TOKEN:-}" ]; then
327
+ python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: shutdown sync failed."
328
+ fi
329
+ kill $(jobs -p) 2>/dev/null || true
330
+ exit 0
331
+ }
332
+ trap graceful_shutdown SIGTERM SIGINT
333
+
334
+ # ── Start the public-facing router (port 7861) ────────────────────────
335
+ node "$APP_DIR/health-server.js" &
336
+ HEALTH_PID=$!
337
+
338
+ if [ -n "${WEBHOOK_URL:-}" ]; then
339
+ python3 - <<'PY' >/dev/null 2>&1 &
340
+ import json, os, urllib.request
341
+ body = json.dumps({
342
+ "event": "restart",
343
+ "status": "success",
344
+ "message": "HuggingMes + Hermes WebUI has started.",
345
+ "model": os.environ.get("MODEL_FOR_CONFIG", ""),
346
+ }).encode()
347
+ req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST",
348
+ headers={"Content-Type": "application/json"})
349
+ urllib.request.urlopen(req, timeout=10).read()
350
+ PY
351
+ fi
352
+
353
+ # ── Launch Hermes dashboard (private; proxied via /hm/app) ────────────
354
+ echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
355
+ (hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
356
+ DASHBOARD_PID=$!
357
+
358
+ # ── Launch Hermes gateway ─────────────────────────────────────────────
359
+ echo "Launching Hermes gateway..."
360
+ (hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
361
+ GATEWAY_PID=$!
362
+
363
+ GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
364
+ ready=false
365
+ for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
366
+ if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
367
+ ready=true
368
+ break
369
+ fi
370
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
371
+ break
372
+ fi
373
+ sleep 1
374
+ done
375
+
376
+ if [ "$ready" != "true" ]; then
377
+ echo ""
378
+ echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
379
+ echo "----------------------------------------"
380
+ tail -40 "$HERMES_HOME/logs/gateway.log" || true
381
+ exit 1
382
+ fi
383
+
384
+ # ── Launch Hermes WebUI (nesquena/hermes-webui) ───────────────────────
385
+ # Points WebUI at the already-running Hermes agent venv and persists state
386
+ # under $HERMES_HOME/webui so hermes-sync.py backs it up.
387
+ export HERMES_WEBUI_AGENT_DIR="/opt/hermes"
388
+ export HERMES_WEBUI_PYTHON="/opt/hermes/.venv/bin/python"
389
+ export HERMES_WEBUI_HOST="127.0.0.1"
390
+ export HERMES_WEBUI_PORT
391
+ export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-$HERMES_HOME/webui}"
392
+ export HERMES_WEBUI_DEFAULT_WORKSPACE="${HERMES_WEBUI_DEFAULT_WORKSPACE:-$HERMES_HOME/workspace}"
393
+ export HERMES_WEBUI_AUTO_INSTALL="0"
394
+ mkdir -p "$HERMES_WEBUI_STATE_DIR"
395
+
396
+ echo "Launching Hermes WebUI on 127.0.0.1:${WEBUI_PORT}..."
397
+ (cd "$WEBUI_REPO" && \
398
+ "$HERMES_WEBUI_PYTHON" "$WEBUI_REPO/server.py" 2>&1 | \
399
+ tee -a "$HERMES_HOME/logs/webui.log") &
400
+ WEBUI_PID=$!
401
+
402
+ # Wait for WebUI to bind its port (non-fatal on timeout — router handles it)
403
+ WEBUI_READY_TIMEOUT="${WEBUI_READY_TIMEOUT:-60}"
404
+ for ((i=0; i<WEBUI_READY_TIMEOUT; i++)); do
405
+ if (echo > "/dev/tcp/127.0.0.1/${WEBUI_PORT}") 2>/dev/null; then
406
+ echo "Hermes WebUI is up."
407
+ break
408
+ fi
409
+ if ! kill -0 "$WEBUI_PID" 2>/dev/null; then
410
+ echo "Warning: Hermes WebUI exited during startup. Last 20 log lines:"
411
+ tail -20 "$HERMES_HOME/logs/webui.log" || true
412
+ break
413
+ fi
414
+ sleep 1
415
+ done
416
+
417
+ # ── Periodic backup loop ──────────────────────────────────────────────
418
+ if [ -n "${HF_TOKEN:-}" ]; then
419
+ python3 -u "$APP_DIR/hermes-sync.py" loop &
420
+ fi
421
+
422
+ # ── Wait on the gateway (primary supervision target) ──────────────────
423
+ wait "$GATEWAY_PID"
424
+
425
+ if [ -n "${HF_TOKEN:-}" ]; then
426
+ echo "Gateway exited - syncing state before shutdown..."
427
+ python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: final sync failed."
428
+ fi