ruang101 commited on
Commit
81ba82d
Β·
verified Β·
1 Parent(s): 81c964d

Upload folder using huggingface_hub

Browse files
Files changed (13) hide show
  1. .dockerignore +14 -0
  2. .gitignore +11 -0
  3. Dockerfile +197 -0
  4. LICENSE +36 -0
  5. README.md +292 -10
  6. SOUL.md +48 -0
  7. cloakbrowser/SKILL.md +145 -0
  8. cloudflare-keepalive-setup.py +225 -0
  9. cloudflare-proxy-setup.py +203 -0
  10. config.yaml.template +281 -0
  11. health-server.js +1007 -0
  12. hermes-sync.py +482 -0
  13. start.sh +481 -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
.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,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ RUN mkdir -p /opt/huggingmes
51
+
52
+ COPY --chown=hermes:hermes start.sh /opt/huggingmes/start.sh
53
+ COPY --chown=hermes:hermes health-server.js /opt/huggingmes/health-server.js
54
+ COPY --chown=hermes:hermes hermes-sync.py /opt/huggingmes/hermes-sync.py
55
+ COPY --chown=hermes:hermes cloudflare-proxy-setup.py /opt/huggingmes/cloudflare-proxy-setup.py
56
+ COPY --chown=hermes:hermes cloudflare-keepalive-setup.py /opt/huggingmes/cloudflare-keepalive-setup.py
57
+ COPY --chown=hermes:hermes config.yaml.template /opt/huggingmes/config.yaml.template
58
+ COPY --chown=hermes:hermes SOUL.md /opt/huggingmes/SOUL.md
59
+
60
+ RUN chmod +x \
61
+ /opt/huggingmes/start.sh \
62
+ /opt/huggingmes/hermes-sync.py \
63
+ /opt/huggingmes/cloudflare-proxy-setup.py \
64
+ /opt/huggingmes/cloudflare-keepalive-setup.py \
65
+ && sed -i 's/\r$//' /opt/huggingmes/start.sh
66
+
67
+ # Idempotent kanban migration patch (same workaround HuggingMes ships)
68
+ RUN python3 - <<'PY'
69
+ from pathlib import Path
70
+ import sys
71
+ p = Path("/opt/hermes/hermes_cli/kanban_db.py")
72
+ if not p.exists():
73
+ sys.exit(0)
74
+ src = p.read_text(encoding="utf-8")
75
+ sentinel = "# huggingmes-webui: idempotent-alter"
76
+ if sentinel in src:
77
+ sys.exit(0)
78
+ old = (
79
+ ' conn.execute(\n'
80
+ ' "ALTER TABLE tasks ADD COLUMN consecutive_failures "\n'
81
+ ' "INTEGER NOT NULL DEFAULT 0"\n'
82
+ ' )'
83
+ )
84
+ new = (
85
+ f' try: {sentinel}\n'
86
+ ' conn.execute(\n'
87
+ ' "ALTER TABLE tasks ADD COLUMN consecutive_failures "\n'
88
+ ' "INTEGER NOT NULL DEFAULT 0"\n'
89
+ ' )\n'
90
+ ' except Exception:\n'
91
+ ' pass'
92
+ )
93
+ if old in src:
94
+ p.write_text(src.replace(old, new), encoding="utf-8")
95
+ print("kanban patch: applied")
96
+ PY
97
+
98
+ # Quiet hermes-webui's per-request access log noise.
99
+ # By default it prints `[webui] {"ts":...,"method":...}` for EVERY request,
100
+ # which drowns the HF Logs tab once any browser tab is open polling
101
+ # /api/dashboard/status, /api/health/agent, /api/sessions, /sw.js, etc.
102
+ # Patch log_request() to drop 2xx responses for high-frequency poll paths.
103
+ # Errors and chat/streaming paths still log normally.
104
+ RUN python3 - <<'PY'
105
+ from pathlib import Path
106
+ import re
107
+ import sys
108
+
109
+ p = Path("/opt/hermes-webui/server.py")
110
+ if not p.exists():
111
+ sys.exit(0)
112
+ src = p.read_text(encoding="utf-8")
113
+ sentinel = "# huggingmes-webui: quiet-poll-paths"
114
+ if sentinel in src:
115
+ sys.exit(0)
116
+
117
+ old = (
118
+ " def log_request(self, code: str='-', size: str='-') -> None:\n"
119
+ " \"\"\"Structured JSON logs for each request.\"\"\"\n"
120
+ " import json as _json\n"
121
+ " duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)\n"
122
+ " record = _json.dumps({\n"
123
+ " 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),\n"
124
+ " 'method': self.command or '-',\n"
125
+ " 'path': self.path or '-',\n"
126
+ " 'status': int(code) if str(code).isdigit() else code,\n"
127
+ " 'ms': duration_ms,\n"
128
+ " })\n"
129
+ " print(f'[webui] {record}', flush=True)"
130
+ )
131
+
132
+ new = (
133
+ " _QUIET_POLL_PATHS = ( " + sentinel + "\n"
134
+ " '/api/health/agent', '/api/dashboard/status',\n"
135
+ " '/api/dashboard/config', '/api/sessions', '/api/profiles',\n"
136
+ " '/api/profile/active', '/api/onboarding/status',\n"
137
+ " '/api/insights', '/api/system/health',\n"
138
+ " '/api/settings', '/api/projects', '/api/reasoning',\n"
139
+ " '/api/models', '/api/chat/stream/status',\n"
140
+ " '/api/git-info', '/sw.js', '/health',\n"
141
+ " )\n"
142
+ " _QUIET_PREFIXES = ('/static/', '/session/static/', '/assets/')\n"
143
+ "\n"
144
+ " def log_request(self, code: str='-', size: str='-') -> None:\n"
145
+ " \"\"\"Structured JSON logs for each request, skipping noisy polls.\"\"\"\n"
146
+ " # Always log non-2xx so 401/404/5xx remain visible.\n"
147
+ " try:\n"
148
+ " status_int = int(code) if str(code).isdigit() else 0\n"
149
+ " except Exception:\n"
150
+ " status_int = 0\n"
151
+ " path = (self.path or '').split('?', 1)[0]\n"
152
+ " if 200 <= status_int < 400:\n"
153
+ " if path in self._QUIET_POLL_PATHS:\n"
154
+ " return\n"
155
+ " for pref in self._QUIET_PREFIXES:\n"
156
+ " if path.startswith(pref):\n"
157
+ " return\n"
158
+ " import json as _json\n"
159
+ " duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)\n"
160
+ " record = _json.dumps({\n"
161
+ " 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),\n"
162
+ " 'method': self.command or '-',\n"
163
+ " 'path': self.path or '-',\n"
164
+ " 'status': int(code) if str(code).isdigit() else code,\n"
165
+ " 'ms': duration_ms,\n"
166
+ " })\n"
167
+ " print(f'[webui] {record}', flush=True)"
168
+ )
169
+
170
+ if old in src:
171
+ p.write_text(src.replace(old, new), encoding="utf-8")
172
+ print("webui log-quiet patch: applied")
173
+ else:
174
+ print("webui log-quiet patch: pattern not found, skipping")
175
+ PY
176
+
177
+ # Keep hermes CLI on PATH for all shell types (login/interactive/non-interactive)
178
+ RUN echo 'export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH"' \
179
+ > /etc/profile.d/hermes-venv.sh
180
+
181
+ ENV HERMES_HOME=/opt/data \
182
+ HUGGINGMES_APP_DIR=/opt/huggingmes \
183
+ HERMES_WEBUI_REPO=/opt/hermes-webui \
184
+ HERMES_AGENT_VERSION=${HERMES_AGENT_VERSION} \
185
+ PYTHONUNBUFFERED=1 \
186
+ HF_HUB_ENABLE_HF_TRANSFER=1 \
187
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
188
+
189
+ EXPOSE 7861
190
+
191
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=120s \
192
+ CMD curl -fsS http://localhost:7861/health || exit 1
193
+
194
+ USER hermes
195
+ ENTRYPOINT ["/opt/huggingmes/start.sh"]
196
+
197
+
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 CHANGED
@@ -1,10 +1,292 @@
1
- ---
2
- title: Botgithub
3
- emoji: πŸ“š
4
- colorFrom: indigo
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
12
+ Run your own AI agent with a chat interface on Hugging Face Spaces β€” for free.
13
+
14
+ > **This is not original work.** It combines three great open-source projects into one easy-to-deploy package:
15
+ > - [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research β€” the AI brain
16
+ > - [Hermes WebUI](https://github.com/nesquena/hermes-webui) by @nesquena β€” the chat interface
17
+ > - [HuggingMes](https://github.com/somratpro/HuggingMes) by @somratpro β€” the Hugging Face wrapper
18
+
19
+ All credit goes to the original creators. This repo just wires them together.
20
+
21
+ ---
22
+
23
+ ## Quick Setup (5 minutes)
24
+
25
+ ### 1. Duplicate the Space
26
+
27
+ [![Duplicate this Space](https://huggingface.co/datasets/huggingface/badges/resolve/main/duplicate-this-space-xl.svg)](https://huggingface.co/spaces/f4b404/hermes?duplicate=true)
28
+
29
+ Click the badge above, name your space β†’ pick **CPU Basic (Free)** β†’ and keep it public(else the .hf.space urls won't work).
30
+
31
+ ### 2. Add Your Secrets
32
+
33
+ Go to **Settings β†’ Variables and secrets** in your new Space and add these:
34
+
35
+ | Secret | What It's For | How to Get It |
36
+ |--------|---------------|---------------|
37
+ | `GATEWAY_TOKEN` | Your password for logging into the chat | Make up any strong password |
38
+ | `HF_TOKEN` | Saves your chats and settings so they don't disappear | [Go here](https://huggingface.co/settings/tokens) β†’ Create new token β†’ Pick write|
39
+ | `CLOUDFLARE_WORKERS_TOKEN` | Keeps your Space awake and lets Telegram work | [Create a token here](https://dash.cloudflare.com/profile/api-tokens) choose **Edit Cloudflare Workers** template |
40
+
41
+ ### 3. Add an AI Provider
42
+
43
+ Your agent needs an AI model to talk to. Add one of these API keys as a secret (or configure later in the dashboard):
44
+
45
+ | Secret | Provider |
46
+ |--------|----------|
47
+ | `OPENAI_API_KEY` | OpenAI (GPT models) |
48
+ | `ANTHROPIC_API_KEY` | Anthropic (Claude models) |
49
+ | `MOONSHOT_API_KEY` | Moonshot / Kimi |
50
+
51
+ Or configure manually later at `/hm/app/config` inside your Space.
52
+
53
+ ### 4. Start It Up
54
+
55
+ Hit **Restart this Space** in Hugging Face. Wait 5–8 minutes for the first build.
56
+
57
+ When you see this in the Logs tab, you're ready:
58
+ ```
59
+ HuggingMes + Hermes WebUI router listening on 0.0.0.0:7861
60
+ ```
61
+
62
+ Open your Space URL (`https://your-name.hf.space`) in a **new tab**, enter your `GATEWAY_TOKEN`, and start chatting.
63
+ Open Hermes Dashboard from here (`https://f4b404-hermes.hf.space/hm/app`)
64
+
65
+
66
+ > **Pro tip:** Bookmark the direct `*.hf.space` URL β€” it works better on mobile than the Hugging Face embed.
67
+
68
+ ---
69
+
70
+ ## What You Get
71
+
72
+ | URL | What It Is |
73
+ |-----|------------|
74
+ | `/` | **Chat UI** β€” main interface for talking to your agent |
75
+ | `/hm` | Status dashboard β€” see what's running |
76
+ | `/hm/app/` | Settings β€” add AI models, set up cron jobs, manage profiles |
77
+ | `/v1/*` | API endpoint β€” connect other apps to your agent |
78
+ | `/telegram` | Telegram bot (if you added `TELEGRAM_BOT_TOKEN`) |
79
+
80
+ ---
81
+
82
+ ## Your Data Is Safe
83
+
84
+ When `HF_TOKEN` is set:
85
+ - All your chats, files, settings, and agent memory are backed up to a **private** Hugging Face Dataset within seconds of each change (change-driven, capped at 60 s)
86
+ - If the Space restarts, everything comes back exactly as you left it
87
+
88
+ ---
89
+
90
+ ## Common Issues
91
+
92
+ | Problem | Fix |
93
+ |---------|-----|
94
+ | Login keeps looping | Open the Space URL in a new tab (Hugging Face iframe blocks cookies) |
95
+ | Space goes to sleep after a few hours | Make sure `CLOUDFLARE_WORKERS_TOKEN` is set |
96
+ | Agent doesn't reply to questions | Check that you added an AI provider API key |
97
+ | Dashboard shows blank pages | Hard-refresh and clear service workers in browser dev tools |
98
+
99
+ ---
100
+
101
+ ## Want It on Your Phone?
102
+
103
+ Use the same (`https://your-name.hf.space`) url in android and then you can install it as Progressive Web App(PWA) or just use the same url on any browser for normal chat using the web.
104
+
105
+ ---
106
+
107
+ # πŸ”§ Advanced Setup & Technical Details
108
+
109
+ > **Skip this section if you just want to chat.** The steps above are enough to get started. This part is for developers, power users, and anyone who wants to customize or understand the internals.
110
+
111
+ ## Optional Secrets (Power Users)
112
+
113
+ | Secret | What It Does |
114
+ |--------|--------------|
115
+ | `CLOUDFLARE_ACCOUNT_ID` | Explicit Cloudflare account ID if you have multiple |
116
+ | `TELEGRAM_BOT_TOKEN` | Enables the Telegram bridge so you can chat with Hermes from Telegram |
117
+ | `TELEGRAM_ALLOWED_USERS` | Comma-separated numeric Telegram user IDs allowed to use the bot |
118
+ | `PRIMARY_UI` | Set to `dashboard` to make `/` show the HuggingMes status page instead of the chat UI. Default is `webui`. |
119
+ | `SYNC_INTERVAL` | Backup cadence in seconds (default 600, range 60–86400) |
120
+ | `HERMES_AGENT_VERSION` | Pin the upstream Hermes Agent base image to a specific tag for reproducibility (default `latest`) |
121
+ | `BACKUP_DATASET_NAME` | Name of the private HF Dataset used for persistence (default `huggingmes-backup`) |
122
+
123
+ ## Customizing the Agent Persona (SOUL.md)
124
+
125
+ The agent's personality and behavior is defined by `SOUL.md`. A default persona is included in the project and deployed on first boot.
126
+
127
+ **How it works:**
128
+ 1. On first boot, `SOUL.md` from the project is copied to `/opt/data/SOUL.md`
129
+ 2. Once deployed, it's backed up to your HF Dataset β€” edits persist across restarts
130
+ 3. The template is NOT re-deployed if `SOUL.md` already exists (your edits are safe)
131
+
132
+ **To customize before deploying:**
133
+ - Edit `SOUL.md` in your fork of this repo, then rebuild the Space
134
+
135
+ **To customize after deploying:**
136
+ - Edit `/opt/data/SOUL.md` via the dashboard's file editor, or SSH into the container
137
+ - Changes are saved to your HF Dataset automatically
138
+
139
+ ## Configure LLM Provider via Config Editor
140
+
141
+ > ### ⚠️ Provider keys go in HF Space Secrets, not the dashboard's Env tab
142
+ >
143
+ > The Hermes dashboard exposes an "Env" editor that writes to `/opt/data/.env`
144
+ > inside the container. **That file is *not* backed up to your HF Dataset.**
145
+ > On every Space sleep / rebuild the container's filesystem is wiped, the
146
+ > `.env` is gone, and your `OLLAMA_API_KEY` / `OPENROUTER_API_KEY` /
147
+ > `ANTHROPIC_API_KEY` / etc. disappear with it. The Space then 500s on the
148
+ > first chat with `Provider 'X' is set in config.yaml but no API key was
149
+ > found`.
150
+ >
151
+ > **Always add provider keys as HF Space Secrets** (Settings β†’ Variables and
152
+ > secrets β†’ New secret). HF injects them as env vars at boot, never writes
153
+ > them to disk on the Space, and they survive every restart.
154
+ >
155
+ > Use the dashboard's Env tab only for non-secret tweaks. The status page's
156
+ > Backup tile will show a yellow warning whenever it detects keys sitting in
157
+ > the ephemeral `.env` so you don't have to remember this on your own.
158
+ >
159
+ > If you accept the security tradeoff and want `.env` backed up anyway, set
160
+ > `SYNC_INCLUDE_ENV=1` as a Space Variable. The dataset is private, but a
161
+ > leak of that dataset URL is then a leak of every key in `.env`.
162
+
163
+ If you prefer not to add API keys as HF Secrets, you can configure providers directly in Hermes after the Space starts:
164
+
165
+ 1. Open `/hm/app/config` in your Space
166
+ 2. Add your provider under the `llm` section:
167
+
168
+ ```yaml
169
+ llm:
170
+ openai:
171
+ api_key: "${OPENAI_API_KEY}"
172
+ anthropic:
173
+ api_key: "${ANTHROPIC_API_KEY}"
174
+ moonshot:
175
+ api_key: "${MOONSHOT_API_KEY}"
176
+ base_url: "https://api.moonshot.cn/v1"
177
+ ```
178
+
179
+ If you set the API keys as HF Secrets, you can reference them with `${VAR_NAME}` as shown above. Hermes supports many providers β€” see the [Hermes Agent docs](https://github.com/NousResearch/hermes-agent) for the full list.
180
+
181
+ ## Using the API from Code
182
+
183
+ Your Space exposes an OpenAI-compatible API at `/v1/*`:
184
+
185
+ ```shell
186
+ curl https://<you>-<name>.hf.space/v1/chat/completions \
187
+ -H "Authorization: Bearer $GATEWAY_TOKEN" \
188
+ -H "Content-Type: application/json" \
189
+ -d '{
190
+ "model": "hermes",
191
+ "messages": [{"role": "user", "content": "hello"}]
192
+ }'
193
+ ```
194
+
195
+ ```python
196
+ from openai import OpenAI
197
+
198
+ client = OpenAI(
199
+ base_url="https://<you>-<name>.hf.space/v1",
200
+ api_key="<your GATEWAY_TOKEN>",
201
+ )
202
+ resp = client.chat.completions.create(
203
+ model="hermes",
204
+ messages=[{"role": "user", "content": "hello"}],
205
+ )
206
+ ```
207
+
208
+ ## Adding MCP Servers
209
+
210
+ MCP (Model Context Protocol) servers extend your agent's capabilities. Add them via the config editor at `/hm/app/config`:
211
+
212
+ ```yaml
213
+ mcp:
214
+ servers:
215
+ fetch:
216
+ command: uvx
217
+ args: ["mcp-server-fetch"]
218
+ filesystem:
219
+ command: npx
220
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/opt/data/workspace"]
221
+ ```
222
+
223
+ `uvx` and `npx` are pre-installed in the image.
224
+
225
+ ## Persistence Details
226
+
227
+ When `HF_TOKEN` is set:
228
+
229
+ * **On boot**, the Space downloads the latest snapshot from your private HF Dataset and restores it into `/opt/data/`.
230
+ * **Every `SYNC_INTERVAL` seconds** (default 600), it detects state changes and uploads a new snapshot.
231
+ * **On graceful shutdown** (SIGTERM), it does one final sync before exit.
232
+
233
+ What gets backed up: chat sessions, agent memory, workspace files, profiles, skills, cron jobs, Hermes config. The dataset is private to your HF account.
234
+
235
+ ## Architecture
236
+
237
+ Single port (7861) Node.js router fronts multiple backends:
238
+
239
+ ```
240
+ HF Space port 7861
241
+ β”‚
242
+ β–Ό
243
+ health-server.js (router + auth + status page)
244
+ β”‚
245
+ β”œβ”€β–Ί / β†’ Hermes WebUI (127.0.0.1:8787)
246
+ β”œβ”€β–Ί /hm β†’ HuggingMes status (in-process)
247
+ β”œβ”€β–Ί /hm/app/* β†’ Hermes dashboard (127.0.0.1:9119) [SPA-rewritten]
248
+ β”œβ”€β–Ί /v1/* β†’ Hermes gateway API (127.0.0.1:8642) [bearer auth]
249
+ β”œβ”€β–Ί /telegram β†’ Telegram webhook (127.0.0.1:8765)
250
+ └─► /health, /status β†’ in-process JSON
251
+ ```
252
+
253
+ `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.
254
+
255
+ ## Local Testing
256
+
257
+ ```shell
258
+ git clone https://github.com/F4bC0d3/huggingmes-hermes-webui.git
259
+ cd huggingmes-hermes-webui
260
+ cp .env.example .env
261
+ # edit .env with GATEWAY_TOKEN and provider API keys (e.g., OPENAI_API_KEY, ANTHROPIC_API_KEY)
262
+ docker build -t huggingmes-hermes-webui .
263
+ docker run --rm -p 7861:7861 --env-file .env huggingmes-hermes-webui
264
+ # open http://localhost:7861
265
+ ```
266
+
267
+ ## Extended Troubleshooting
268
+
269
+ | Symptom | Cause / Fix |
270
+ | --- | --- |
271
+ | Build fails on `nousresearch/hermes-agent:latest` | Set `HERMES_AGENT_VERSION` to a specific tag and restart |
272
+ | Container Running but `/` returns 502 | Hermes WebUI didn't bind. Check Logs tab for `webui.log` output β€” usually missing/wrong provider API key or LLM config |
273
+ | `/v1/*` returns 401 | Need `Authorization: Bearer <GATEWAY_TOKEN>` header |
274
+ | `/api/status` 404s in logs | Cosmetic β€” old browser tab polling. Ignored. |
275
+ | Login loops on `/login` | Browser embedded in HF iframe blocks cookies. Open the Space in a new tab. |
276
+ | 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 |
277
+ | Space sleeps after a few hours | Free tier limitation. Add `CLOUDFLARE_WORKERS_TOKEN` to provision a keep-alive cron worker |
278
+ | Telegram bot doesn't respond | HF Spaces blocks `api.telegram.org` egress. Add `CLOUDFLARE_WORKERS_TOKEN` to auto-provision an outbound proxy |
279
+ | Two Spaces overwriting each other's backup | Set different `BACKUP_DATASET_NAME` on each |
280
+ | Agent responds but cannot answer questions | No LLM provider configured. Add provider API keys and restart, or configure via `/hm/app/config` |
281
+
282
+ ## Credits
283
+
284
+ * **[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.
285
+ * **[@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.
286
+ * **[@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.
287
+
288
+ 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.
289
+
290
+ ## License
291
+
292
+ MIT β€” same as all upstream projects.
SOUL.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SYSTEM PROMPT: ATLAS - THE AUTONOMOUS GITHUB ARCHITECT
2
+
3
+ ## IDENTITY & PERSONA
4
+ You are **Atlas**, the Senior Autonomous GitHub Manager. You do not wait for instructions; you own the repository’s health, momentum, and code quality. You are "Technical," detailed, and direct. You view the repository as a living organism that requires proactive care and creative optimization.
5
+
6
+ ## CORE OPERATIONAL PRINCIPLES
7
+ - **Proactive Ownership**: You scan for issues, PRs, and repository drift before being asked.
8
+ - **Resilient Execution**: You handle API rate limits via `credential_pool` rotation and debug failing CI pipelines until they pass.
9
+ - **Autonomous Learning**: You use `skill_manage` to codify successful workflows into reusable skills and update `MEMORY.md` with project-specific lessons.
10
+ - **Creative Innovation**: You proactively suggest refactors, GitHub Action improvements, and updates to `AGENTS.md` to enhance team productivity.
11
+
12
+ ## TOOLBELT
13
+ - **GitHub Suite**: `github-auth`, `github-code-review`, `github-issues`, `github-pr-workflow`, `github-repo-management`.
14
+ - **Management**: `kanban` (for task tracking), `cron` (for scheduled audits), `todo`.
15
+ - **Orchestration**: `delegate_task` (to spawn specialized sub-agents for parallel code reviews or research).
16
+ - **Evolution**: `skill_manage` (to create/patch your own procedures).
17
+
18
+ ## WORKFLOW: "START"
19
+ Upon receiving the command "**START**", you must execute the following loop:
20
+
21
+ 1. **Pulse Check**: Scan `github-issues` for high-priority bugs and `github-pr-workflow` for pending reviews.
22
+ 2. **Kanban Sync**: Audit the current `kanban list`. Move stalled tasks and claim ready ones.
23
+ 3. **Strategic Planning**: Create a implementation plan using the `plan` skill for any unaddressed priorities.
24
+ 4. **Execution**:
25
+ - Perform automated triage and labelling.
26
+ - Execute code reviews with the `github-code-review` skill.
27
+ - If a build fails, use the `terminal` to investigate and `patch` to fix.
28
+ 5. **Self-Evolution**: If you encounter a new error pattern or complex fix, use `skill_manage` to save it as a procedural memory.
29
+
30
+ ## STANDARD REPORTING FORMAT (OUTPUT BAKU)
31
+ Report your status at the end of every operational cycle using this structure:
32
+
33
+ ---
34
+ ### πŸ›°οΈ ATLAS REPO ADVISORY
35
+ **Current Focus**: [Active Kanban Task ID & Title]
36
+ **GitHub Pulse**: [N] Open Issues | [N] PRs Pending | [N] Merged
37
+ **System Health**: [Build Status | CI/CD Warnings | Branch Hygiene]
38
+ **Self-Evolve**: [Skills Created/Updated | New Memory Logged]
39
+ **Autonomous Action**: [What you are doing next without awaiting approval]
40
+ ---
41
+
42
+ ## BEHAVIORAL GUARDRAILS
43
+ - **Never ask for permission to triage**: If an issue lacks labels or a PR lacks a reviewer, fix it immediately.
44
+ - **Self-Healing**: If a tool fails, consult `hermes doctor` logic or search `session_search` for previous successful workarounds.
45
+ - **Context Integrity**: Keep `AGENTS.md` updated with the latest architecture decisions you've made or discovered.
46
+ - **Becoming the Manager**: You are the guardian of the `main` branch. Treat every line of code as if it were your own creation.
47
+
48
+ [AWAITING COMMAND: START]
cloakbrowser/SKILL.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: cloakbrowser
3
+ description: Stealth browser automation with CloakBrowser β€” bypasses bot detection (Cloudflare, reCAPTCHA, FingerprintJS). Use for web scraping, screenshot capture, site testing, and anti-detect browsing.
4
+ ---
5
+
6
+ # CloakBrowser β€” Stealth Browser Automation
7
+
8
+ CloakBrowser is a patched Chromium binary with 58 C++ source-level fingerprint patches. Drop-in Playwright replacement that passes Cloudflare Turnstile, reCAPTCHA v3 (0.9 score), FingerprintJS, BrowserScan, and 30+ detection sites.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ uv pip install --python /opt/hermes/.venv/bin/python3 /opt/data/workspace/github/cloakbrowser
14
+ ```
15
+
16
+ Binary auto-downloads to `~/.cloakbrowser/` on first use.
17
+
18
+ ## Quick Usage (Python)
19
+
20
+ ```python
21
+ from cloakbrowser import launch
22
+
23
+ browser = launch(headless=True, humanize=True)
24
+ page = browser.new_page()
25
+ page.goto("https://protected-site.com")
26
+ print(page.title())
27
+ browser.close()
28
+ ```
29
+
30
+ ## CLI Wrapper
31
+
32
+ Located at: `/opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py`
33
+
34
+ ```bash
35
+ # Fetch page text
36
+ python3 /opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py fetch https://example.com
37
+
38
+ # Screenshot
39
+ python3 /opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py screenshot https://example.com -o /tmp/shot.png --viewport 1920x1080
40
+
41
+ # Extract structured content (title, links, meta, text)
42
+ python3 /opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py extract https://example.com
43
+
44
+ # Execute JS on page
45
+ python3 /opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py js https://example.com "document.title"
46
+
47
+ # Run stealth fingerprint test
48
+ python3 /opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py stealth-test
49
+
50
+ # Show binary info
51
+ python3 /opt/data/workspace/github/cloakbrowser/tools/cloak_browse.py info
52
+ ```
53
+
54
+ ## Key Launch Options
55
+
56
+ ```python
57
+ browser = launch(
58
+ headless=True, # Headless mode (default True)
59
+ humanize=True, # Human-like mouse/keyboard/scroll
60
+ proxy="http://user:pass@proxy:port", # Residential proxy
61
+ geoip=True, # Auto-detect timezone from proxy IP
62
+ args=["--remote-debugging-port=9242"], # Extra Chrome args
63
+ extension_paths=["/path/to/ext"], # Load Chrome extensions
64
+ )
65
+ ```
66
+
67
+ ## Integration Patterns
68
+
69
+ ### With Crawl4AI
70
+ ```python
71
+ from cloakbrowser import launch_async
72
+ cb = await launch_async(headless=True, args=["--remote-debugging-port=9243"])
73
+ # Connect Crawl4AI via CDP: http://127.0.0.1:9243
74
+ ```
75
+
76
+ ### With browser-use (AI agent)
77
+ ```python
78
+ from cloakbrowser import launch_async
79
+ cb = await launch_async(headless=True, args=["--remote-debugging-port=9242"])
80
+ # Connect browser-use via CDP: http://127.0.0.1:9242
81
+ ```
82
+
83
+ ### Persistent context (cookies survive restart)
84
+ ```python
85
+ from cloakbrowser import launch_persistent_context
86
+ ctx = launch_persistent_context("./my-profile", headless=False)
87
+ page = ctx.new_page()
88
+ page.goto("https://site.com")
89
+ ctx.close() # Cookies saved to ./my-profile
90
+ ```
91
+
92
+ ## Stealth Verified
93
+
94
+ - navigator.webdriver: False
95
+ - Real plugins (5), languages, platform
96
+ - WebGL: Google Inc. (NVIDIA) β€” real GPU vendor
97
+ - Canvas fingerprint: unique per session (random seed)
98
+ - Passes bot.incolumitas.com, BrowserScan, Cloudflare Turnstile
99
+
100
+ ## Hermes Integration (Default Browser)
101
+
102
+ CloakBrowser is configured as the default browser for Hermes via `agent-browser` CLI.
103
+
104
+ **Config file:** `/opt/data/.env`
105
+ ```
106
+ AGENT_BROWSER_EXECUTABLE_PATH=/opt/data/home/.cloakbrowser/chromium-146.0.7680.177.5/chrome
107
+ AGENT_BROWSER_ARGS=--no-sandbox,--fingerprint-platform=windows
108
+ AGENT_BROWSER_ENGINE=chrome
109
+ ```
110
+
111
+ **How it works:**
112
+ - Hermes browser tools (`browser_navigate`, `browser_click`, etc.) use `agent-browser` CLI
113
+ - `agent-browser` launches Chromium β€” `AGENT_BROWSER_EXECUTABLE_PATH` points it to CloakBrowser's stealth binary
114
+ - `AGENT_BROWSER_ARGS` passes fingerprint spoofing flags
115
+ - Result: all Hermes browser automation runs through stealth Chromium
116
+
117
+ **Verify integration:**
118
+ ```bash
119
+ # Check agent-browser uses CloakBrowser
120
+ export AGENT_BROWSER_EXECUTABLE_PATH=/opt/data/home/.cloakbrowser/chromium-146.0.7680.177.5/chrome
121
+ agent-browser --engine chrome --session test open "https://example.com"
122
+ agent-browser --session test eval "navigator.webdriver" # Should be false
123
+ agent-browser --session test close
124
+ ```
125
+
126
+ **Update binary path after CloakBrowser update:**
127
+ ```bash
128
+ python3 -c "from cloakbrowser.config import get_binary_path; print(get_binary_path())"
129
+ # Update AGENT_BROWSER_EXECUTABLE_PATH in /opt/data/.env with new path
130
+ ```
131
+
132
+ ## Pitfalls
133
+
134
+ 1. **Headless detection**: Some sites detect headless even with patches. Use `headless=False` for stubborn sites.
135
+ 2. **Datacenter IPs**: Residential proxy needed for sites that check IP reputation.
136
+ 3. **First run downloads ~120MB binary** to `~/.cloakbrowser/`. Subsequent runs use cache.
137
+ 4. **No pip**: Use `uv pip install --python /opt/hermes/.venv/bin/python3` instead of bare `pip`.
138
+ 5. **Playwright API**: CloakBrowser returns standard Playwright Browser/Context objects β€” same API.
139
+
140
+ ## Environment Variables
141
+
142
+ - `CLOAKBROWSER_BINARY_PATH` β€” Use custom binary (skip download)
143
+ - `CLOAKBROWSER_CACHE_DIR` β€” Custom cache directory
144
+ - `CLOAKBROWSER_AUTO_UPDATE=false` β€” Disable auto-update checks
145
+ - `CLOAKBROWSER_DOWNLOAD_URL` β€” Custom download mirror
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())
config.yaml.template ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ══════════════════════════════════════════════════════════════════════
2
+ # HuggingMes + Hermes WebUI β€” Configuration Template
3
+ # ══════════════════════════════════════════════════════════════════════
4
+ # This template is deployed to /opt/data/config.yaml in the HF Space.
5
+ # Environment variables from .env.local override values at boot time.
6
+ #
7
+ # Priority: Environment variables > This config > Hermes defaults
8
+ #
9
+ # Documentation: https://hermes-agent.nousresearch.com/docs/user-guide/configuration
10
+ # ══════════════════════════════════════════════════════════════════════
11
+
12
+ # ── Model Configuration ──────────────────────────────────────────────
13
+ # The LLM model and provider to use.
14
+ # Override via env: LLM_MODEL, HERMES_MODEL, CUSTOM_BASE_URL, CUSTOM_API_KEY
15
+ #
16
+ # Examples:
17
+ # openrouter/free β†’ OpenRouter free-tier models
18
+ # anthropic/claude-sonnet-4 β†’ Anthropic Claude
19
+ # openai/gpt-4o β†’ OpenAI GPT-4o
20
+ # deepseek/deepseek-chat β†’ DeepSeek
21
+ # google/gemini-2.0-flash β†’ Google Gemini
22
+ #
23
+ # For custom endpoints (e.g., Xiaomi MiMo, NVIDIA NIM):
24
+ # Set CUSTOM_BASE_URL and CUSTOM_API_KEY in .env.local
25
+ # The start.sh script will auto-configure model.base_url and model.api_key
26
+
27
+ model:
28
+ default: "mimo-v2.5-pro"
29
+ provider: "custom" # Using Xiaomi MiMo custom endpoint
30
+ # base_url: "" # Auto-set from CUSTOM_BASE_URL
31
+ # api_key: "" # Auto-set from CUSTOM_API_KEY
32
+ # context_length: 1000000 # Auto-set from CUSTOM_MODEL_CONTEXT_LENGTH
33
+ # max_tokens: 8192 # Auto-set from CUSTOM_MODEL_MAX_TOKENS
34
+
35
+ # ── Agent Behavior ────────────────────────────────────────────────────
36
+ agent:
37
+ max_turns: 90 # Maximum conversation turns per session
38
+ tool_use_enforcement: auto # auto, required, or off
39
+
40
+ # ── Terminal Configuration ────────────────────────────────────────────
41
+ # Working directory for shell commands and file operations.
42
+ # Override via env: MESSAGING_CWD
43
+
44
+ terminal:
45
+ backend: local # local, docker, or ssh
46
+ cwd: "/opt/data/workspace" # Default workspace directory
47
+ timeout: 180 # Command timeout in seconds
48
+
49
+ # ── Context Compression ──────────────────────────────────────────────
50
+ # Automatically compress long conversations to stay within token limits.
51
+
52
+ compression:
53
+ enabled: true
54
+ threshold: 0.50 # Trigger compression at 50% of context window
55
+ target_ratio: 0.20 # Compress to 20% of original size
56
+
57
+ # ── Display Settings ─────────────────────────────────────────────────
58
+ # UI and notification preferences.
59
+ # Override via env: HERMES_BACKGROUND_NOTIFICATIONS
60
+
61
+ display:
62
+ skin: default # UI theme (default, minimal, etc.)
63
+ tool_progress: true # Show tool execution progress
64
+ show_reasoning: false # Show model reasoning steps
65
+ show_cost: true # Show token cost per request
66
+ background_process_notifications: "result" # result, none, or progress
67
+
68
+ # ── Security Settings ────────────────────────────────────────────────
69
+ # CRITICAL: Always enable secret redaction to prevent credential leakage.
70
+
71
+ security:
72
+ redact_secrets: true # Auto-mask API keys in tool output
73
+ redact_pii: false # Auto-mask PII (phone numbers, emails)
74
+ tirith_enabled: false # Enable Tirith security scanner
75
+
76
+ # ── Approval Mode ─────────────────────────────────────────────────────
77
+ # Control how destructive commands are handled.
78
+ # Options: manual (always prompt), smart (LLM decides), off (no prompts)
79
+
80
+ approvals:
81
+ mode: smart # Recommended: smart for autonomous operation
82
+
83
+ # ── Memory Configuration ─────────────────────────────────────────────
84
+ # Persistent memory across sessions.
85
+
86
+ memory:
87
+ memory_enabled: true # Enable cross-session memory
88
+ user_profile_enabled: true # Enable user profile persistence
89
+ provider: builtin # builtin, honcho, mem0
90
+
91
+ # ── Checkpoints ──────────────────────────────────────────────────────
92
+ # Filesystem snapshots for rollback capability.
93
+
94
+ checkpoints:
95
+ enabled: true
96
+ max_snapshots: 10 # Maximum checkpoints to keep
97
+ auto_prune: true # Auto-cleanup old checkpoints
98
+
99
+ # ── Sessions ─────────────────────────────────────────────────────────
100
+ # Session management and cleanup.
101
+
102
+ sessions:
103
+ auto_prune: true # Auto-cleanup old sessions
104
+ prune_days: 3 # Delete sessions older than N days
105
+
106
+ # ── Model Catalog ────────────────────────────────────────────────────
107
+ # Disable to reduce context bloat.
108
+
109
+ model_catalog:
110
+ enabled: false # Set true to show model picker in UI
111
+
112
+ # ── Delegation (Subagents) ───────────────────────────────────────────
113
+ # Configuration for delegated subtasks.
114
+
115
+ delegation:
116
+ max_iterations: 50 # Max tool calls per subagent
117
+ max_spawn_depth: 1 # Max nesting depth (1 = no nesting)
118
+ max_concurrent_children: 3 # Max parallel subagents
119
+
120
+ # ── Platform Configuration ───────────────────────────────────────────
121
+ # Messaging platform integrations.
122
+ # Telegram is auto-configured from TELEGRAM_BOT_TOKEN env var.
123
+
124
+ platforms:
125
+ telegram:
126
+ enabled: false # Auto-enabled when TELEGRAM_BOT_TOKEN is set
127
+ # extra:
128
+ # base_url: "" # Auto-set from TELEGRAM_BASE_URL
129
+ # base_file_url: "" # Auto-set from TELEGRAM_BASE_FILE_URL
130
+
131
+ # Uncomment to enable other platforms:
132
+ # discord:
133
+ # enabled: true
134
+ # extra:
135
+ # bot_token: "${DISCORD_BOT_TOKEN}"
136
+ #
137
+ # slack:
138
+ # enabled: true
139
+ # extra:
140
+ # bot_token: "${SLACK_BOT_TOKEN}"
141
+
142
+ # ── Telegram Access Control ──────────────────────────────────────────
143
+ # Comma-separated user IDs allowed to interact with the bot.
144
+ # Override via env: TELEGRAM_ALLOWED_USERS, TELEGRAM_USER_ID
145
+
146
+ telegram:
147
+ allow_from: [] # Empty = allow all (not recommended for production)
148
+
149
+ # ── STT (Speech-to-Text) ─────────────────────────────────────────────
150
+ # Voice message transcription.
151
+
152
+ stt:
153
+ enabled: false # Enable voice transcription
154
+ provider: local # local, groq, openai, mistral
155
+ local:
156
+ model: base # tiny, base, small, medium, large-v3
157
+
158
+ # ── TTS (Text-to-Speech) ─────────────────────────────────────────────
159
+ # Voice response generation.
160
+
161
+ tts:
162
+ provider: edge # edge, elevenlabs, openai, minimax, mistral, neutts
163
+
164
+ # ── MCP Servers ──────────────────────────────────────────────────────
165
+ # Model Context Protocol servers for extended tool access.
166
+ # IMPORTANT: Use 'mcp_servers:' key, NOT 'mcp:'
167
+
168
+ mcp_servers:
169
+ cloudflare:
170
+ url: https://mcp.cloudflare.com/mcp
171
+ timeout: 120
172
+ headers:
173
+ Authorization: Bearer ${CLOUDFLARE_API_TOKEN}
174
+ n8n:
175
+ url: https://ruang101-n8n.hf.space/mcp-server/http
176
+ timeout: 120
177
+ headers:
178
+ Authorization: Bearer ${N8N_MCP_API_KEY}
179
+ tavily:
180
+ url: https://mcp.tavily.com/mcp/?tavilyApiKey=${TAVILY_API_KEY}
181
+ exa:
182
+ url: https://mcp.exa.ai/mcp?tools=web_search_exa,web_fetch_exa,web_search_advanced_exa
183
+ headers:
184
+ x-api-key: ${EXA_API_KEY2}
185
+ anysearch:
186
+ url: https://api.anysearch.com/mcp
187
+ headers:
188
+ Authorization: Bearer ${ANYSERCH_API_KEY}
189
+ google-maps-tools:
190
+ url: https://mapstools.googleapis.com/mcp
191
+ type: http
192
+
193
+ secrets:
194
+ bitwarden:
195
+ enabled: true
196
+ access_token_env: BWS_ACCESS_TOKEN
197
+ project_id: afa31408-cc79-4dfb-8a35-b4590142ba94
198
+ cache_ttl_seconds: 300
199
+ override_existing: true
200
+ auto_install: true
201
+ server_url: ''
202
+
203
+ # ── Toolsets ─────────────────────────────────────────────────────────
204
+ # Explicit tool list. Remove tools to reduce model schema size.
205
+ # Default: all tools enabled.
206
+
207
+ # toolsets:
208
+ # - hermes-cli
209
+ # - browser
210
+ # - file
211
+ # - terminal
212
+ # - code_execution
213
+ # - delegation
214
+ # - memory
215
+ # - session_search
216
+ # - skills
217
+ # - web
218
+ # - vision
219
+ # - image_gen
220
+ # - tts
221
+ # - cronjob
222
+ # - todo
223
+
224
+ # ── Auxiliary Models ─────────────────────────────────────────────────
225
+ # Models for vision, compression, session search, etc.
226
+
227
+ auxiliary:
228
+ vision:
229
+ provider: auto # auto, openrouter, google, etc.
230
+ model: "" # Leave empty for auto-detection
231
+ compression:
232
+ provider: auto
233
+ model: ""
234
+
235
+ # ── Cloudflare Integration ───────────────────────────────────────────
236
+ # For Telegram proxy and keepalive.
237
+ # Override via env: CLOUDFLARE_WORKERS_TOKEN, CLOUDFLARE_PROXY_URL
238
+
239
+ # cloudflare:
240
+ # proxy_url: "" # Auto-set from CLOUDFLARE_PROXY_URL
241
+ # keepalive_cron: "*/10 * * * *"
242
+
243
+ # ── Custom Provider Configuration ────────────────────────────────────
244
+ # For non-standard API endpoints (e.g., Xiaomi MiMo, NVIDIA NIM).
245
+ # These are auto-configured from env vars by start.sh.
246
+ #
247
+ # Environment variables:
248
+ # CUSTOM_BASE_URL β†’ model.base_url
249
+ # CUSTOM_API_KEY β†’ model.api_key
250
+ # CUSTOM_MODEL_CONTEXT_LENGTH β†’ model.context_length
251
+ # CUSTOM_MODEL_MAX_TOKENS β†’ model.max_tokens
252
+ # CUSTOM_PROVIDER β†’ model.provider (default: "custom")
253
+
254
+ # ── Advanced Settings ────────────────────────────────────────────────
255
+ # Uncomment and customize as needed.
256
+
257
+ # hooks:
258
+ # pre_tool_call: "" # Script to run before each tool call
259
+ # post_tool_call: "" # Script to run after each tool call
260
+
261
+ # logging:
262
+ # level: info # debug, info, warning, error
263
+ # file: "" # Log file path (empty = stdout only)
264
+
265
+ # ── HF Space Specific ────────────────────────────────────────────────
266
+ # These settings are optimized for Hugging Face Spaces deployment.
267
+
268
+ # Backup dataset for state persistence
269
+ # Override via env: BACKUP_DATASET_NAME
270
+ backup_dataset: "huggingmes-backup"
271
+
272
+ # Sync settings for HF Dataset backup
273
+ # Override via env: SYNC_INTERVAL, SYNC_POLL_INTERVAL, SYNC_DEBOUNCE_SECONDS
274
+ sync:
275
+ interval: 60 # Seconds between sync cycles
276
+ poll_interval: 2 # Seconds between change detection polls
277
+ debounce_seconds: 3 # Seconds to wait after last change
278
+
279
+ # ══════════════════════════════════════════════════════════════════════
280
+ # END OF CONFIGURATION
281
+ # ══════════════════════════════════════════════════════════════════════
health-server.js ADDED
@@ -0,0 +1,1007 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ * /hmd /hmd/* -> Hermes dashboard passthrough for off-Space
11
+ * workspaces (no router auth β€” dashboard's own
12
+ * session token gates writes; opt-in by URL)
13
+ * /dashboard -> redirect to /hm
14
+ * /v1 /v1/* -> Hermes gateway (bearer auth; HTML => login redirect)
15
+ * /telegram /telegram/*-> Telegram webhook (unauthenticated; Telegram needs to reach it)
16
+ * everything else -> Hermes WebUI (nesquena/hermes-webui) as the primary UI
17
+ * WebUI handles its own login at /login-... no, wait: WebUI
18
+ * also exposes /login. We keep HuggingMes' login at /login
19
+ * so the shared GATEWAY_TOKEN gates both.
20
+ *
21
+ * Based on github.com/somratpro/HuggingMes with added WebUI routing as the
22
+ * primary UI.
23
+ */
24
+
25
+ const http = require("http");
26
+ const fs = require("fs");
27
+ const net = require("net");
28
+ const crypto = require("crypto");
29
+
30
+ const PORT = Number(process.env.PORT || 7861);
31
+ const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
32
+ const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
33
+ const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
34
+ const WEBUI_PORT = Number(process.env.HERMES_WEBUI_PORT || 8787);
35
+ const GATEWAY_HOST = "127.0.0.1";
36
+ const startTime = Date.now();
37
+ const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
38
+ const HM_PREFIX = "/hm";
39
+ // Dashboard passthrough for off-Space workspaces (e.g. hermes-workspace
40
+ // running on a laptop). Anything under /hmd/* is forwarded directly to the
41
+ // internal dashboard with no router-level auth β€” the dashboard's own
42
+ // ephemeral session token is the only gate. This is intentional: the
43
+ // workspace scrapes that token from /hmd/ and then sends it as the bearer
44
+ // on /hmd/api/* requests, exactly mirroring the dashboard's normal flow.
45
+ //
46
+ // Implication: anyone who can reach this Space's URL can call the dashboard
47
+ // API (sessions, skills, config). If you don't need remote workspace access,
48
+ // don't share the Space URL or set up an upstream auth layer.
49
+ const HMD_PREFIX = "/hmd";
50
+ const LOGIN_PATH = "/hm/login";
51
+ const SESSION_COOKIE = "huggingmes_session";
52
+ const PRIMARY_UI = (process.env.PRIMARY_UI || "webui").toLowerCase();
53
+
54
+ const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
55
+ const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
56
+ "/tmp/huggingmes-cloudflare-keepalive-status.json";
57
+
58
+ /* ── Port probing + auth ──────────────────────────────────────────── */
59
+
60
+ function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
61
+ return new Promise((resolve) => {
62
+ const socket = net.createConnection({ port, host });
63
+ const done = (ok) => {
64
+ socket.removeAllListeners();
65
+ socket.destroy();
66
+ resolve(ok);
67
+ };
68
+ socket.setTimeout(timeoutMs);
69
+ socket.once("connect", () => done(true));
70
+ socket.once("timeout", () => done(false));
71
+ socket.once("error", () => done(false));
72
+ });
73
+ }
74
+
75
+ function readJson(path, fallback = null) {
76
+ try {
77
+ if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
78
+ } catch {}
79
+ return fallback;
80
+ }
81
+
82
+ function timingSafeEqualString(left, right) {
83
+ if (!left || !right) return false;
84
+ const a = Buffer.from(left);
85
+ const b = Buffer.from(right);
86
+ if (a.length !== b.length) return false;
87
+ return crypto.timingSafeEqual(a, b);
88
+ }
89
+
90
+ function expectedSessionValue() {
91
+ if (!API_SERVER_KEY) return "";
92
+ return crypto
93
+ .createHmac("sha256", API_SERVER_KEY)
94
+ .update("huggingmes-session-v1")
95
+ .digest("hex");
96
+ }
97
+
98
+ function parseCookies(req) {
99
+ const header = req.headers.cookie || "";
100
+ const cookies = {};
101
+ for (const item of header.split(";")) {
102
+ const sep = item.indexOf("=");
103
+ if (sep < 0) continue;
104
+ const name = item.slice(0, sep).trim();
105
+ const value = item.slice(sep + 1).trim();
106
+ if (!name) continue;
107
+ try {
108
+ cookies[name] = decodeURIComponent(value);
109
+ } catch {
110
+ cookies[name] = value;
111
+ }
112
+ }
113
+ return cookies;
114
+ }
115
+
116
+ function isHttpsRequest(req) {
117
+ return req.headers["x-forwarded-proto"] === "https";
118
+ }
119
+
120
+ function buildSessionCookie(req) {
121
+ const secure = isHttpsRequest(req) ? "; Secure" : "";
122
+ return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
123
+ }
124
+
125
+ function getBearerToken(req) {
126
+ const value = req.headers.authorization || "";
127
+ const match = /^Bearer\s+(.+)$/i.exec(value);
128
+ return match ? match[1] : "";
129
+ }
130
+
131
+ function isAuthorized(req) {
132
+ if (!API_SERVER_KEY) return true;
133
+ return (
134
+ timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
135
+ timingSafeEqualString(
136
+ parseCookies(req)[SESSION_COOKIE],
137
+ expectedSessionValue(),
138
+ )
139
+ );
140
+ }
141
+
142
+ function sanitizeNext(value, fallback = "/") {
143
+ if (!value || typeof value !== "string") return fallback;
144
+ if (!value.startsWith("/") || value.startsWith("//")) return fallback;
145
+ return value;
146
+ }
147
+
148
+ function loginUrl(nextPath) {
149
+ return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
150
+ }
151
+
152
+ function wantsHtml(req) {
153
+ const accept = String(req.headers.accept || "");
154
+ return accept.includes("text/html");
155
+ }
156
+
157
+ function escapeHtml(value) {
158
+ return String(value)
159
+ .replace(/&/g, "&amp;")
160
+ .replace(/</g, "&lt;")
161
+ .replace(/>/g, "&gt;")
162
+ .replace(/"/g, "&quot;");
163
+ }
164
+
165
+ function readRequestBody(req, limit = 64 * 1024) {
166
+ return new Promise((resolve, reject) => {
167
+ let body = "";
168
+ req.on("data", (chunk) => {
169
+ body += chunk;
170
+ if (body.length > limit) {
171
+ reject(new Error("Request body is too large."));
172
+ req.destroy();
173
+ }
174
+ });
175
+ req.on("end", () => resolve(body));
176
+ req.on("error", reject);
177
+ });
178
+ }
179
+
180
+ /* ── Login page ───────────────────────────────────────────────────── */
181
+
182
+ function renderLoginPage(nextPath, errorMessage = "") {
183
+ const safeNext = sanitizeNext(nextPath, "/");
184
+ const errorHtml = errorMessage
185
+ ? `<div class="error">${escapeHtml(errorMessage)}</div>`
186
+ : "";
187
+ return `<!doctype html>
188
+ <html lang="en">
189
+ <head>
190
+ <meta charset="utf-8" />
191
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
192
+ <title>HuggingMes + Hermes WebUI β€” Login</title>
193
+ <style>
194
+ :root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --bad:#ef4444; --accent:#38bdf8; }
195
+ * { box-sizing:border-box; }
196
+ 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; }
197
+ main { width:min(440px, 100%); border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:28px; }
198
+ h1 { margin:0 0 8px; font-size:1.55rem; }
199
+ p { margin:0 0 22px; color:var(--muted); line-height:1.5; }
200
+ label { display:block; color:var(--muted); font-size:.82rem; margin-bottom:8px; }
201
+ input { width:100%; min-height:46px; border:1px solid var(--line); border-radius:7px; background:#0b0f18; color:var(--text); padding:0 12px; font:inherit; }
202
+ 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; }
203
+ .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; }
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <main>
208
+ <h1>HuggingMes Admin</h1>
209
+ <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>
210
+ ${errorHtml}
211
+ <form method="post" action="${LOGIN_PATH}">
212
+ <input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
213
+ <label for="token">GATEWAY_TOKEN</label>
214
+ <input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
215
+ <button type="submit">Continue</button>
216
+ </form>
217
+ </main>
218
+ </body>
219
+ </html>`;
220
+ }
221
+
222
+ async function handleLogin(req, res, parsed) {
223
+ const nextPath = sanitizeNext(parsed.searchParams.get("next") || "/", "/");
224
+
225
+ if (!API_SERVER_KEY) {
226
+ redirect(res, nextPath);
227
+ return;
228
+ }
229
+
230
+ if (req.method === "GET") {
231
+ res.writeHead(200, {
232
+ "content-type": "text/html; charset=utf-8",
233
+ "cache-control": "no-store",
234
+ });
235
+ res.end(renderLoginPage(nextPath));
236
+ return;
237
+ }
238
+
239
+ if (req.method !== "POST") {
240
+ res.writeHead(405, { allow: "GET, POST" });
241
+ res.end("Method not allowed");
242
+ return;
243
+ }
244
+
245
+ try {
246
+ const body = await readRequestBody(req);
247
+ const params = new URLSearchParams(body);
248
+ const submittedToken = params.get("token") || "";
249
+ const submittedNext = sanitizeNext(params.get("next") || nextPath, "/");
250
+
251
+ if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
252
+ res.writeHead(401, {
253
+ "content-type": "text/html; charset=utf-8",
254
+ "cache-control": "no-store",
255
+ });
256
+ res.end(
257
+ renderLoginPage(
258
+ submittedNext,
259
+ "That token did not match GATEWAY_TOKEN.",
260
+ ),
261
+ );
262
+ return;
263
+ }
264
+
265
+ res.writeHead(302, {
266
+ location: submittedNext,
267
+ "set-cookie": buildSessionCookie(req),
268
+ "cache-control": "no-store",
269
+ });
270
+ res.end();
271
+ } catch (error) {
272
+ res.writeHead(400, {
273
+ "content-type": "text/plain; charset=utf-8",
274
+ "cache-control": "no-store",
275
+ });
276
+ res.end(error.message || "Invalid login request.");
277
+ }
278
+ }
279
+
280
+ function requireAuth(req, res) {
281
+ if (isAuthorized(req)) return true;
282
+ const parsed = new URL(req.url, "http://localhost");
283
+ redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
284
+ return false;
285
+ }
286
+
287
+ /* ── Upstream proxy ────────────────────────────────────────────────── */
288
+
289
+ function proxyRequest(
290
+ req,
291
+ res,
292
+ targetPort,
293
+ rewritePath = (path) => path,
294
+ headerOverrides = {},
295
+ ) {
296
+ const parsed = new URL(req.url, "http://localhost");
297
+ const targetPath = rewritePath(parsed.pathname) + parsed.search;
298
+ const headers = {
299
+ ...req.headers,
300
+ ...headerOverrides,
301
+ host: `${GATEWAY_HOST}:${targetPort}`,
302
+ "x-forwarded-host": req.headers.host || "",
303
+ "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
304
+ };
305
+
306
+ const proxy = http.request(
307
+ {
308
+ hostname: GATEWAY_HOST,
309
+ port: targetPort,
310
+ method: req.method,
311
+ path: targetPath,
312
+ headers,
313
+ },
314
+ (upstream) => {
315
+ res.writeHead(upstream.statusCode || 502, upstream.headers);
316
+ upstream.pipe(res);
317
+ },
318
+ );
319
+
320
+ proxy.on("error", (error) => {
321
+ res.writeHead(502, { "content-type": "application/json" });
322
+ res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
323
+ });
324
+
325
+ req.pipe(proxy);
326
+ }
327
+
328
+ function redirect(res, location, statusCode = 302) {
329
+ res.writeHead(statusCode, { location });
330
+ res.end();
331
+ }
332
+
333
+ /* ── Dashboard SPA proxy with HTML rewriting ──────────────────────────
334
+ *
335
+ * The Hermes dashboard is a Vite React app built for root-path deployment.
336
+ * Its HTML hardcodes window.__HERMES_BASE_PATH__="" and absolute src/href
337
+ * paths like /assets/index-XXX.js. Under /hm/app, React's router wouldn't
338
+ * know its basename and client-side routes (/config, /sessions, etc.) 404
339
+ * on refresh.
340
+ *
341
+ * This proxy:
342
+ * - serves the dashboard's index.html for any non-asset /hm/app/* path
343
+ * (SPA fallback, so /config, /profiles etc. work on direct load)
344
+ * - rewrites the returned HTML so React router uses /hm/app as its
345
+ * basename and absolute asset paths get prefixed with /hm/app
346
+ */
347
+ function proxyDashboard(req, res, basePrefix = HM_PREFIX + "/app") {
348
+ const parsed = new URL(req.url, "http://localhost");
349
+ const inner = parsed.pathname.replace(basePrefix, "") || "/";
350
+
351
+ const isAssetLike =
352
+ inner.startsWith("/assets/") ||
353
+ inner.startsWith("/api/") ||
354
+ inner.startsWith("/dashboard-plugins/") ||
355
+ inner.startsWith("/ds-assets/") ||
356
+ /\.[a-z0-9]{1,6}$/i.test(inner);
357
+
358
+ // SPA routes β†’ serve index.html; everything else β†’ forward as-is.
359
+ const targetPath =
360
+ (isAssetLike || inner === "/" ? inner : "/") + parsed.search;
361
+
362
+ const headers = {
363
+ ...req.headers,
364
+ host: `${GATEWAY_HOST}:${DASHBOARD_PORT}`,
365
+ "x-forwarded-host": req.headers.host || "",
366
+ "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
367
+ // Disable upstream compression so we can rewrite text responses.
368
+ "accept-encoding": "identity",
369
+ };
370
+
371
+ const upstream = http.request(
372
+ {
373
+ hostname: GATEWAY_HOST,
374
+ port: DASHBOARD_PORT,
375
+ method: req.method,
376
+ path: targetPath,
377
+ headers,
378
+ },
379
+ (upRes) => {
380
+ const contentType = String(upRes.headers["content-type"] || "");
381
+ const shouldRewrite =
382
+ contentType.includes("text/html") ||
383
+ contentType.includes("application/xhtml");
384
+
385
+ if (!shouldRewrite) {
386
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
387
+ upRes.pipe(res);
388
+ return;
389
+ }
390
+
391
+ const chunks = [];
392
+ upRes.on("data", (chunk) => chunks.push(chunk));
393
+ upRes.on("end", () => {
394
+ let body = Buffer.concat(chunks).toString("utf8");
395
+
396
+ // Tell the React router its basename.
397
+ body = body.replace(
398
+ /window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"/g,
399
+ `window.__HERMES_BASE_PATH__="${basePrefix}"`,
400
+ );
401
+
402
+ // Prefix absolute asset URLs so they stay under the base path.
403
+ const prefix = basePrefix;
404
+ body = body.replace(
405
+ /\b(src|href)="\/(?!\/|http)([^"]*)"/g,
406
+ (match, attr, rest) => {
407
+ if (
408
+ ("/" + rest).startsWith(prefix + "/") ||
409
+ "/" + rest === prefix
410
+ ) {
411
+ return match;
412
+ }
413
+ return `${attr}="${prefix}/${rest}"`;
414
+ },
415
+ );
416
+
417
+ const buf = Buffer.from(body, "utf8");
418
+ const outHeaders = { ...upRes.headers };
419
+ delete outHeaders["content-length"];
420
+ delete outHeaders["transfer-encoding"];
421
+ delete outHeaders["content-encoding"];
422
+ outHeaders["content-length"] = String(buf.length);
423
+
424
+ res.writeHead(upRes.statusCode || 502, outHeaders);
425
+ res.end(buf);
426
+ });
427
+ upRes.on("error", () => {
428
+ try {
429
+ res.writeHead(502);
430
+ res.end();
431
+ } catch {}
432
+ });
433
+ },
434
+ );
435
+
436
+ upstream.on("error", (error) => {
437
+ res.writeHead(502, { "content-type": "application/json" });
438
+ res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
439
+ });
440
+
441
+ req.pipe(upstream);
442
+ }
443
+
444
+ /* ── Status JSON + HuggingMes status page ─────────────────────────── */
445
+
446
+ function formatUptime(ms) {
447
+ const total = Math.floor(ms / 1000);
448
+ const days = Math.floor(total / 86400);
449
+ const hours = Math.floor((total % 86400) / 3600);
450
+ const minutes = Math.floor((total % 3600) / 60);
451
+ if (days) return `${days}d ${hours}h ${minutes}m`;
452
+ if (hours) return `${hours}h ${minutes}m`;
453
+ return `${minutes}m`;
454
+ }
455
+
456
+ async function statusPayload() {
457
+ const gateway = await canConnect(GATEWAY_PORT);
458
+ const dashboard = await canConnect(DASHBOARD_PORT);
459
+ const webui = await canConnect(WEBUI_PORT);
460
+ const telegramWebhook =
461
+ !!process.env.TELEGRAM_WEBHOOK_URL &&
462
+ (await canConnect(TELEGRAM_WEBHOOK_PORT));
463
+ const sync = readJson(
464
+ SYNC_STATUS_FILE,
465
+ process.env.HF_TOKEN
466
+ ? { status: "configured", message: "Backup enabled; waiting for first sync." }
467
+ : { status: "disabled", message: "HF_TOKEN is not configured." },
468
+ );
469
+
470
+ return {
471
+ ok: gateway && webui,
472
+ uptime: formatUptime(Date.now() - startTime),
473
+ startedAt: new Date(startTime).toISOString(),
474
+ gateway,
475
+ dashboard,
476
+ webui,
477
+ authConfigured: !!API_SERVER_KEY,
478
+ primaryUi: PRIMARY_UI,
479
+ ports: {
480
+ public: PORT,
481
+ gateway: GATEWAY_PORT,
482
+ dashboard: DASHBOARD_PORT,
483
+ webui: WEBUI_PORT,
484
+ telegramWebhook: TELEGRAM_WEBHOOK_PORT,
485
+ },
486
+ telegram: {
487
+ configured: !!process.env.TELEGRAM_BOT_TOKEN,
488
+ webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
489
+ webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
490
+ webhookListening: telegramWebhook,
491
+ proxy: process.env.CLOUDFLARE_PROXY_URL || "",
492
+ },
493
+ model:
494
+ process.env.MODEL_FOR_CONFIG ||
495
+ process.env.HERMES_MODEL ||
496
+ process.env.LLM_MODEL ||
497
+ "",
498
+ provider:
499
+ process.env.PROVIDER_FOR_CONFIG ||
500
+ process.env.HERMES_INFERENCE_PROVIDER ||
501
+ "auto",
502
+ backup: sync,
503
+ keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null),
504
+ };
505
+ }
506
+
507
+ function toneBadge(label, tone = "neutral") {
508
+ return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
509
+ }
510
+
511
+ function valueOrUnset(value, fallback = "Not set") {
512
+ return value
513
+ ? escapeHtml(value)
514
+ : `<span class="muted">${escapeHtml(fallback)}</span>`;
515
+ }
516
+
517
+ function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) {
518
+ return `<article class="tile ${tone}">
519
+ <div class="tile-head">
520
+ <span class="tile-title">${escapeHtml(title)}</span>
521
+ <span class="tile-dot"></span>
522
+ </div>
523
+ <div class="tile-value">${value}</div>
524
+ ${detail ? `<div class="tile-detail">${detail}</div>` : ""}
525
+ ${meta ? `<div class="tile-meta">${meta}</div>` : ""}
526
+ </article>`;
527
+ }
528
+
529
+ function renderStatusPage(data) {
530
+ const syncStatus = String(data.backup?.status || "unknown");
531
+ const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus)
532
+ ? "ok"
533
+ : syncStatus === "disabled"
534
+ ? "warn"
535
+ : "neutral";
536
+ const telegramTone = data.telegram.configured
537
+ ? data.telegram.webhookListening || !data.telegram.webhook
538
+ ? "ok"
539
+ : "warn"
540
+ : "warn";
541
+ const keepaliveConfigured = data.keepalive?.configured === true;
542
+ const keepaliveStatus = String(
543
+ data.keepalive?.status ||
544
+ (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
545
+ );
546
+ const keepAliveTone = keepaliveConfigured
547
+ ? "ok"
548
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
549
+ ? "warn"
550
+ : "neutral";
551
+ const telegramDetail = data.telegram.configured
552
+ ? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}`
553
+ : "Not configured";
554
+ const backupDetail = data.backup?.message
555
+ ? escapeHtml(data.backup.message)
556
+ : "No status yet";
557
+ // Extra one-line warning row for known-loud failure modes (currently:
558
+ // ephemeral .env on a Space). hermes-sync.py emits this via warning.message.
559
+ const backupWarning = data.backup?.warning?.message
560
+ ? `<div class="tile-warning">${escapeHtml(data.backup.warning.message)}</div>`
561
+ : "";
562
+ const keepAliveDetail = keepaliveConfigured
563
+ ? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
564
+ : keepaliveStatus === "error" && data.keepalive?.message
565
+ ? escapeHtml(data.keepalive.message)
566
+ : process.env.CLOUDFLARE_WORKERS_TOKEN
567
+ ? "Worker pending or failed"
568
+ : "Not configured";
569
+
570
+ const tiles = [
571
+ renderTile({
572
+ title: "WebUI",
573
+ value: toneBadge(data.webui ? "Online" : "Offline", data.webui ? "ok" : "off"),
574
+ detail: data.webui ? `Port ${data.ports.webui}` : "Unreachable",
575
+ tone: data.webui ? "ok" : "off",
576
+ }),
577
+ renderTile({
578
+ title: "Gateway",
579
+ value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"),
580
+ detail: data.gateway ? `API on port ${data.ports.gateway}` : "Unreachable",
581
+ tone: data.gateway ? "ok" : "off",
582
+ meta: data.authConfigured ? "Protected" : "Unprotected",
583
+ }),
584
+ renderTile({
585
+ title: "Model",
586
+ value: `<code>${valueOrUnset(data.model)}</code>`,
587
+ detail: `Provider: ${valueOrUnset(data.provider || "auto")}`,
588
+ tone: data.model ? "ok" : "warn",
589
+ }),
590
+ renderTile({
591
+ title: "Runtime",
592
+ value: escapeHtml(data.uptime),
593
+ detail: `Port ${data.ports.public}`,
594
+ tone: "neutral",
595
+ }),
596
+ renderTile({
597
+ title: "Telegram",
598
+ value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone),
599
+ detail: telegramDetail,
600
+ tone: telegramTone,
601
+ }),
602
+ renderTile({
603
+ title: "Backup",
604
+ value: toneBadge(syncStatus.toUpperCase(), data.backup?.warning ? "warn" : syncTone),
605
+ detail: backupDetail + backupWarning,
606
+ tone: data.backup?.warning ? "warn" : syncTone,
607
+ meta: data.backup?.timestamp
608
+ ? `<span class="local-time" data-iso="${data.backup.timestamp}"></span>`
609
+ : "",
610
+ }),
611
+ renderTile({
612
+ title: "Keep Awake",
613
+ value: toneBadge(
614
+ keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
615
+ keepAliveTone,
616
+ ),
617
+ detail: keepAliveDetail,
618
+ tone: keepAliveTone,
619
+ }),
620
+ ].join("");
621
+
622
+ return `<!doctype html>
623
+ <html lang="en">
624
+ <head>
625
+ <meta charset="utf-8" />
626
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
627
+ <title>HuggingMes + Hermes WebUI</title>
628
+ <style>
629
+ :root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; }
630
+ * { box-sizing:border-box; }
631
+ 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; }
632
+ main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
633
+ header { text-align:center; margin-bottom:22px; }
634
+ h1 { margin:0; font-size:1.65rem; }
635
+ .subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
636
+ .row { display:flex; gap:10px; margin:24px 0 20px; flex-wrap:wrap; }
637
+ .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; }
638
+ .hero-action.secondary { background:#232234; color:var(--text); border:1px solid var(--line); }
639
+ .hero-action:hover { opacity:.9; }
640
+ .overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
641
+ .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; }
642
+ .tile.ok { border-color:rgba(34,197,94,.22); }
643
+ .tile.warn { border-color:rgba(245,197,66,.24); }
644
+ .tile.off { border-color:rgba(251,113,133,.28); }
645
+ .tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
646
+ .tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
647
+ .tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
648
+ .tile.ok .tile-dot { background:var(--good); }
649
+ .tile.warn .tile-dot { background:var(--warn); }
650
+ .tile.off .tile-dot { background:var(--bad); }
651
+ .tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
652
+ .tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
653
+ .tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
654
+ .tile-warning { color:#fde68a; background:rgba(245,158,11,.08); border:1px solid rgba(245,158,11,.32); border-radius:6px; padding:6px 8px; margin-top:6px; font-size:.78rem; line-height:1.4; }
655
+ code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
656
+ .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; }
657
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
658
+ .badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
659
+ .badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
660
+ .badge.neutral { color:var(--soft); }
661
+ .muted { color:var(--muted); }
662
+ footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
663
+ @media (max-width: 700px) { .overview { grid-template-columns:1fr; } }
664
+ </style>
665
+ </head>
666
+ <body>
667
+ <main>
668
+ <header>
669
+ <h1>HuggingMes + Hermes WebUI</h1>
670
+ <div class="subtitle">Self-hosted Hermes Agent on HF Spaces</div>
671
+ </header>
672
+ <div class="row">
673
+ <a class="hero-action" href="/" target="_blank" rel="noopener">Open Hermes WebUI -&gt;</a>
674
+ <a class="hero-action secondary" href="${HM_PREFIX}/app/" target="_blank" rel="noopener">Open Hermes Dashboard</a>
675
+ </div>
676
+ <section class="overview">
677
+ ${tiles}
678
+ </section>
679
+ <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>
680
+ </main>
681
+ <script>
682
+ document.querySelectorAll('.local-time').forEach(el => {
683
+ const date = new Date(el.getAttribute('data-iso'));
684
+ if (!isNaN(date)) el.textContent = 'At ' + date.toLocaleTimeString();
685
+ });
686
+ </script>
687
+ </body>
688
+ </html>`;
689
+ }
690
+
691
+ /* ── Server ───────────────────────────────────────────────────────── */
692
+
693
+ const server = http.createServer(async (req, res) => {
694
+ const parsed = new URL(req.url, "http://localhost");
695
+ const path = parsed.pathname;
696
+
697
+ // 1. /hm/login β€” HuggingMes admin login (cookie-based, gates /hm/*).
698
+ // hermes-webui handles its own /login at the catch-all below.
699
+ if (path === LOGIN_PATH) {
700
+ await handleLogin(req, res, parsed);
701
+ return;
702
+ }
703
+
704
+ // 2. /health β€” unauthenticated; HF Spaces probes + Cloudflare keepalive.
705
+ if (path === "/health") {
706
+ const data = await statusPayload();
707
+ res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
708
+ res.end(
709
+ JSON.stringify({
710
+ ok: data.ok,
711
+ gateway: data.gateway,
712
+ webui: data.webui,
713
+ uptime: data.uptime,
714
+ }),
715
+ );
716
+ return;
717
+ }
718
+
719
+ // 3. /status β€” unauthenticated JSON status dump.
720
+ if (path === "/status" || path === "/api/status") {
721
+ const data = await statusPayload();
722
+ res.writeHead(200, { "content-type": "application/json" });
723
+ res.end(JSON.stringify(data, null, 2));
724
+ return;
725
+ }
726
+
727
+ // 4. /telegram β€” webhook endpoint; no auth (Telegram can't do our cookie).
728
+ if (path === "/telegram" || path.startsWith("/telegram/")) {
729
+ proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
730
+ return;
731
+ }
732
+
733
+ // 5. /v1/* β€” Hermes gateway OpenAI-compatible API.
734
+ if (path === "/v1" || path.startsWith("/v1/")) {
735
+ if (!isAuthorized(req)) {
736
+ if (wantsHtml(req)) {
737
+ redirect(res, loginUrl(`${path}${parsed.search}`));
738
+ return;
739
+ }
740
+ res.writeHead(401, {
741
+ "content-type": "application/json",
742
+ "cache-control": "no-store",
743
+ });
744
+ res.end(
745
+ JSON.stringify({
746
+ error: "unauthorized",
747
+ message: "Use Authorization: Bearer <GATEWAY_TOKEN>.",
748
+ }),
749
+ );
750
+ return;
751
+ }
752
+ const upstreamHeaders =
753
+ getBearerToken(req) || !API_SERVER_KEY
754
+ ? {}
755
+ : { authorization: `Bearer ${API_SERVER_KEY}` };
756
+ proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders);
757
+ return;
758
+ }
759
+
760
+ // 6. /hm β€” HuggingMes status page.
761
+ if (path === HM_PREFIX || path === `${HM_PREFIX}/`) {
762
+ if (!requireAuth(req, res)) return;
763
+ const data = await statusPayload();
764
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
765
+ res.end(renderStatusPage(data));
766
+ return;
767
+ }
768
+
769
+ // /hmd/* β€” Off-Space dashboard passthrough.
770
+ //
771
+ // Forwards verbatim to the internal Hermes dashboard on DASHBOARD_PORT,
772
+ // including its /api/* endpoints, /assets/*, root HTML (which carries the
773
+ // ephemeral session token), and WebSocket upgrades. Workspace clients
774
+ // (e.g. hermes-workspace) point HERMES_DASHBOARD_URL at
775
+ // https://<space>/hmd
776
+ // and the workspace's own scrape-the-token-from-root-HTML logic just
777
+ // works because /hmd/ returns the unmodified dashboard index.
778
+ //
779
+ // SECURITY: this prefix has no router-level auth on purpose β€” the
780
+ // dashboard's own session token gates writes. If you need an extra layer,
781
+ // wrap your Space behind a Cloudflare Access policy or remove this
782
+ // handler.
783
+ if (path === HMD_PREFIX || path.startsWith(`${HMD_PREFIX}/`)) {
784
+ proxyDashboard(req, res, HMD_PREFIX);
785
+ return;
786
+ }
787
+
788
+ // /hm/app/* -> Hermes dashboard (SPA with HTML rewriting for base path)
789
+ if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
790
+ if (!requireAuth(req, res)) return;
791
+ proxyDashboard(req, res);
792
+ return;
793
+ }
794
+
795
+ // /hm/status -> JSON
796
+ if (path === `${HM_PREFIX}/status`) {
797
+ if (!requireAuth(req, res)) return;
798
+ const data = await statusPayload();
799
+ res.writeHead(200, { "content-type": "application/json" });
800
+ res.end(JSON.stringify(data, null, 2));
801
+ return;
802
+ }
803
+
804
+ // Legacy /dashboard -> /hm
805
+ if (path === "/dashboard" || path === "/dashboard/") {
806
+ redirect(res, `${HM_PREFIX}${parsed.search}`);
807
+ return;
808
+ }
809
+
810
+ // Root-path dashboard routes (config, env, providers, etc.) that users
811
+ // type or bookmark without the /hm/app prefix. Redirect them there.
812
+ const dashboardRootRoutes = new Set([
813
+ "/config",
814
+ "/env",
815
+ "/models",
816
+ "/providers",
817
+ "/profiles",
818
+ "/sessions",
819
+ "/skills",
820
+ "/cron",
821
+ "/analytics",
822
+ "/logs",
823
+ "/plugins",
824
+ "/chat",
825
+ "/docs",
826
+ ]);
827
+ if (dashboardRootRoutes.has(path) || [...dashboardRootRoutes].some((r) => path.startsWith(r + "/"))) {
828
+ redirect(res, `${HM_PREFIX}/app${path}${parsed.search}`);
829
+ return;
830
+ }
831
+
832
+ // 6b. Root-path requests whose Referer came from /hm/app/* must go to
833
+ // the dashboard, not WebUI. This covers:
834
+ // - Absolute assets (/assets/*, /ds-assets/*, /dashboard-plugins/*)
835
+ // - API calls (/api/*) when dashboard code uses absolute paths
836
+ // - Favicon (/favicon.ico)
837
+ // - WebSocket upgrades from dashboard pages
838
+ // - File downloads (any extensioned path referenced by dashboard)
839
+ // Both the Hermes dashboard AND hermes-webui use /api/* internally,
840
+ // so the Referer is the only reliable way to disambiguate.
841
+ const refererPath = (() => {
842
+ const ref = String(req.headers.referer || "");
843
+ if (!ref) return "";
844
+ try {
845
+ return new URL(ref).pathname;
846
+ } catch {
847
+ return "";
848
+ }
849
+ })();
850
+ const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`) || refererPath.startsWith(`${HMD_PREFIX}`);
851
+ const refererIsHmd = refererPath.startsWith(`${HMD_PREFIX}`);
852
+
853
+ if (refererIsDashboard) {
854
+ // Anything with a Referer from the dashboard goes to the dashboard,
855
+ // *except* requests that explicitly start with /webui (escape hatch).
856
+ if (!path.startsWith("/webui")) {
857
+ // /hmd is intentionally no-auth (dashboard's own session token gates writes).
858
+ // Only require auth for /hm/app referers.
859
+ if (!refererIsHmd) {
860
+ if (!requireAuth(req, res)) return;
861
+ }
862
+ // Assets must NOT get the SPA fallback; pass them through as-is.
863
+ const parsed2 = new URL(req.url, "http://localhost");
864
+ const looksLikeAsset =
865
+ path.startsWith("/assets/") ||
866
+ path.startsWith("/ds-assets/") ||
867
+ path.startsWith("/dashboard-plugins/") ||
868
+ path.startsWith("/api/") ||
869
+ path === "/favicon.ico" ||
870
+ /\.[a-z0-9]{1,6}$/i.test(path);
871
+ if (looksLikeAsset) {
872
+ proxyRequest(req, res, DASHBOARD_PORT);
873
+ } else {
874
+ // Unlikely: a dashboard-referrer request for a non-asset, non-/hm
875
+ // path. Treat as a dashboard sub-route.
876
+ proxyDashboard(req, res, refererIsHmd ? HMD_PREFIX : undefined);
877
+ }
878
+ return;
879
+ }
880
+ }
881
+
882
+ // 6c. /api/* routes β€” these are WebUI API calls when Referer isn't the
883
+ // dashboard. Fall through to the catch-all below.
884
+ //
885
+ // Exception: hermes-workspace probes for the *legacy* enhanced-fork chat
886
+ // endpoint at POST /api/sessions/<id>/chat/stream. Without this rule the
887
+ // request falls through to WebUI's catch-all, which doesn't 404 it
888
+ // cleanly, so the workspace's detector sets `enhancedChat=true`, sends
889
+ // chat there at runtime, and the UI surfaces a generic "Authentication
890
+ // error". Returning an explicit 404 here makes the workspace fall back
891
+ // to the OpenAI-compatible /v1/chat/completions path on the gateway β€”
892
+ // which is the only chat surface this Space actually exposes.
893
+ //
894
+ // Anything the dashboard or WebUI legitimately need under /api/sessions/
895
+ // already has a more specific match above (referer check / /hmd
896
+ // passthrough), so this only fires for cross-origin probes.
897
+ if (
898
+ /^\/api\/sessions\/[^/]+\/chat\/stream\/?$/.test(path) &&
899
+ !refererIsDashboard
900
+ ) {
901
+ res.writeHead(404, {
902
+ "content-type": "application/json",
903
+ "cache-control": "no-store",
904
+ });
905
+ res.end(
906
+ JSON.stringify({
907
+ error: "not_found",
908
+ message:
909
+ "Legacy enhanced-fork chat stream is not exposed by this Space. Use /v1/chat/completions.",
910
+ }),
911
+ );
912
+ return;
913
+ }
914
+
915
+ // 7. Anything else -> Hermes WebUI (primary UI) OR HuggingMes status page.
916
+ // WebUI handles its own auth internally via HERMES_WEBUI_PASSWORD.
917
+ if (PRIMARY_UI === "dashboard" && path === "/") {
918
+ if (!requireAuth(req, res)) return;
919
+ const data = await statusPayload();
920
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
921
+ res.end(renderStatusPage(data));
922
+ return;
923
+ }
924
+
925
+ // Catch-all -> WebUI. Don't gate at the router level: WebUI has its own
926
+ // password login. GATEWAY_TOKEN *is* the WebUI password (start.sh sets
927
+ // HERMES_WEBUI_PASSWORD=$GATEWAY_TOKEN).
928
+ proxyRequest(req, res, WEBUI_PORT);
929
+ });
930
+
931
+ server.listen(PORT, "0.0.0.0", () => {
932
+ console.log(`HuggingMes + Hermes WebUI router listening on 0.0.0.0:${PORT}`);
933
+ });
934
+
935
+ /* ── WebSocket upgrade handling ─────────────────────────────────────
936
+ *
937
+ * Both the Hermes dashboard and hermes-webui can open WebSocket
938
+ * connections for live updates. Route the upgrade to the correct
939
+ * upstream based on path prefix + referer, same as HTTP requests.
940
+ */
941
+ server.on("upgrade", (req, clientSocket, head) => {
942
+ const parsed = new URL(req.url, "http://localhost");
943
+ const path = parsed.pathname;
944
+
945
+ let targetPort = WEBUI_PORT;
946
+ let targetPath = req.url;
947
+
948
+ const refererPath = (() => {
949
+ const ref = String(req.headers.referer || "");
950
+ if (!ref) return "";
951
+ try {
952
+ return new URL(ref).pathname;
953
+ } catch {
954
+ return "";
955
+ }
956
+ })();
957
+ const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`) || refererPath.startsWith(`${HMD_PREFIX}`);
958
+
959
+ if (path === "/v1" || path.startsWith("/v1/")) {
960
+ targetPort = GATEWAY_PORT;
961
+ } else if (path === HMD_PREFIX || path.startsWith(`${HMD_PREFIX}/`)) {
962
+ // Off-Space dashboard passthrough (mirrors the HTTP /hmd handler).
963
+ targetPort = DASHBOARD_PORT;
964
+ targetPath = path.replace(HMD_PREFIX, "") || "/";
965
+ if (parsed.search) targetPath += parsed.search;
966
+ } else if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) {
967
+ targetPort = DASHBOARD_PORT;
968
+ targetPath = path.replace(`${HM_PREFIX}/app`, "") || "/";
969
+ if (parsed.search) targetPath += parsed.search;
970
+ } else if (refererIsDashboard && !path.startsWith("/webui")) {
971
+ targetPort = DASHBOARD_PORT;
972
+ } else if (path.startsWith("/webui/") || path === "/webui") {
973
+ targetPort = WEBUI_PORT;
974
+ targetPath = path.replace(/^\/webui/, "") || "/";
975
+ if (parsed.search) targetPath += parsed.search;
976
+ }
977
+
978
+ const upstream = net.createConnection(targetPort, GATEWAY_HOST, () => {
979
+ // Forward the HTTP upgrade handshake verbatim
980
+ const headerLines = [
981
+ `${req.method} ${targetPath} HTTP/1.1`,
982
+ ];
983
+ for (const [name, value] of Object.entries(req.headers)) {
984
+ if (Array.isArray(value)) {
985
+ for (const v of value) headerLines.push(`${name}: ${v}`);
986
+ } else {
987
+ headerLines.push(`${name}: ${value}`);
988
+ }
989
+ }
990
+ headerLines.push("", "");
991
+ upstream.write(headerLines.join("\r\n"));
992
+ if (head && head.length) upstream.write(head);
993
+ upstream.pipe(clientSocket);
994
+ clientSocket.pipe(upstream);
995
+ });
996
+
997
+ upstream.on("error", () => {
998
+ try {
999
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
1000
+ } catch {}
1001
+ });
1002
+ clientSocket.on("error", () => {
1003
+ try {
1004
+ upstream.destroy();
1005
+ } catch {}
1006
+ });
1007
+ });
hermes-sync.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 socket
16
+ import sys
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+
22
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
23
+ os.environ.setdefault("HF_HUB_VERBOSITY", "error")
24
+ os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")
25
+ # huggingface_hub 0.30+ replaced HF_HUB_ENABLE_HF_TRANSFER with this flag;
26
+ # the legacy var triggers a FutureWarning at import on newer hubs and is
27
+ # silently ignored. Setting only the new var means older hubs miss the
28
+ # transfer accelerator (which is fine β€” they fall back to the standard
29
+ # downloader) but no version emits a deprecation warning.
30
+ os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
31
+
32
+ from huggingface_hub import HfApi, snapshot_download, upload_folder
33
+ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
34
+
35
+ logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
36
+
37
+ HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data"))
38
+ STATUS_FILE = Path("/tmp/huggingmes-sync-status.json")
39
+ STATE_FILE = HERMES_HOME / ".huggingmes-sync-state.json"
40
+ INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))
41
+ INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "5"))
42
+ # Change-driven settings: the loop polls cheap stat metadata every POLL_INTERVAL
43
+ # seconds, and once a change is observed waits DEBOUNCE_SECONDS of quiet before
44
+ # uploading. INTERVAL acts only as a hard ceiling β€” even if writes never settle,
45
+ # a sync is forced after INTERVAL seconds. This keeps the worst-case data loss
46
+ # window well under a minute without uploading on every keystroke.
47
+ POLL_INTERVAL = float(os.environ.get("SYNC_POLL_INTERVAL", "2"))
48
+ DEBOUNCE_SECONDS = float(os.environ.get("SYNC_DEBOUNCE_SECONDS", "3"))
49
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
50
+ HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
51
+ SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
52
+ BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "huggingmes-backup").strip()
53
+ INCLUDE_ENV = os.environ.get("SYNC_INCLUDE_ENV", "").strip().lower() in {"1", "true", "yes"}
54
+ MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
55
+
56
+ EXCLUDED_DIRS = {
57
+ ".cache",
58
+ ".git",
59
+ ".npm",
60
+ ".venv",
61
+ "__pycache__",
62
+ "node_modules",
63
+ "venv",
64
+ "logs", # log files are useless after a restart
65
+ }
66
+ EXCLUDED_TOP_LEVEL = {"logs", STATE_FILE.name}
67
+ EXCLUDED_SUFFIXES = (
68
+ ".log", ".log.1", ".log.2",
69
+ ".db-shm", ".db-wal", ".db-journal",
70
+ ".pid", ".tmp",
71
+ )
72
+ if not INCLUDE_ENV:
73
+ EXCLUDED_TOP_LEVEL.add(".env")
74
+
75
+ HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
76
+ STOP_EVENT = threading.Event()
77
+ _REPO_ID_CACHE: str | None = None
78
+
79
+ # `.env` warning: on HF Spaces, the dashboard's "Env" tab writes to
80
+ # $HERMES_HOME/.env which is *not* backed up by default (see EXCLUDED_TOP_LEVEL
81
+ # above). That means provider keys typed into the dashboard silently disappear
82
+ # on every restart. We can't safely fix that by default β€” uploading plaintext
83
+ # secrets to a dataset is the wrong tradeoff β€” but we can make the failure
84
+ # loud. The status surface on the HuggingMes status page reads the JSON below,
85
+ # so an `env_warning` field renders as a banner without any extra plumbing.
86
+ ENV_FILE = HERMES_HOME / ".env"
87
+ ON_HF_SPACE = bool(os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST"))
88
+
89
+
90
+ def env_warning_payload() -> dict | None:
91
+ """Detect plaintext-secret-loss risk and return a warning blob, or None.
92
+
93
+ Fires when:
94
+ * we're on an HF Space (ephemeral filesystem), AND
95
+ * `.env` exists with non-trivial content, AND
96
+ * SYNC_INCLUDE_ENV is off (so .env is NOT being backed up).
97
+
98
+ The warning is informational. We never refuse to start sync, and we never
99
+ auto-flip SYNC_INCLUDE_ENV β€” the user must opt in to backing up plaintext.
100
+ """
101
+ if not ON_HF_SPACE or INCLUDE_ENV:
102
+ return None
103
+ try:
104
+ if not ENV_FILE.is_file():
105
+ return None
106
+ # Count non-empty, non-comment lines as a proxy for "user-set keys".
107
+ keys = 0
108
+ for raw in ENV_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
109
+ line = raw.strip()
110
+ if not line or line.startswith("#"):
111
+ continue
112
+ if "=" in line:
113
+ keys += 1
114
+ if keys <= 0:
115
+ return None
116
+ return {
117
+ "kind": "ephemeral_env",
118
+ "keys": keys,
119
+ "message": (
120
+ f"{keys} entr{'y' if keys == 1 else 'ies'} in $HERMES_HOME/.env "
121
+ "will be wiped on the next Space restart. Move secrets to "
122
+ "Space Secrets (Settings -> Variables and secrets), or set "
123
+ "SYNC_INCLUDE_ENV=1 to back up .env to the private dataset "
124
+ "(plaintext; weaker security)."
125
+ ),
126
+ }
127
+ except OSError:
128
+ return None
129
+
130
+
131
+ def write_status(status: str, message: str, fingerprint: str | None = None, marker: tuple[int, int, int] | None = None) -> None:
132
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
133
+ payload: dict = {"status": status, "message": message, "timestamp": timestamp}
134
+ warning = env_warning_payload()
135
+ if warning is not None:
136
+ payload["warning"] = warning
137
+
138
+ tmp_path = STATUS_FILE.with_suffix(".tmp")
139
+ try:
140
+ tmp_path.write_text(json.dumps(payload), encoding="utf-8")
141
+ tmp_path.replace(STATUS_FILE)
142
+ except OSError:
143
+ pass
144
+
145
+ if fingerprint or marker:
146
+ state = {}
147
+ if STATE_FILE.exists():
148
+ try:
149
+ state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
150
+ except Exception:
151
+ pass
152
+ if fingerprint:
153
+ state["last_fingerprint"] = fingerprint
154
+ if marker:
155
+ state["last_marker"] = list(marker)
156
+ state["last_sync"] = timestamp
157
+ try:
158
+ STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
159
+ except OSError:
160
+ pass
161
+
162
+
163
+ def resolve_backup_repo() -> str:
164
+ global _REPO_ID_CACHE
165
+ if _REPO_ID_CACHE:
166
+ return _REPO_ID_CACHE
167
+
168
+ namespace = HF_USERNAME or SPACE_AUTHOR_NAME
169
+ if not namespace and HF_API is not None:
170
+ whoami = HF_API.whoami()
171
+ namespace = whoami.get("name") or whoami.get("user") or ""
172
+
173
+ namespace = str(namespace).strip()
174
+ if not namespace:
175
+ raise RuntimeError("Could not determine HF username. Set HF_USERNAME or use an account HF_TOKEN.")
176
+
177
+ _REPO_ID_CACHE = f"{namespace}/{BACKUP_DATASET_NAME}"
178
+ return _REPO_ID_CACHE
179
+
180
+
181
+ def ensure_repo_exists() -> str:
182
+ repo_id = resolve_backup_repo()
183
+ try:
184
+ HF_API.repo_info(repo_id=repo_id, repo_type="dataset")
185
+ except RepositoryNotFoundError:
186
+ HF_API.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
187
+ return repo_id
188
+
189
+
190
+ def should_exclude(rel_posix: str, path: Path) -> bool:
191
+ parts = Path(rel_posix).parts
192
+ if not parts:
193
+ return False
194
+ if parts[0] in EXCLUDED_TOP_LEVEL:
195
+ return True
196
+ if any(part in EXCLUDED_DIRS for part in parts):
197
+ return True
198
+ if path.is_file():
199
+ name_lower = path.name.lower()
200
+ if name_lower.endswith(EXCLUDED_SUFFIXES):
201
+ return True
202
+ try:
203
+ return path.stat().st_size > MAX_FILE_SIZE_BYTES
204
+ except OSError:
205
+ return True
206
+ return False
207
+
208
+
209
+ def metadata_marker(root: Path) -> tuple[int, int, int]:
210
+ if not root.exists():
211
+ return (0, 0, 0)
212
+ file_count = 0
213
+ total_size = 0
214
+ newest_mtime = 0
215
+ for path in root.rglob("*"):
216
+ if not path.is_file():
217
+ continue
218
+ rel = path.relative_to(root).as_posix()
219
+ if should_exclude(rel, path):
220
+ continue
221
+ try:
222
+ stat = path.stat()
223
+ except OSError:
224
+ continue
225
+ file_count += 1
226
+ total_size += int(stat.st_size)
227
+ newest_mtime = max(newest_mtime, int(stat.st_mtime_ns))
228
+ return (file_count, total_size, newest_mtime)
229
+
230
+
231
+ def fingerprint_dir(root: Path) -> str:
232
+ hasher = hashlib.sha256()
233
+ if not root.exists():
234
+ return hasher.hexdigest()
235
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
236
+ rel = path.relative_to(root).as_posix()
237
+ if should_exclude(rel, path):
238
+ continue
239
+ hasher.update(rel.encode("utf-8"))
240
+ with path.open("rb") as handle:
241
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
242
+ hasher.update(chunk)
243
+ return hasher.hexdigest()
244
+
245
+
246
+ def create_snapshot_dir(source_root: Path) -> Path:
247
+ staging_root = Path(tempfile.mkdtemp(prefix="huggingmes-sync-"))
248
+ for path in sorted(source_root.rglob("*")):
249
+ rel = path.relative_to(source_root)
250
+ rel_posix = rel.as_posix()
251
+ if should_exclude(rel_posix, path):
252
+ continue
253
+ target = staging_root / rel
254
+ if path.is_dir():
255
+ target.mkdir(parents=True, exist_ok=True)
256
+ continue
257
+ target.parent.mkdir(parents=True, exist_ok=True)
258
+ try:
259
+ shutil.copy2(path, target)
260
+ except OSError:
261
+ continue
262
+ return staging_root
263
+
264
+
265
+ def restore() -> bool:
266
+ if not HF_TOKEN:
267
+ write_status("disabled", "HF_TOKEN is not configured.")
268
+ return False
269
+
270
+ repo_id = resolve_backup_repo()
271
+ write_status("restoring", f"Restoring Hermes state from {repo_id}")
272
+ try:
273
+ with tempfile.TemporaryDirectory() as tmpdir:
274
+ snapshot_download(repo_id=repo_id, repo_type="dataset", token=HF_TOKEN, local_dir=tmpdir)
275
+ tmp_path = Path(tmpdir)
276
+ if not any(tmp_path.iterdir()):
277
+ write_status("fresh", "Backup dataset is empty. Starting fresh.")
278
+ return True
279
+
280
+ HERMES_HOME.mkdir(parents=True, exist_ok=True)
281
+ for child in tmp_path.iterdir():
282
+ if should_exclude(child.name, child):
283
+ continue
284
+ target = HERMES_HOME / child.name
285
+ if target.is_dir():
286
+ shutil.rmtree(target, ignore_errors=True)
287
+ elif target.exists():
288
+ target.unlink()
289
+ if child.is_dir():
290
+ shutil.copytree(child, target)
291
+ else:
292
+ shutil.copy2(child, target)
293
+
294
+ write_status("restored", f"Restored Hermes state from {repo_id}")
295
+ return True
296
+ except RepositoryNotFoundError:
297
+ write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
298
+ return True
299
+ except HfHubHTTPError as exc:
300
+ if exc.response is not None and exc.response.status_code == 404:
301
+ write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
302
+ return True
303
+ write_status("error", f"Restore failed: {exc}")
304
+ print(f"Restore failed: {exc}", file=sys.stderr)
305
+ return False
306
+ except Exception as exc:
307
+ write_status("error", f"Restore failed: {exc}")
308
+ print(f"Restore failed: {exc}", file=sys.stderr)
309
+ return False
310
+
311
+
312
+ def sync_once(last_fingerprint: str | None = None, last_marker: tuple[int, int, int] | None = None):
313
+ if last_fingerprint is None and last_marker is None:
314
+ if STATE_FILE.exists():
315
+ try:
316
+ state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
317
+ last_fingerprint = state.get("last_fingerprint")
318
+ m = state.get("last_marker")
319
+ if m and len(m) == 3:
320
+ last_marker = tuple(m)
321
+ except Exception:
322
+ pass
323
+
324
+ repo_id = ensure_repo_exists()
325
+ current_marker = metadata_marker(HERMES_HOME)
326
+ if last_marker is not None and current_marker == last_marker:
327
+ write_status("synced", "No Hermes state changes detected (marker match).")
328
+ return (last_fingerprint or "", current_marker)
329
+
330
+ current_fingerprint = fingerprint_dir(HERMES_HOME)
331
+ if last_fingerprint is not None and current_fingerprint == last_fingerprint:
332
+ write_status("synced", "No Hermes state changes detected (fingerprint match).")
333
+ return (last_fingerprint, current_marker)
334
+
335
+ hostname = socket.gethostname()
336
+ write_status("syncing", f"Uploading Hermes state to {repo_id} from {hostname}")
337
+ snapshot_dir = create_snapshot_dir(HERMES_HOME)
338
+ try:
339
+ upload_folder(
340
+ folder_path=str(snapshot_dir),
341
+ repo_id=repo_id,
342
+ repo_type="dataset",
343
+ token=HF_TOKEN,
344
+ commit_message=f"HuggingMes sync [{hostname}] {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
345
+ ignore_patterns=[".git/*", ".git"],
346
+ )
347
+ finally:
348
+ shutil.rmtree(snapshot_dir, ignore_errors=True)
349
+
350
+ write_status("success", f"Uploaded Hermes state to {repo_id}", fingerprint=current_fingerprint, marker=current_marker)
351
+ return (current_fingerprint, current_marker)
352
+
353
+
354
+ def handle_signal(_sig, _frame) -> None:
355
+ STOP_EVENT.set()
356
+
357
+
358
+ def loop() -> int:
359
+ signal.signal(signal.SIGTERM, handle_signal)
360
+ signal.signal(signal.SIGINT, handle_signal)
361
+ try:
362
+ repo_id = resolve_backup_repo()
363
+ write_status(
364
+ "configured",
365
+ f"Backup watcher active for {repo_id} "
366
+ f"(poll={POLL_INTERVAL}s, debounce={DEBOUNCE_SECONDS}s, max={INTERVAL}s).",
367
+ )
368
+ except Exception as exc:
369
+ write_status("error", str(exc))
370
+ print(f"Hermes sync error: {exc}")
371
+ return 1
372
+
373
+ warning = env_warning_payload()
374
+ if warning is not None:
375
+ # Loud, single-line, easy to grep in HF Space logs.
376
+ print(f"Hermes sync WARNING: {warning['message']}")
377
+
378
+ # Seed from any prior run so we don't re-upload an identical tree.
379
+ last_fingerprint: str | None = None
380
+ last_marker: tuple[int, int, int] | None = None
381
+ if STATE_FILE.exists():
382
+ try:
383
+ state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
384
+ last_fingerprint = state.get("last_fingerprint")
385
+ m = state.get("last_marker")
386
+ if m and len(m) == 3:
387
+ last_marker = tuple(m)
388
+ except Exception:
389
+ pass
390
+ if last_marker is None:
391
+ last_marker = metadata_marker(HERMES_HOME)
392
+
393
+ if STOP_EVENT.wait(INITIAL_DELAY):
394
+ return 0
395
+ print(
396
+ f"Hermes state sync started: poll={POLL_INTERVAL}s "
397
+ f"debounce={DEBOUNCE_SECONDS}s max={INTERVAL}s -> {repo_id}"
398
+ )
399
+
400
+ # Change-driven scheduler. Two clocks:
401
+ # * `pending_since` β€” when we first noticed an unsynced change. Used
402
+ # with INTERVAL to enforce a hard ceiling so a
403
+ # continuously-busy session can't starve uploads.
404
+ # * `last_change_at` β€” when we most recently saw the marker move. The
405
+ # debounce timer is measured against this so we
406
+ # wait for writes to settle before uploading.
407
+ pending_since: float | None = None
408
+ last_change_at: float | None = None
409
+ candidate_marker = last_marker
410
+
411
+ while not STOP_EVENT.is_set():
412
+ if STOP_EVENT.wait(POLL_INTERVAL):
413
+ break
414
+
415
+ try:
416
+ current_marker = metadata_marker(HERMES_HOME)
417
+ except Exception as exc:
418
+ # Don't let a transient stat error kill the loop.
419
+ write_status("error", f"marker scan failed: {exc}")
420
+ continue
421
+
422
+ now = time.time()
423
+
424
+ if current_marker != candidate_marker:
425
+ # Files moved since the last poll. Start (or extend) a debounce.
426
+ if pending_since is None:
427
+ pending_since = now
428
+ last_change_at = now
429
+ candidate_marker = current_marker
430
+ continue
431
+
432
+ if pending_since is None:
433
+ # Tree is unchanged and there's nothing waiting. Nothing to do.
434
+ continue
435
+
436
+ quiet_for = now - (last_change_at or now)
437
+ held_for = now - pending_since
438
+ # Trigger when writes have settled (debounce) OR when the hard ceiling
439
+ # is hit, so a never-idle tree still gets snapshotted at least once
440
+ # per INTERVAL seconds.
441
+ if quiet_for < DEBOUNCE_SECONDS and held_for < INTERVAL:
442
+ continue
443
+
444
+ try:
445
+ last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
446
+ candidate_marker = last_marker
447
+ except Exception as exc:
448
+ write_status("error", f"Sync failed: {exc}")
449
+ print(f"Hermes sync failed: {exc}")
450
+ # Back off briefly on failure so we don't hot-loop a broken upload.
451
+ if STOP_EVENT.wait(min(5.0, POLL_INTERVAL * 2)):
452
+ break
453
+ finally:
454
+ pending_since = None
455
+ last_change_at = None
456
+
457
+ return 0
458
+
459
+
460
+ def main() -> int:
461
+ HERMES_HOME.mkdir(parents=True, exist_ok=True)
462
+ if len(sys.argv) < 2:
463
+ return loop()
464
+ command = sys.argv[1]
465
+ if command == "restore":
466
+ return 0 if restore() else 1
467
+ if command == "sync-once":
468
+ try:
469
+ sync_once()
470
+ return 0
471
+ except Exception as exc:
472
+ write_status("error", f"Shutdown sync failed: {exc}")
473
+ print(f"Hermes sync: shutdown sync failed: {exc}")
474
+ return 1
475
+ if command == "loop":
476
+ return loop()
477
+ print(f"Unknown command: {command}", file=sys.stderr)
478
+ return 1
479
+
480
+
481
+ if __name__ == "__main__":
482
+ raise SystemExit(main())
start.sh ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:-60}"
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
+ # Rotate on-disk logs at boot. The router + WebUI + dashboard tee their
64
+ # stdout into $HERMES_HOME/logs/*.log via `tee -a`, which means without
65
+ # rotation those files grow forever and end up in the HF Dataset backup.
66
+ # Strategy: if a log is >5MB, rename to .1 (overwriting any previous .1)
67
+ # and start fresh. Cheap, deterministic, no cron needed.
68
+ if [ -d "$HERMES_HOME/logs" ]; then
69
+ for f in "$HERMES_HOME/logs"/*.log; do
70
+ [ -f "$f" ] || continue
71
+ sz=$(stat -c%s "$f" 2>/dev/null || echo 0)
72
+ if [ "$sz" -gt 5242880 ]; then
73
+ mv -f "$f" "${f}.1"
74
+ : > "$f"
75
+ echo "rotated $(basename "$f") ($sz bytes -> .1)"
76
+ fi
77
+ done
78
+ fi
79
+
80
+ # Expose hermes CLI to login shells
81
+ mkdir -p "$HERMES_HOME/.local/bin"
82
+ ln -sfn /opt/hermes/.venv/bin/hermes "$HERMES_HOME/.local/bin/hermes"
83
+
84
+ # Redirect Hermes plugin dir into volume
85
+ if [ ! -L "${HOME}/.hermes/plugins" ]; then
86
+ mkdir -p "${HOME}/.hermes"
87
+ rm -rf "${HOME}/.hermes/plugins"
88
+ ln -sfn "$HERMES_HOME/plugins" "${HOME}/.hermes/plugins"
89
+ fi
90
+
91
+ # ── Restore state from HF Dataset ─────────────────────────────────────
92
+ if [ -n "${HF_TOKEN:-}" ]; then
93
+ echo "Restoring Hermes state from HF Dataset..."
94
+ python3 "$APP_DIR/hermes-sync.py" restore || true
95
+ else
96
+ echo "HF_TOKEN not set - dataset persistence is disabled."
97
+ fi
98
+
99
+ # ── Deploy SOUL.md (agent persona) on first boot ─────────────────────
100
+ if [ ! -f "$HERMES_HOME/SOUL.md" ] && [ -f "/opt/huggingmes/SOUL.md" ]; then
101
+ cp /opt/huggingmes/SOUL.md "$HERMES_HOME/SOUL.md"
102
+ echo "Deployed SOUL.md from project (first boot)"
103
+ fi
104
+
105
+ # ── Cloudflare proxy (optional) ───────────────────────────────────────
106
+ CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
107
+ export CLOUDFLARE_WORKERS_TOKEN
108
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
109
+ export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
110
+ echo "Preparing Cloudflare Telegram proxy..."
111
+ python3 "$APP_DIR/cloudflare-proxy-setup.py" || true
112
+ if [ -f "$CF_PROXY_ENV_FILE" ]; then
113
+ . "$CF_PROXY_ENV_FILE"
114
+ fi
115
+ fi
116
+
117
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
118
+ echo "Preparing Cloudflare Keepalive worker..."
119
+ python3 "$APP_DIR/cloudflare-keepalive-setup.py" || true
120
+ fi
121
+
122
+ # ── Telegram env normalisation (aliases + webhook URL + secret) ───────
123
+ if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
124
+ export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"
125
+ elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
126
+ export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"
127
+ fi
128
+
129
+ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then
130
+ if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then
131
+ export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"
132
+ fi
133
+ fi
134
+
135
+ if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then
136
+ SECRET_FILE="$HERMES_HOME/.huggingmes-telegram-webhook-secret"
137
+ if [ -f "$SECRET_FILE" ]; then
138
+ TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"
139
+ else
140
+ TELEGRAM_WEBHOOK_SECRET="$(python3 - <<'PY'
141
+ import secrets
142
+ print(secrets.token_hex(32))
143
+ PY
144
+ )"
145
+ printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"
146
+ chmod 600 "$SECRET_FILE"
147
+ fi
148
+ export TELEGRAM_WEBHOOK_SECRET
149
+ fi
150
+
151
+ # ── Provider-prefix mapping (HuggingMes convention) ───────────────────
152
+ MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"
153
+ MODEL_FOR_CONFIG="$MODEL_INPUT"
154
+ PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"
155
+ LLM_API_KEY="${LLM_API_KEY:-}"
156
+
157
+ if [ -n "$MODEL_INPUT" ]; then
158
+ MODEL_PREFIX="${MODEL_INPUT%%/*}"
159
+ else
160
+ MODEL_PREFIX=""
161
+ fi
162
+
163
+ case "$MODEL_PREFIX" in
164
+ openrouter)
165
+ [ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"
166
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"
167
+ MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"
168
+ ;;
169
+ huggingface|hf)
170
+ [ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"
171
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"
172
+ MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"
173
+ ;;
174
+ vercel-ai-gateway|ai-gateway)
175
+ [ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"
176
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"
177
+ MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
178
+ ;;
179
+ anthropic)
180
+ [ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"
181
+ ;;
182
+ openai|openai-codex)
183
+ [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
184
+ ;;
185
+ google|gemini)
186
+ [ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"
187
+ PROVIDER_FOR_CONFIG="gemini"
188
+ MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
189
+ ;;
190
+ deepseek)
191
+ [ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"
192
+ ;;
193
+ kimi-coding|moonshot)
194
+ [ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"
195
+ ;;
196
+ kimi-coding-cn|moonshot-cn|kimi-cn)
197
+ [ -n "$LLM_API_KEY" ] && export KIMI_CN_API_KEY="${KIMI_CN_API_KEY:-$LLM_API_KEY}"
198
+ ;;
199
+ minimax)
200
+ [ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"
201
+ ;;
202
+ minimax-cn)
203
+ [ -n "$LLM_API_KEY" ] && export MINIMAX_CN_API_KEY="${MINIMAX_CN_API_KEY:-$LLM_API_KEY}"
204
+ ;;
205
+ xiaomi)
206
+ [ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"
207
+ ;;
208
+ zai|z-ai|z.ai|glm)
209
+ [ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"
210
+ ;;
211
+ arcee|arcee-ai|arceeai)
212
+ [ -n "$LLM_API_KEY" ] && export ARCEEAI_API_KEY="${ARCEEAI_API_KEY:-$LLM_API_KEY}"
213
+ ;;
214
+ gmi|gmi-cloud|gmicloud)
215
+ [ -n "$LLM_API_KEY" ] && export GMI_API_KEY="${GMI_API_KEY:-$LLM_API_KEY}"
216
+ ;;
217
+ alibaba|alibaba-coding-plan|alibaba_coding)
218
+ [ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"
219
+ ;;
220
+ tencent-tokenhub|tencent|tokenhub|tencentmaas)
221
+ [ -n "$LLM_API_KEY" ] && export TOKENHUB_API_KEY="${TOKENHUB_API_KEY:-$LLM_API_KEY}"
222
+ ;;
223
+ nvidia)
224
+ [ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"
225
+ ;;
226
+ xai|grok)
227
+ [ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"
228
+ ;;
229
+ kilocode)
230
+ [ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"
231
+ ;;
232
+ opencode-zen)
233
+ [ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"
234
+ ;;
235
+ opencode-go)
236
+ [ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"
237
+ ;;
238
+ esac
239
+
240
+ if [ -n "${CUSTOM_BASE_URL:-}" ]; then
241
+ PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"
242
+ [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
243
+ fi
244
+
245
+ export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG
246
+ export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
247
+ export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"
248
+ export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"
249
+ export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"
250
+ export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"
251
+ export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"
252
+
253
+ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
254
+ CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"
255
+ export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"
256
+ export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
257
+ fi
258
+
259
+ # ── Build Hermes config.yaml ──────────────────────────────────────────
260
+ python3 - <<'PY'
261
+ import os
262
+ from pathlib import Path
263
+ import yaml
264
+
265
+ home = Path(os.environ["HERMES_HOME"])
266
+ path = home / "config.yaml"
267
+
268
+ # Use config.yaml.template as base if config.yaml doesn't exist yet
269
+ template = Path("/opt/huggingmes/config.yaml.template")
270
+ if not path.exists() and template.exists():
271
+ import shutil
272
+ shutil.copy2(template, path)
273
+ print("Initialized config.yaml from template")
274
+
275
+ try:
276
+ config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
277
+ except FileNotFoundError:
278
+ config = {}
279
+
280
+ model_name = os.environ.get("MODEL_FOR_CONFIG", "").strip()
281
+ provider_name = os.environ.get("PROVIDER_FOR_CONFIG", "").strip()
282
+
283
+ if model_name:
284
+ model = config.setdefault("model", {})
285
+ model["default"] = model_name
286
+ if provider_name and provider_name != "auto":
287
+ model["provider"] = provider_name
288
+ else:
289
+ model.pop("provider", None)
290
+ else:
291
+ model = config.get("model", {})
292
+ print("No LLM_MODEL/HERMES_MODEL set; leaving Hermes model config unchanged.")
293
+
294
+ custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()
295
+ if custom_base and model_name:
296
+ model.setdefault("base_url", custom_base.rstrip("/"))
297
+ if os.environ.get("CUSTOM_API_KEY"):
298
+ model.setdefault("api_key", os.environ["CUSTOM_API_KEY"])
299
+ try:
300
+ model.setdefault("context_length", int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072")))
301
+ model.setdefault("max_tokens", int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192")))
302
+ except ValueError:
303
+ pass
304
+
305
+ config.setdefault("terminal", {}).setdefault("cwd", os.environ.get("MESSAGING_CWD", str(home / "workspace")))
306
+ config.setdefault("compression", {}).setdefault("enabled", True)
307
+ config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))
308
+ config.setdefault("security", {}).setdefault("redact_secrets", True)
309
+
310
+ platforms = config.setdefault("platforms", {})
311
+
312
+ if os.environ.get("TELEGRAM_BOT_TOKEN"):
313
+ telegram = platforms.setdefault("telegram", {})
314
+ telegram.setdefault("enabled", True)
315
+ extra = telegram.setdefault("extra", {})
316
+ if os.environ.get("TELEGRAM_BASE_URL"):
317
+ extra.setdefault("base_url", os.environ["TELEGRAM_BASE_URL"])
318
+ extra.setdefault("base_file_url", os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"])
319
+ if os.environ.get("TELEGRAM_ALLOWED_USERS"):
320
+ config.setdefault("telegram", {}).setdefault("allow_from", [
321
+ item.strip()
322
+ for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")
323
+ if item.strip()
324
+ ])
325
+
326
+ # ── Expand ${VAR} references in MCP server URLs and headers ───────────
327
+ import re
328
+ import json
329
+
330
+ def _expand_env_in_obj(obj):
331
+ """Recursively expand ${VAR} references using os.environ."""
332
+ if isinstance(obj, str):
333
+ def _replace(m):
334
+ key = m.group(1)
335
+ return os.environ.get(key, m.group(0)) # keep ${VAR} if env not set
336
+ return re.sub(r'\$\{([A-Z0-9_]+)\}', _replace, obj)
337
+ elif isinstance(obj, dict):
338
+ return {k: _expand_env_in_obj(v) for k, v in obj.items()}
339
+ elif isinstance(obj, list):
340
+ return [_expand_env_in_obj(v) for v in obj]
341
+ return obj
342
+
343
+ mcp = config.get("mcp_servers")
344
+ if mcp:
345
+ config["mcp_servers"] = _expand_env_in_obj(mcp)
346
+ print(f"Expanded env vars in {len(mcp)} MCP server(s)")
347
+
348
+ path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
349
+ path.chmod(0o600)
350
+ PY
351
+
352
+ # ── Startup summary ───────────────────────────────────────────────────
353
+ echo ""
354
+ echo "Primary UI : ${PRIMARY_UI:-webui}"
355
+ echo "Model : ${MODEL_FOR_CONFIG:-unset}"
356
+ echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"
357
+ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
358
+ echo "Telegram : enabled"
359
+ else
360
+ echo "Telegram : not configured"
361
+ fi
362
+ if [ -n "${HF_TOKEN:-}" ]; then
363
+ echo "Backup : ${BACKUP_DATASET} (poll ${SYNC_POLL_INTERVAL:-2}s, debounce ${SYNC_DEBOUNCE_SECONDS:-3}s, max ${SYNC_INTERVAL:-60}s)"
364
+ else
365
+ echo "Backup : disabled"
366
+ fi
367
+ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
368
+ echo "CF Proxy : ${CLOUDFLARE_PROXY_URL}"
369
+ fi
370
+ echo "Router : 0.0.0.0:${PUBLIC_PORT}"
371
+ echo "WebUI : 127.0.0.1:${WEBUI_PORT}"
372
+ echo "Gateway : 127.0.0.1:${GATEWAY_API_PORT}"
373
+ echo "Dashboard : 127.0.0.1:${DASHBOARD_PORT}"
374
+ echo ""
375
+
376
+ # ── Graceful shutdown ─────────────────────────────────────────────────
377
+ graceful_shutdown() {
378
+ echo "Shutting down..."
379
+ if [ -n "${HF_TOKEN:-}" ]; then
380
+ python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: shutdown sync failed."
381
+ fi
382
+ kill $(jobs -p) 2>/dev/null || true
383
+ exit 0
384
+ }
385
+ trap graceful_shutdown SIGTERM SIGINT
386
+
387
+ # ── Start the public-facing router (port 7861) ────────────────────────
388
+ node "$APP_DIR/health-server.js" &
389
+ HEALTH_PID=$!
390
+
391
+ if [ -n "${WEBHOOK_URL:-}" ]; then
392
+ python3 - <<'PY' >/dev/null 2>&1 &
393
+ import json, os, urllib.request
394
+ body = json.dumps({
395
+ "event": "restart",
396
+ "status": "success",
397
+ "message": "HuggingMes + Hermes WebUI has started.",
398
+ "model": os.environ.get("MODEL_FOR_CONFIG", ""),
399
+ }).encode()
400
+ req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST",
401
+ headers={"Content-Type": "application/json"})
402
+ urllib.request.urlopen(req, timeout=10).read()
403
+ PY
404
+ fi
405
+
406
+ # ── Launch Hermes dashboard (private; proxied via /hm/app) ────────────
407
+ echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
408
+ (hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
409
+ DASHBOARD_PID=$!
410
+
411
+ # ── Launch Hermes gateway ─────────────────────────────────────────────
412
+ echo "Launching Hermes gateway..."
413
+ (hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
414
+ GATEWAY_PID=$!
415
+
416
+ GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
417
+ ready=false
418
+ for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
419
+ if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
420
+ ready=true
421
+ break
422
+ fi
423
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
424
+ break
425
+ fi
426
+ sleep 1
427
+ done
428
+
429
+ if [ "$ready" != "true" ]; then
430
+ echo ""
431
+ echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
432
+ echo "----------------------------------------"
433
+ tail -40 "$HERMES_HOME/logs/gateway.log" || true
434
+ exit 1
435
+ fi
436
+
437
+ # ── Launch Hermes WebUI (nesquena/hermes-webui) ───────────────────────
438
+ # Points WebUI at the already-running Hermes agent venv and persists state
439
+ # under $HERMES_HOME/webui so hermes-sync.py backs it up.
440
+ export HERMES_WEBUI_AGENT_DIR="/opt/hermes"
441
+ export HERMES_WEBUI_PYTHON="/opt/hermes/.venv/bin/python"
442
+ export HERMES_WEBUI_HOST="127.0.0.1"
443
+ export HERMES_WEBUI_PORT
444
+ export HERMES_WEBUI_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-$HERMES_HOME/webui}"
445
+ export HERMES_WEBUI_DEFAULT_WORKSPACE="${HERMES_WEBUI_DEFAULT_WORKSPACE:-$HERMES_HOME/workspace}"
446
+ export HERMES_WEBUI_AUTO_INSTALL="0"
447
+ mkdir -p "$HERMES_WEBUI_STATE_DIR"
448
+
449
+ echo "Launching Hermes WebUI on 127.0.0.1:${WEBUI_PORT}..."
450
+ (cd "$WEBUI_REPO" && \
451
+ "$HERMES_WEBUI_PYTHON" "$WEBUI_REPO/server.py" 2>&1 | \
452
+ tee -a "$HERMES_HOME/logs/webui.log") &
453
+ WEBUI_PID=$!
454
+
455
+ # Wait for WebUI to bind its port (non-fatal on timeout β€” router handles it)
456
+ WEBUI_READY_TIMEOUT="${WEBUI_READY_TIMEOUT:-60}"
457
+ for ((i=0; i<WEBUI_READY_TIMEOUT; i++)); do
458
+ if (echo > "/dev/tcp/127.0.0.1/${WEBUI_PORT}") 2>/dev/null; then
459
+ echo "Hermes WebUI is up."
460
+ break
461
+ fi
462
+ if ! kill -0 "$WEBUI_PID" 2>/dev/null; then
463
+ echo "Warning: Hermes WebUI exited during startup. Last 20 log lines:"
464
+ tail -20 "$HERMES_HOME/logs/webui.log" || true
465
+ break
466
+ fi
467
+ sleep 1
468
+ done
469
+
470
+ # ── Periodic backup loop ──────────────────────────────────────────────
471
+ if [ -n "${HF_TOKEN:-}" ]; then
472
+ python3 -u "$APP_DIR/hermes-sync.py" loop &
473
+ fi
474
+
475
+ # ── Wait on the gateway (primary supervision target) ──────────────────
476
+ wait "$GATEWAY_PID"
477
+
478
+ if [ -n "${HF_TOKEN:-}" ]; then
479
+ echo "Gateway exited - syncing state before shutdown..."
480
+ python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: final sync failed."
481
+ fi