somratpro commited on
Commit
b9d12d1
·
1 Parent(s): 90e0d20

Add HuggingMess Hermes Space wrapper

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .DS_Store
3
+ __pycache__
4
+ *.pyc
5
+ node_modules
6
+ .env
7
+ venv
8
+ .venv
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ *.py[cod]
4
+ .env
5
+ .venv/
6
+ venv/
7
+ node_modules/
8
+ .cache/
CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-05-03
4
+
5
+ - Initial HuggingMess Docker Space wrapper for Nous Research Hermes Agent.
6
+ - Added HF Space dashboard, `/health`, `/status`, `/v1/*` proxy, and Telegram webhook proxy.
7
+ - Added Cloudflare Worker setup for Telegram Bot API base URL proxying.
8
+ - Added private HF Dataset backup and restore for Hermes state.
9
+ - Added UptimeRobot monitor setup.
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Code of Conduct
2
+
3
+ Be respectful, practical, and kind. This project exists to make self-hosting Hermes on Hugging Face Spaces easier for people with different levels of infrastructure experience.
4
+
5
+ Harassment, abuse, and deliberately unsafe guidance are not welcome.
CONTRIBUTING.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ Thanks for improving HuggingMess.
4
+
5
+ ## Local Checks
6
+
7
+ Run these before submitting changes:
8
+
9
+ ```bash
10
+ bash -n start.sh setup-uptimerobot.sh
11
+ node --check health-server.js
12
+ python3 -m py_compile hermes-sync.py cloudflare-proxy-setup.py
13
+ ```
14
+
15
+ If Docker is available:
16
+
17
+ ```bash
18
+ docker compose up --build
19
+ ```
20
+
21
+ ## Notes
22
+
23
+ - Keep the wrapper thin; prefer the official `nousresearch/hermes-agent` image for Hermes itself.
24
+ - Avoid committing secrets or generated `/opt/data` state.
25
+ - Preserve Hugging Face Space metadata in `README.md`.
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingMess - Hermes Agent Gateway for Hugging Face Spaces
2
+
3
+ ARG HERMES_AGENT_VERSION=latest
4
+ FROM nousresearch/hermes-agent:${HERMES_AGENT_VERSION}
5
+
6
+ USER root
7
+
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ ca-certificates \
10
+ curl \
11
+ jq \
12
+ && rm -rf /var/lib/apt/lists/* \
13
+ && uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir huggingface_hub
14
+
15
+ COPY --chown=hermes:hermes start.sh /opt/huggingmess/start.sh
16
+ COPY --chown=hermes:hermes health-server.js /opt/huggingmess/health-server.js
17
+ COPY --chown=hermes:hermes hermes-sync.py /opt/huggingmess/hermes-sync.py
18
+ COPY --chown=hermes:hermes cloudflare-proxy-setup.py /opt/huggingmess/cloudflare-proxy-setup.py
19
+ COPY --chown=hermes:hermes setup-uptimerobot.sh /opt/huggingmess/setup-uptimerobot.sh
20
+
21
+ RUN chmod +x \
22
+ /opt/huggingmess/start.sh \
23
+ /opt/huggingmess/hermes-sync.py \
24
+ /opt/huggingmess/cloudflare-proxy-setup.py \
25
+ /opt/huggingmess/setup-uptimerobot.sh
26
+
27
+ ENV HERMES_HOME=/opt/data \
28
+ HUGGINGMESS_APP_DIR=/opt/huggingmess \
29
+ HERMES_AGENT_VERSION=${HERMES_AGENT_VERSION} \
30
+ PYTHONUNBUFFERED=1
31
+
32
+ EXPOSE 7861
33
+
34
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s \
35
+ CMD curl -fsS http://localhost:7861/health || exit 1
36
+
37
+ CMD ["/opt/huggingmess/start.sh"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Somrat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,146 @@
1
  ---
2
  title: HuggingMess
3
  emoji: 📚
4
- colorFrom: red
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
8
- short_description: Run Harmess agent for free on HuggingFace Space
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: HuggingMess
3
  emoji: 📚
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7861
8
+ pinned: true
9
+ license: mit
10
+ secrets:
11
+ - name: LLM_API_KEY
12
+ description: "Your LLM provider API key. HuggingMess maps it to the right Hermes provider env var."
13
+ - name: LLM_MODEL
14
+ description: "Model ID, e.g. openrouter/anthropic/claude-sonnet-4 or anthropic/claude-opus-4.6."
15
+ - name: TELEGRAM_BOT_TOKEN
16
+ description: "Telegram bot token from @BotFather."
17
+ - name: TELEGRAM_ALLOWED_USERS
18
+ description: "Comma-separated numeric Telegram user IDs allowed to use the bot."
19
+ - name: GATEWAY_TOKEN
20
+ description: "Bearer token for the proxied Hermes API routes."
21
+ - name: HF_TOKEN
22
+ description: "Hugging Face token with write access for private Dataset backup."
23
+ - name: CLOUDFLARE_WORKERS_TOKEN
24
+ description: "Cloudflare API token for automatic Worker proxy setup."
25
+ - name: UPTIMEROBOT_API_KEY
26
+ description: "UptimeRobot Main API key for automatic keep-awake monitor setup."
27
  ---
28
 
29
+ # HuggingMess
30
+
31
+ HuggingMess runs [Nous Research Hermes Agent](https://github.com/NousResearch/hermes-agent) as a Hugging Face Docker Space. It follows the same practical shape as HuggingClaw: one public Space port, Telegram gateway support, Cloudflare Worker proxy setup, UptimeRobot keep-awake, and private HF Dataset backup for Hermes state.
32
+
33
+ ## Quick Start
34
+
35
+ 1. Duplicate this Space or push this folder to a new Docker Space.
36
+ 2. Add these secrets in Space Settings:
37
+
38
+ | Secret | Required | Notes |
39
+ | :--- | :--- | :--- |
40
+ | `LLM_MODEL` | Yes | Examples: `openrouter/anthropic/claude-sonnet-4`, `anthropic/claude-opus-4.6`, `google/gemini-2.5-flash` |
41
+ | `LLM_API_KEY` | Usually | Used to populate the provider-specific env var automatically |
42
+ | `TELEGRAM_BOT_TOKEN` | For Telegram | Bot token from BotFather |
43
+ | `TELEGRAM_ALLOWED_USERS` | Recommended | Comma-separated numeric Telegram user IDs |
44
+ | `GATEWAY_TOKEN` | Recommended | Bearer token for `/v1/*` API routes |
45
+ | `HF_TOKEN` | Optional | Enables private Dataset backup named `huggingmess-backup` |
46
+ | `CLOUDFLARE_WORKERS_TOKEN` | Optional | Auto-creates a Worker proxy for Telegram Bot API traffic |
47
+ | `UPTIMEROBOT_API_KEY` | Optional | Auto-creates a monitor for `/health` |
48
+
49
+ ## Telegram on HF Spaces
50
+
51
+ When `TELEGRAM_BOT_TOKEN` and `SPACE_HOST` are present, HuggingMess defaults Telegram to webhook mode:
52
+
53
+ ```bash
54
+ TELEGRAM_WEBHOOK_URL=https://your-space.hf.space/telegram
55
+ TELEGRAM_WEBHOOK_PORT=8765
56
+ ```
57
+
58
+ If you need polling instead, set:
59
+
60
+ ```bash
61
+ TELEGRAM_MODE=polling
62
+ ```
63
+
64
+ Hermes requires numeric Telegram IDs for allowlists. You can use either Hermes-native `TELEGRAM_ALLOWED_USERS` or the HuggingClaw-style aliases `TELEGRAM_USER_ID` / `TELEGRAM_USER_IDS`.
65
+
66
+ ## Cloudflare Proxy
67
+
68
+ Hugging Face Spaces can be restrictive for outbound bot API traffic. Add `CLOUDFLARE_WORKERS_TOKEN`, and HuggingMess will:
69
+
70
+ 1. create a Cloudflare Worker,
71
+ 2. generate a shared proxy secret,
72
+ 3. set Hermes Telegram `base_url` to `https://worker.example.workers.dev/bot`,
73
+ 4. set `base_file_url` to `https://worker.example.workers.dev/file/bot`.
74
+
75
+ Manual mode is also supported:
76
+
77
+ ```bash
78
+ CLOUDFLARE_PROXY_URL=https://your-worker.workers.dev
79
+ CLOUDFLARE_PROXY_SECRET=optional-shared-secret
80
+ ```
81
+
82
+ The manual Worker source is included in `cloudflare-worker.js`.
83
+
84
+ ## Backup
85
+
86
+ Set `HF_TOKEN` with write access to enable backup. HuggingMess syncs `/opt/data` to a private Dataset named `huggingmess-backup` every 180 seconds by default.
87
+
88
+ | Variable | Default | Description |
89
+ | :--- | :--- | :--- |
90
+ | `BACKUP_DATASET_NAME` | `huggingmess-backup` | Dataset name under your HF account |
91
+ | `SYNC_INTERVAL` | `180` | Backup interval in seconds |
92
+ | `SYNC_INCLUDE_ENV` | `false` | Include `/opt/data/.env` in backup |
93
+
94
+ By default `.env` is excluded from backups because HF Space secrets are already injected at runtime.
95
+
96
+ ## Keep Awake
97
+
98
+ Add `UPTIMEROBOT_API_KEY`, and HuggingMess creates or reuses a monitor for:
99
+
100
+ ```text
101
+ https://your-space.hf.space/health
102
+ ```
103
+
104
+ Optional UptimeRobot variables:
105
+
106
+ | Variable | Default | Description |
107
+ | :--- | :--- | :--- |
108
+ | `UPTIMEROBOT_MONITOR_NAME` | `HuggingMess <space>` | Friendly monitor name |
109
+ | `UPTIMEROBOT_INTERVAL` | `300` | Monitor interval in seconds |
110
+ | `UPTIMEROBOT_ALERT_CONTACTS` | unset | Dash-separated alert contact IDs |
111
+
112
+ ## Local Development
113
+
114
+ ```bash
115
+ docker compose up --build
116
+ ```
117
+
118
+ Then open:
119
+
120
+ ```text
121
+ http://localhost:7861
122
+ ```
123
+
124
+ ## Useful Routes
125
+
126
+ | Route | Purpose |
127
+ | :--- | :--- |
128
+ | `/` | HuggingMess dashboard |
129
+ | `/health` | Health check for HF and UptimeRobot |
130
+ | `/status` | JSON status |
131
+ | `/dashboard/` | Proxied Hermes dashboard |
132
+ | `/v1/models` | Proxied Hermes OpenAI-compatible API server |
133
+ | `/telegram` | Telegram webhook endpoint |
134
+
135
+ The `/v1/*` routes require:
136
+
137
+ ```text
138
+ Authorization: Bearer <GATEWAY_TOKEN>
139
+ ```
140
+
141
+ ## Links
142
+
143
+ - [Hermes Agent GitHub](https://github.com/NousResearch/hermes-agent)
144
+ - [Hermes Agent Docs](https://hermes-agent.nousresearch.com/docs)
145
+ - [Hermes Docker Docs](https://hermes-agent.nousresearch.com/docs/user-guide/docker/)
146
+ - [Hermes Telegram Docs](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram)
SECURITY.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security
2
+
3
+ HuggingMess runs a full agent gateway with tool access. Treat the Space and its secrets like a server.
4
+
5
+ ## Required Hardening
6
+
7
+ - Set `GATEWAY_TOKEN`; `/v1/*` routes require `Authorization: Bearer <GATEWAY_TOKEN>`.
8
+ - Set `TELEGRAM_ALLOWED_USERS` to numeric Telegram user IDs.
9
+ - Keep your HF Dataset backup private.
10
+ - Do not enable `SYNC_INCLUDE_ENV=true` unless you intentionally want `/opt/data/.env` backed up.
11
+
12
+ ## Reporting
13
+
14
+ Open a private issue or contact the maintainer directly with reproduction steps and affected configuration.
cloudflare-proxy-setup.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create or reuse a Cloudflare Worker that proxies Telegram Bot API calls."""
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import secrets
8
+ import sys
9
+ import urllib.request
10
+ from pathlib import Path
11
+
12
+ API_BASE = "https://api.cloudflare.com/client/v4"
13
+ ENV_FILE = Path("/tmp/huggingmess-cloudflare-proxy.env")
14
+ DEFAULT_ALLOWED = [
15
+ "api.telegram.org",
16
+ "discord.com",
17
+ "discordapp.com",
18
+ "gateway.discord.gg",
19
+ "status.discord.com",
20
+ "slack.com",
21
+ "api.slack.com",
22
+ "web.whatsapp.com",
23
+ "graph.facebook.com",
24
+ "graph.instagram.com",
25
+ "api.openai.com",
26
+ "googleapis.com",
27
+ "google.com",
28
+ "googleusercontent.com",
29
+ "gstatic.com",
30
+ ]
31
+
32
+
33
+ def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
34
+ req = urllib.request.Request(
35
+ f"{API_BASE}{path}",
36
+ data=body,
37
+ method=method,
38
+ headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
39
+ )
40
+ with urllib.request.urlopen(req, timeout=30) as response:
41
+ payload = json.loads(response.read().decode("utf-8"))
42
+ if not payload.get("success"):
43
+ errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
44
+ raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
45
+ return payload["result"]
46
+
47
+
48
+ def slugify(value: str) -> str:
49
+ cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
50
+ cleaned = re.sub(r"-{2,}", "-", cleaned)
51
+ return (cleaned or "huggingmess-proxy")[:63].rstrip("-")
52
+
53
+
54
+ def derive_worker_name() -> str:
55
+ explicit = os.environ.get("CLOUDFLARE_WORKER_NAME", "").strip()
56
+ if explicit:
57
+ return slugify(explicit)
58
+ space_host = os.environ.get("SPACE_HOST", "").strip()
59
+ if space_host:
60
+ return slugify(f"{space_host.replace('.hf.space', '')}-proxy")
61
+ return "huggingmess-proxy"
62
+
63
+
64
+ def render_worker(secret_value: str, allowed_targets: list[str], allow_proxy_all: bool) -> str:
65
+ return f"""addEventListener("fetch", (event) => {{
66
+ event.respondWith(handleRequest(event.request));
67
+ }});
68
+
69
+ const PROXY_SHARED_SECRET = {json.dumps(secret_value)};
70
+ const ALLOW_PROXY_ALL = {"true" if allow_proxy_all else "false"};
71
+ const ALLOWED_TARGETS = {json.dumps(allowed_targets)};
72
+
73
+ function isAllowedHost(hostname) {{
74
+ const normalized = String(hostname || "").trim().toLowerCase();
75
+ if (!normalized) return false;
76
+ if (ALLOW_PROXY_ALL) return true;
77
+ return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${{domain}}`));
78
+ }}
79
+
80
+ async function handleRequest(request) {{
81
+ const url = new URL(request.url);
82
+ const queryTarget = url.searchParams.get("proxy_target");
83
+ const targetHost = request.headers.get("x-target-host") || queryTarget;
84
+
85
+ if (PROXY_SHARED_SECRET) {{
86
+ const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
87
+ const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
88
+ if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {{
89
+ return new Response("Unauthorized: Invalid proxy key", {{ status: 401 }});
90
+ }}
91
+ }}
92
+
93
+ let targetBase = "";
94
+ if (targetHost) {{
95
+ if (!isAllowedHost(targetHost)) {{
96
+ return new Response(`Forbidden: Host ${{targetHost}} is not allowed.`, {{ status: 403 }});
97
+ }}
98
+ targetBase = `https://${{targetHost}}`;
99
+ }} else if (url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot")) {{
100
+ targetBase = "https://api.telegram.org";
101
+ }} else {{
102
+ return new Response("Invalid request: No target host provided.", {{ status: 400 }});
103
+ }}
104
+
105
+ const cleanSearch = new URLSearchParams(url.search);
106
+ cleanSearch.delete("proxy_target");
107
+ cleanSearch.delete("proxy_key");
108
+ const searchStr = cleanSearch.toString();
109
+ const targetUrl = targetBase + url.pathname + (searchStr ? `?${{searchStr}}` : "");
110
+
111
+ const headers = new Headers(request.headers);
112
+ for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {{
113
+ headers.delete(header);
114
+ }}
115
+
116
+ try {{
117
+ return await fetch(new Request(targetUrl, {{
118
+ method: request.method,
119
+ headers,
120
+ body: request.body,
121
+ redirect: "follow",
122
+ }}));
123
+ }} catch (error) {{
124
+ return new Response(`Proxy Error: ${{error.message}}`, {{ status: 502 }});
125
+ }}
126
+ }}
127
+ """
128
+
129
+
130
+ def write_env(proxy_url: str, proxy_secret: str) -> None:
131
+ ENV_FILE.write_text(
132
+ f'export CLOUDFLARE_PROXY_URL="{proxy_url}"\nexport CLOUDFLARE_PROXY_SECRET="{proxy_secret}"\n',
133
+ encoding="utf-8",
134
+ )
135
+ ENV_FILE.chmod(0o600)
136
+
137
+
138
+ def main() -> int:
139
+ existing_url = os.environ.get("CLOUDFLARE_PROXY_URL", "").strip()
140
+ existing_secret = os.environ.get("CLOUDFLARE_PROXY_SECRET", "").strip()
141
+ api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
142
+
143
+ if existing_url:
144
+ write_env(existing_url, existing_secret)
145
+ return 0
146
+
147
+ if not api_token:
148
+ return 0
149
+
150
+ try:
151
+ account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
152
+ if not account_id:
153
+ accounts = cf_request("GET", "/accounts", api_token)
154
+ if not accounts:
155
+ raise RuntimeError("No Cloudflare account is available for this token.")
156
+ account_id = accounts[0]["id"]
157
+
158
+ subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
159
+ subdomain = (subdomain_info or {}).get("subdomain", "").strip()
160
+ if not subdomain:
161
+ raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
162
+
163
+ allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
164
+ allow_proxy_all = allowed_raw == "*"
165
+ extra = [] if allow_proxy_all else [v.strip() for v in allowed_raw.split(",") if v.strip()]
166
+ allowed = list(dict.fromkeys(DEFAULT_ALLOWED + extra))
167
+ worker_name = derive_worker_name()
168
+ proxy_secret = existing_secret or secrets.token_urlsafe(24)
169
+
170
+ cf_request(
171
+ "PUT",
172
+ f"/accounts/{account_id}/workers/scripts/{worker_name}",
173
+ api_token,
174
+ body=render_worker(proxy_secret, allowed, allow_proxy_all).encode("utf-8"),
175
+ content_type="application/javascript",
176
+ )
177
+ cf_request(
178
+ "POST",
179
+ f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
180
+ api_token,
181
+ body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
182
+ )
183
+ write_env(f"https://{worker_name}.{subdomain}.workers.dev", proxy_secret)
184
+ return 0
185
+ except Exception as exc:
186
+ print(f"Cloudflare proxy setup failed: {exc}", file=sys.stderr)
187
+ return 1
188
+
189
+
190
+ if __name__ == "__main__":
191
+ raise SystemExit(main())
cloudflare-worker.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ addEventListener("fetch", (event) => {
2
+ event.respondWith(handleRequest(event.request));
3
+ });
4
+
5
+ const PROXY_SHARED_SECRET = "";
6
+ const ALLOW_PROXY_ALL = false;
7
+ const ALLOWED_TARGETS = [
8
+ "api.telegram.org",
9
+ "discord.com",
10
+ "discordapp.com",
11
+ "gateway.discord.gg",
12
+ "status.discord.com",
13
+ "slack.com",
14
+ "api.slack.com",
15
+ "web.whatsapp.com",
16
+ "graph.facebook.com",
17
+ "graph.instagram.com",
18
+ "api.openai.com",
19
+ "googleapis.com",
20
+ "google.com",
21
+ "googleusercontent.com",
22
+ "gstatic.com",
23
+ ];
24
+
25
+ function isAllowedHost(hostname) {
26
+ const normalized = String(hostname || "").trim().toLowerCase();
27
+ if (!normalized) return false;
28
+ if (ALLOW_PROXY_ALL) return true;
29
+ return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${domain}`));
30
+ }
31
+
32
+ async function handleRequest(request) {
33
+ const url = new URL(request.url);
34
+ const queryTarget = url.searchParams.get("proxy_target");
35
+ const targetHost = request.headers.get("x-target-host") || queryTarget;
36
+ const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
37
+
38
+ if (PROXY_SHARED_SECRET) {
39
+ const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
40
+ if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {
41
+ return new Response("Unauthorized: Invalid proxy key", { status: 401 });
42
+ }
43
+ }
44
+
45
+ let targetBase = "";
46
+ if (targetHost) {
47
+ if (!isAllowedHost(targetHost)) {
48
+ return new Response(`Forbidden: Host ${targetHost} is not allowed.`, { status: 403 });
49
+ }
50
+ targetBase = `https://${targetHost}`;
51
+ } else if (telegramStylePath) {
52
+ targetBase = "https://api.telegram.org";
53
+ } else {
54
+ return new Response("Invalid request: No target host provided.", { status: 400 });
55
+ }
56
+
57
+ const cleanSearch = new URLSearchParams(url.search);
58
+ cleanSearch.delete("proxy_target");
59
+ cleanSearch.delete("proxy_key");
60
+ const searchStr = cleanSearch.toString();
61
+ const targetUrl = targetBase + url.pathname + (searchStr ? `?${searchStr}` : "");
62
+
63
+ const headers = new Headers(request.headers);
64
+ for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {
65
+ headers.delete(header);
66
+ }
67
+
68
+ try {
69
+ return await fetch(new Request(targetUrl, {
70
+ method: request.method,
71
+ headers,
72
+ body: request.body,
73
+ redirect: "follow",
74
+ }));
75
+ } catch (error) {
76
+ return new Response(`Proxy Error: ${error.message}`, { status: 502 });
77
+ }
78
+ }
docker-compose.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ huggingmess:
3
+ build:
4
+ context: .
5
+ args:
6
+ HERMES_AGENT_VERSION: ${HERMES_AGENT_VERSION:-latest}
7
+ image: huggingmess:local
8
+ ports:
9
+ - "7861:7861"
10
+ environment:
11
+ LLM_MODEL: ${LLM_MODEL:-openrouter/anthropic/claude-sonnet-4}
12
+ LLM_API_KEY: ${LLM_API_KEY:-}
13
+ GATEWAY_TOKEN: ${GATEWAY_TOKEN:-local-dev-token}
14
+ TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
15
+ TELEGRAM_ALLOWED_USERS: ${TELEGRAM_ALLOWED_USERS:-}
16
+ HF_TOKEN: ${HF_TOKEN:-}
17
+ SPACE_HOST: ${SPACE_HOST:-localhost:7861}
18
+ volumes:
19
+ - huggingmess-data:/opt/data
20
+
21
+ volumes:
22
+ huggingmess-data:
health-server.js ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const fs = require("fs");
5
+ const net = require("net");
6
+
7
+ const PORT = Number(process.env.PORT || 7861);
8
+ const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
9
+ const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
10
+ const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
11
+ const GATEWAY_HOST = "127.0.0.1";
12
+ const startTime = Date.now();
13
+ const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
14
+
15
+ const SYNC_STATUS_FILE = "/tmp/huggingmess-sync-status.json";
16
+ const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingmess-uptimerobot-status.json";
17
+
18
+ function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
19
+ return new Promise((resolve) => {
20
+ const socket = net.createConnection({ port, host });
21
+ const done = (ok) => {
22
+ socket.removeAllListeners();
23
+ socket.destroy();
24
+ resolve(ok);
25
+ };
26
+ socket.setTimeout(timeoutMs);
27
+ socket.once("connect", () => done(true));
28
+ socket.once("timeout", () => done(false));
29
+ socket.once("error", () => done(false));
30
+ });
31
+ }
32
+
33
+ function readJson(path, fallback = null) {
34
+ try {
35
+ if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
36
+ } catch {}
37
+ return fallback;
38
+ }
39
+
40
+ function proxyRequest(req, res, targetPort, rewritePath = (path) => path) {
41
+ const parsed = new URL(req.url, "http://localhost");
42
+ const targetPath = rewritePath(parsed.pathname) + parsed.search;
43
+ const headers = {
44
+ ...req.headers,
45
+ host: `${GATEWAY_HOST}:${targetPort}`,
46
+ "x-forwarded-host": req.headers.host || "",
47
+ "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
48
+ };
49
+
50
+ const proxy = http.request(
51
+ {
52
+ hostname: GATEWAY_HOST,
53
+ port: targetPort,
54
+ method: req.method,
55
+ path: targetPath,
56
+ headers,
57
+ },
58
+ (upstream) => {
59
+ res.writeHead(upstream.statusCode || 502, upstream.headers);
60
+ upstream.pipe(res);
61
+ },
62
+ );
63
+
64
+ proxy.on("error", (error) => {
65
+ res.writeHead(502, { "content-type": "application/json" });
66
+ res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
67
+ });
68
+
69
+ req.pipe(proxy);
70
+ }
71
+
72
+ function formatUptime(ms) {
73
+ const total = Math.floor(ms / 1000);
74
+ const days = Math.floor(total / 86400);
75
+ const hours = Math.floor((total % 86400) / 3600);
76
+ const minutes = Math.floor((total % 3600) / 60);
77
+ if (days) return `${days}d ${hours}h ${minutes}m`;
78
+ if (hours) return `${hours}h ${minutes}m`;
79
+ return `${minutes}m`;
80
+ }
81
+
82
+ async function statusPayload() {
83
+ const gateway = await canConnect(GATEWAY_PORT);
84
+ const dashboard = await canConnect(DASHBOARD_PORT);
85
+ const telegramWebhook =
86
+ !!process.env.TELEGRAM_WEBHOOK_URL && (await canConnect(TELEGRAM_WEBHOOK_PORT));
87
+ const sync = readJson(SYNC_STATUS_FILE, process.env.HF_TOKEN
88
+ ? { status: "configured", message: "Backup is enabled; waiting for the first sync." }
89
+ : { status: "disabled", message: "HF_TOKEN is not configured." });
90
+
91
+ return {
92
+ ok: gateway,
93
+ uptime: formatUptime(Date.now() - startTime),
94
+ gateway,
95
+ dashboard,
96
+ telegram: {
97
+ configured: !!process.env.TELEGRAM_BOT_TOKEN,
98
+ webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
99
+ webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
100
+ webhookListening: telegramWebhook,
101
+ proxy: process.env.CLOUDFLARE_PROXY_URL || "",
102
+ },
103
+ model: process.env.MODEL_FOR_CONFIG || process.env.HERMES_MODEL || process.env.LLM_MODEL || "",
104
+ provider: process.env.PROVIDER_FOR_CONFIG || process.env.HERMES_INFERENCE_PROVIDER || "auto",
105
+ backup: sync,
106
+ uptimerobot: readJson(UPTIMEROBOT_STATUS_FILE, null),
107
+ };
108
+ }
109
+
110
+ function badge(label, state) {
111
+ const cls = state ? "ok" : "off";
112
+ return `<span class="badge ${cls}">${label}</span>`;
113
+ }
114
+
115
+ function renderDashboard(data) {
116
+ const syncStatus = String(data.backup?.status || "unknown").toUpperCase();
117
+ const dashboardLink = data.dashboard ? `<a class="button" href="/dashboard/">Open Hermes Dashboard</a>` : "";
118
+ const apiLink = data.gateway ? `<a class="button secondary" href="/v1/models">API Models</a>` : "";
119
+ const keepAlive = data.uptimerobot?.configured
120
+ ? `UptimeRobot is monitoring <code>${data.uptimerobot.url}</code>.`
121
+ : process.env.UPTIMEROBOT_API_KEY
122
+ ? "UptimeRobot setup is pending or failed; check logs."
123
+ : "Add UPTIMEROBOT_API_KEY to create a keep-awake monitor.";
124
+
125
+ return `<!doctype html>
126
+ <html lang="en">
127
+ <head>
128
+ <meta charset="utf-8" />
129
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
130
+ <title>HuggingMess</title>
131
+ <style>
132
+ :root { color-scheme: dark; --bg:#10141f; --panel:#171d2b; --line:#293246; --text:#f4f7fb; --muted:#9aa7bd; --good:#22c55e; --warn:#f59e0b; --bad:#ef4444; --accent:#38bdf8; }
133
+ * { box-sizing:border-box; }
134
+ 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); }
135
+ main { width:min(960px, calc(100% - 32px)); margin:0 auto; padding:36px 0; }
136
+ header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:28px; }
137
+ h1 { margin:0; font-size:clamp(2rem, 6vw, 4.4rem); line-height:.95; letter-spacing:0; }
138
+ .subtitle { margin-top:12px; color:var(--muted); max-width:620px; line-height:1.5; }
139
+ .grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:14px; }
140
+ .card { border:1px solid var(--line); background:var(--panel); border-radius:8px; padding:18px; min-height:120px; }
141
+ .wide { grid-column:1 / -1; }
142
+ .label { color:var(--muted); font-size:.78rem; letter-spacing:.08em; text-transform:uppercase; margin-bottom:10px; }
143
+ .value { font-size:1.05rem; overflow-wrap:anywhere; }
144
+ code { background:#0b0f18; border:1px solid var(--line); border-radius:6px; padding:2px 6px; }
145
+ .row { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
146
+ .badge { display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.8rem; font-weight:700; }
147
+ .badge.ok { color:var(--good); border-color:rgba(34,197,94,.35); background:rgba(34,197,94,.08); }
148
+ .badge.off { color:var(--bad); border-color:rgba(239,68,68,.35); background:rgba(239,68,68,.08); }
149
+ .button { display:inline-flex; align-items:center; justify-content:center; min-height:42px; padding:0 14px; border-radius:7px; color:#07111f; background:var(--accent); text-decoration:none; font-weight:750; }
150
+ .button.secondary { color:var(--text); background:#222b3c; border:1px solid var(--line); }
151
+ @media (max-width: 720px) { header { display:block; } .grid { grid-template-columns:1fr; } }
152
+ </style>
153
+ </head>
154
+ <body>
155
+ <main>
156
+ <header>
157
+ <div>
158
+ <h1>HuggingMess</h1>
159
+ <div class="subtitle">Hermes Agent running as an always-on Hugging Face Docker Space, with Telegram gateway, state backup, Cloudflare proxy support, and keep-awake monitoring.</div>
160
+ </div>
161
+ <div class="row">${badge("Gateway", data.gateway)}${badge("Dashboard", data.dashboard)}${badge("Backup", data.backup?.status !== "disabled")}</div>
162
+ </header>
163
+ <section class="grid">
164
+ <div class="card"><div class="label">Uptime</div><div class="value">${data.uptime}</div></div>
165
+ <div class="card"><div class="label">Model</div><div class="value"><code>${data.model || "not set"}</code></div></div>
166
+ <div class="card"><div class="label">Provider</div><div class="value"><code>${data.provider}</code></div></div>
167
+ <div class="card"><div class="label">Telegram</div><div class="value">${data.telegram.configured ? "Configured" : "Not configured"}${data.telegram.webhook ? " via webhook" : ""}</div></div>
168
+ <div class="card wide"><div class="label">Backup</div><div class="value"><strong>${syncStatus}</strong><br>${data.backup?.message || ""}</div></div>
169
+ <div class="card wide"><div class="label">Keep Awake</div><div class="value">${keepAlive}</div></div>
170
+ <div class="card wide"><div class="label">Entrypoints</div><div class="row">${dashboardLink}${apiLink}<a class="button secondary" href="/status">Status JSON</a></div></div>
171
+ </section>
172
+ </main>
173
+ </body>
174
+ </html>`;
175
+ }
176
+
177
+ const server = http.createServer(async (req, res) => {
178
+ const parsed = new URL(req.url, "http://localhost");
179
+ const path = parsed.pathname;
180
+
181
+ if (path === "/health" || path === "/dashboard/health") {
182
+ const data = await statusPayload();
183
+ res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" });
184
+ res.end(JSON.stringify({ ok: data.ok, gateway: data.gateway, uptime: data.uptime }));
185
+ return;
186
+ }
187
+
188
+ if (path === "/status" || path === "/dashboard/status") {
189
+ const data = await statusPayload();
190
+ res.writeHead(200, { "content-type": "application/json" });
191
+ res.end(JSON.stringify(data, null, 2));
192
+ return;
193
+ }
194
+
195
+ if (path === "/" || path === "/dashboard") {
196
+ const data = await statusPayload();
197
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
198
+ res.end(renderDashboard(data));
199
+ return;
200
+ }
201
+
202
+ if (path === "/telegram" || path.startsWith("/telegram/")) {
203
+ proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
204
+ return;
205
+ }
206
+
207
+ if (path === "/dashboard/" || path.startsWith("/dashboard/")) {
208
+ proxyRequest(req, res, DASHBOARD_PORT, (p) => p.replace(/^\/dashboard/, "") || "/");
209
+ return;
210
+ }
211
+
212
+ if (path === "/v1" || path.startsWith("/v1/")) {
213
+ if (API_SERVER_KEY) {
214
+ const expected = `Bearer ${API_SERVER_KEY}`;
215
+ if (req.headers.authorization !== expected) {
216
+ res.writeHead(401, { "content-type": "application/json" });
217
+ res.end(JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer <GATEWAY_TOKEN>." }));
218
+ return;
219
+ }
220
+ }
221
+ proxyRequest(req, res, GATEWAY_PORT);
222
+ return;
223
+ }
224
+
225
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
226
+ res.end("Not found");
227
+ });
228
+
229
+ server.listen(PORT, "0.0.0.0", () => {
230
+ console.log(`HuggingMess dashboard listening on 0.0.0.0:${PORT}`);
231
+ });
hermes-sync.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """HuggingMess Hermes state backup via Hugging Face Datasets."""
3
+
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import signal
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
16
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
17
+ os.environ.setdefault("HF_HUB_VERBOSITY", "error")
18
+
19
+ from huggingface_hub import HfApi, snapshot_download, upload_folder
20
+ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError
21
+
22
+ logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
23
+
24
+ HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data"))
25
+ STATUS_FILE = Path("/tmp/huggingmess-sync-status.json")
26
+ INTERVAL = int(os.environ.get("SYNC_INTERVAL", "180"))
27
+ INITIAL_DELAY = int(os.environ.get("SYNC_START_DELAY", "10"))
28
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
29
+ HF_USERNAME = os.environ.get("HF_USERNAME", "").strip()
30
+ SPACE_AUTHOR_NAME = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
31
+ BACKUP_DATASET_NAME = os.environ.get("BACKUP_DATASET_NAME", "huggingmess-backup").strip()
32
+ INCLUDE_ENV = os.environ.get("SYNC_INCLUDE_ENV", "").strip().lower() in {"1", "true", "yes"}
33
+ MAX_FILE_SIZE_BYTES = int(os.environ.get("SYNC_MAX_FILE_BYTES", str(50 * 1024 * 1024)))
34
+
35
+ EXCLUDED_DIRS = {
36
+ ".cache",
37
+ ".git",
38
+ ".npm",
39
+ ".venv",
40
+ "__pycache__",
41
+ "node_modules",
42
+ "venv",
43
+ }
44
+ EXCLUDED_TOP_LEVEL = {"logs"}
45
+ if not INCLUDE_ENV:
46
+ EXCLUDED_TOP_LEVEL.add(".env")
47
+
48
+ HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
49
+ STOP_EVENT = threading.Event()
50
+ _REPO_ID_CACHE: str | None = None
51
+
52
+
53
+ def write_status(status: str, message: str) -> None:
54
+ payload = {
55
+ "status": status,
56
+ "message": message,
57
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
58
+ }
59
+ tmp_path = STATUS_FILE.with_suffix(".tmp")
60
+ tmp_path.write_text(json.dumps(payload), encoding="utf-8")
61
+ tmp_path.replace(STATUS_FILE)
62
+
63
+
64
+ def resolve_backup_repo() -> str:
65
+ global _REPO_ID_CACHE
66
+ if _REPO_ID_CACHE:
67
+ return _REPO_ID_CACHE
68
+
69
+ namespace = HF_USERNAME or SPACE_AUTHOR_NAME
70
+ if not namespace and HF_API is not None:
71
+ whoami = HF_API.whoami()
72
+ namespace = whoami.get("name") or whoami.get("user") or ""
73
+
74
+ namespace = str(namespace).strip()
75
+ if not namespace:
76
+ raise RuntimeError("Could not determine HF username. Set HF_USERNAME or use an account HF_TOKEN.")
77
+
78
+ _REPO_ID_CACHE = f"{namespace}/{BACKUP_DATASET_NAME}"
79
+ return _REPO_ID_CACHE
80
+
81
+
82
+ def ensure_repo_exists() -> str:
83
+ repo_id = resolve_backup_repo()
84
+ try:
85
+ HF_API.repo_info(repo_id=repo_id, repo_type="dataset")
86
+ except RepositoryNotFoundError:
87
+ HF_API.create_repo(repo_id=repo_id, repo_type="dataset", private=True)
88
+ return repo_id
89
+
90
+
91
+ def should_exclude(rel_posix: str, path: Path) -> bool:
92
+ parts = Path(rel_posix).parts
93
+ if not parts:
94
+ return False
95
+ if parts[0] in EXCLUDED_TOP_LEVEL:
96
+ return True
97
+ if any(part in EXCLUDED_DIRS for part in parts):
98
+ return True
99
+ if path.is_file():
100
+ try:
101
+ return path.stat().st_size > MAX_FILE_SIZE_BYTES
102
+ except OSError:
103
+ return True
104
+ return False
105
+
106
+
107
+ def metadata_marker(root: Path) -> tuple[int, int, int]:
108
+ if not root.exists():
109
+ return (0, 0, 0)
110
+ file_count = 0
111
+ total_size = 0
112
+ newest_mtime = 0
113
+ for path in root.rglob("*"):
114
+ if not path.is_file():
115
+ continue
116
+ rel = path.relative_to(root).as_posix()
117
+ if should_exclude(rel, path):
118
+ continue
119
+ try:
120
+ stat = path.stat()
121
+ except OSError:
122
+ continue
123
+ file_count += 1
124
+ total_size += int(stat.st_size)
125
+ newest_mtime = max(newest_mtime, int(stat.st_mtime_ns))
126
+ return (file_count, total_size, newest_mtime)
127
+
128
+
129
+ def fingerprint_dir(root: Path) -> str:
130
+ hasher = hashlib.sha256()
131
+ if not root.exists():
132
+ return hasher.hexdigest()
133
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
134
+ rel = path.relative_to(root).as_posix()
135
+ if should_exclude(rel, path):
136
+ continue
137
+ hasher.update(rel.encode("utf-8"))
138
+ with path.open("rb") as handle:
139
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
140
+ hasher.update(chunk)
141
+ return hasher.hexdigest()
142
+
143
+
144
+ def create_snapshot_dir(source_root: Path) -> Path:
145
+ staging_root = Path(tempfile.mkdtemp(prefix="huggingmess-sync-"))
146
+ for path in sorted(source_root.rglob("*")):
147
+ rel = path.relative_to(source_root)
148
+ rel_posix = rel.as_posix()
149
+ if should_exclude(rel_posix, path):
150
+ continue
151
+ target = staging_root / rel
152
+ if path.is_dir():
153
+ target.mkdir(parents=True, exist_ok=True)
154
+ continue
155
+ target.parent.mkdir(parents=True, exist_ok=True)
156
+ shutil.copy2(path, target)
157
+ return staging_root
158
+
159
+
160
+ def restore() -> bool:
161
+ if not HF_TOKEN:
162
+ write_status("disabled", "HF_TOKEN is not configured.")
163
+ return False
164
+
165
+ repo_id = resolve_backup_repo()
166
+ write_status("restoring", f"Restoring Hermes state from {repo_id}")
167
+ try:
168
+ with tempfile.TemporaryDirectory() as tmpdir:
169
+ snapshot_download(repo_id=repo_id, repo_type="dataset", token=HF_TOKEN, local_dir=tmpdir)
170
+ tmp_path = Path(tmpdir)
171
+ if not any(tmp_path.iterdir()):
172
+ write_status("fresh", "Backup dataset is empty. Starting fresh.")
173
+ return True
174
+
175
+ HERMES_HOME.mkdir(parents=True, exist_ok=True)
176
+ for child in tmp_path.iterdir():
177
+ if should_exclude(child.name, child):
178
+ continue
179
+ target = HERMES_HOME / child.name
180
+ if target.is_dir():
181
+ shutil.rmtree(target, ignore_errors=True)
182
+ elif target.exists():
183
+ target.unlink()
184
+ if child.is_dir():
185
+ shutil.copytree(child, target)
186
+ else:
187
+ shutil.copy2(child, target)
188
+
189
+ write_status("restored", f"Restored Hermes state from {repo_id}")
190
+ return True
191
+ except RepositoryNotFoundError:
192
+ write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
193
+ return True
194
+ except HfHubHTTPError as exc:
195
+ if exc.response is not None and exc.response.status_code == 404:
196
+ write_status("fresh", f"Backup dataset {repo_id} does not exist yet.")
197
+ return True
198
+ write_status("error", f"Restore failed: {exc}")
199
+ print(f"Restore failed: {exc}", file=sys.stderr)
200
+ return False
201
+ except Exception as exc:
202
+ write_status("error", f"Restore failed: {exc}")
203
+ print(f"Restore failed: {exc}", file=sys.stderr)
204
+ return False
205
+
206
+
207
+ def sync_once(last_fingerprint: str | None = None, last_marker: tuple[int, int, int] | None = None):
208
+ if not HF_TOKEN:
209
+ write_status("disabled", "HF_TOKEN is not configured.")
210
+ return (last_fingerprint or "", last_marker or (0, 0, 0))
211
+
212
+ repo_id = ensure_repo_exists()
213
+ current_marker = metadata_marker(HERMES_HOME)
214
+ if last_marker is not None and current_marker == last_marker:
215
+ write_status("synced", "No Hermes state changes detected.")
216
+ return (last_fingerprint or "", current_marker)
217
+
218
+ current_fingerprint = fingerprint_dir(HERMES_HOME)
219
+ if last_fingerprint is not None and current_fingerprint == last_fingerprint:
220
+ write_status("synced", "No Hermes state changes detected.")
221
+ return (last_fingerprint, current_marker)
222
+
223
+ write_status("syncing", f"Uploading Hermes state to {repo_id}")
224
+ snapshot_dir = create_snapshot_dir(HERMES_HOME)
225
+ try:
226
+ try:
227
+ HF_API.upload_large_folder(
228
+ repo_id=repo_id,
229
+ repo_type="dataset",
230
+ folder_path=str(snapshot_dir),
231
+ num_workers=2,
232
+ print_report=False,
233
+ )
234
+ except AttributeError:
235
+ upload_folder(
236
+ folder_path=str(snapshot_dir),
237
+ repo_id=repo_id,
238
+ repo_type="dataset",
239
+ token=HF_TOKEN,
240
+ commit_message=f"HuggingMess sync {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}",
241
+ ignore_patterns=[".git/*", ".git"],
242
+ )
243
+ finally:
244
+ shutil.rmtree(snapshot_dir, ignore_errors=True)
245
+
246
+ write_status("success", f"Uploaded Hermes state to {repo_id}")
247
+ return (current_fingerprint, current_marker)
248
+
249
+
250
+ def handle_signal(_sig, _frame) -> None:
251
+ STOP_EVENT.set()
252
+
253
+
254
+ def loop() -> int:
255
+ signal.signal(signal.SIGTERM, handle_signal)
256
+ signal.signal(signal.SIGINT, handle_signal)
257
+ try:
258
+ repo_id = resolve_backup_repo()
259
+ write_status("configured", f"Backup loop active for {repo_id} with {INTERVAL}s interval.")
260
+ except Exception as exc:
261
+ write_status("error", str(exc))
262
+ print(f"Hermes sync error: {exc}")
263
+ return 1
264
+
265
+ last_fingerprint = fingerprint_dir(HERMES_HOME)
266
+ last_marker = metadata_marker(HERMES_HOME)
267
+ time.sleep(INITIAL_DELAY)
268
+ print(f"Hermes state sync started: every {INTERVAL}s -> {repo_id}")
269
+
270
+ while not STOP_EVENT.is_set():
271
+ try:
272
+ last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
273
+ except Exception as exc:
274
+ write_status("error", f"Sync failed: {exc}")
275
+ print(f"Hermes sync failed: {exc}")
276
+ if STOP_EVENT.wait(INTERVAL):
277
+ break
278
+ return 0
279
+
280
+
281
+ def main() -> int:
282
+ HERMES_HOME.mkdir(parents=True, exist_ok=True)
283
+ if len(sys.argv) < 2:
284
+ return loop()
285
+ command = sys.argv[1]
286
+ if command == "restore":
287
+ return 0 if restore() else 1
288
+ if command == "sync-once":
289
+ try:
290
+ sync_once()
291
+ return 0
292
+ except Exception as exc:
293
+ write_status("error", f"Shutdown sync failed: {exc}")
294
+ print(f"Hermes sync: shutdown sync failed: {exc}")
295
+ return 1
296
+ if command == "loop":
297
+ return loop()
298
+ print(f"Unknown command: {command}", file=sys.stderr)
299
+ return 1
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())
setup-uptimerobot.sh ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ API_URL="https://api.uptimerobot.com/v2"
5
+ API_KEY="${UPTIMEROBOT_API_KEY:-}"
6
+ SPACE_HOST_INPUT="${1:-${SPACE_HOST:-}}"
7
+ STATUS_FILE="/tmp/huggingmess-uptimerobot-status.json"
8
+
9
+ if [ -z "$API_KEY" ]; then
10
+ echo "Missing UPTIMEROBOT_API_KEY."
11
+ exit 1
12
+ fi
13
+
14
+ if [ -z "$SPACE_HOST_INPUT" ]; then
15
+ echo "Missing Space host."
16
+ exit 1
17
+ fi
18
+
19
+ SPACE_HOST_CLEAN="${SPACE_HOST_INPUT#https://}"
20
+ SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN#http://}"
21
+ SPACE_HOST_CLEAN="${SPACE_HOST_CLEAN%%/*}"
22
+ MONITOR_URL="https://${SPACE_HOST_CLEAN}/health"
23
+ MONITOR_NAME="${UPTIMEROBOT_MONITOR_NAME:-HuggingMess ${SPACE_HOST_CLEAN}}"
24
+ INTERVAL="${UPTIMEROBOT_INTERVAL:-300}"
25
+
26
+ MONITORS_RESPONSE=$(curl -sS -X POST "${API_URL}/getMonitors" \
27
+ -d "api_key=${API_KEY}" \
28
+ -d "format=json" \
29
+ -d "logs=0" \
30
+ -d "response_times=0" \
31
+ -d "response_times_limit=1")
32
+
33
+ MONITOR_ID=$(printf '%s' "$MONITORS_RESPONSE" | jq -r --arg url "$MONITOR_URL" '
34
+ (.monitors // []) | map(select(.url == $url)) | first | .id // empty
35
+ ')
36
+
37
+ if [ -n "$MONITOR_ID" ]; then
38
+ printf '{"configured":true,"monitorId":"%s","url":"%s","alreadyExisted":true,"timestamp":"%s"}\n' \
39
+ "$MONITOR_ID" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
40
+ echo "UptimeRobot monitor already exists for ${MONITOR_URL}"
41
+ exit 0
42
+ fi
43
+
44
+ CURL_ARGS=(
45
+ -sS
46
+ -X POST "${API_URL}/newMonitor"
47
+ -d "api_key=${API_KEY}"
48
+ -d "format=json"
49
+ -d "type=1"
50
+ -d "friendly_name=${MONITOR_NAME}"
51
+ -d "url=${MONITOR_URL}"
52
+ -d "interval=${INTERVAL}"
53
+ )
54
+
55
+ if [ -n "${UPTIMEROBOT_ALERT_CONTACTS:-}" ]; then
56
+ CURL_ARGS+=(-d "alert_contacts=${UPTIMEROBOT_ALERT_CONTACTS}")
57
+ fi
58
+
59
+ CREATE_RESPONSE=$(curl "${CURL_ARGS[@]}")
60
+ CREATE_STATUS=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.stat // "fail"')
61
+
62
+ if [ "$CREATE_STATUS" != "ok" ]; then
63
+ printf '{"configured":false,"error":"creation failed","timestamp":"%s"}\n' \
64
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
65
+ echo "Failed to create UptimeRobot monitor."
66
+ printf '%s\n' "$CREATE_RESPONSE"
67
+ exit 1
68
+ fi
69
+
70
+ NEW_ID=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.monitor.id // empty')
71
+ printf '{"configured":true,"monitorId":"%s","url":"%s","timestamp":"%s"}\n' \
72
+ "${NEW_ID:-}" "$MONITOR_URL" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$STATUS_FILE"
73
+ echo "Created UptimeRobot monitor ${NEW_ID:-"(id unavailable)"} for ${MONITOR_URL}"
start.sh ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ umask 0077
5
+
6
+ APP_DIR="${HUGGINGMESS_APP_DIR:-/opt/huggingmess}"
7
+ HERMES_HOME="${HERMES_HOME:-/opt/data}"
8
+ PUBLIC_PORT="${PORT:-7861}"
9
+ GATEWAY_API_PORT="${API_SERVER_PORT:-8642}"
10
+ DASHBOARD_PORT="${DASHBOARD_PORT:-9119}"
11
+ TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"
12
+ SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
13
+ BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmess-backup}"
14
+ CF_PROXY_ENV_FILE="/tmp/huggingmess-cloudflare-proxy.env"
15
+
16
+ export HERMES_HOME
17
+ export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
18
+ export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}"
19
+ export API_SERVER_PORT="$GATEWAY_API_PORT"
20
+ export GATEWAY_HEALTH_URL="${GATEWAY_HEALTH_URL:-http://127.0.0.1:${GATEWAY_API_PORT}}"
21
+ export TELEGRAM_WEBHOOK_PORT
22
+
23
+ if [ -z "${API_SERVER_KEY:-}" ]; then
24
+ if [ -n "${GATEWAY_TOKEN:-}" ]; then
25
+ export API_SERVER_KEY="$GATEWAY_TOKEN"
26
+ else
27
+ API_SERVER_KEY="$(python - <<'PY'
28
+ import secrets
29
+ print(secrets.token_urlsafe(32))
30
+ PY
31
+ )"
32
+ export API_SERVER_KEY
33
+ echo "GATEWAY_TOKEN not set - generated an ephemeral API token for this boot."
34
+ fi
35
+ fi
36
+
37
+ echo ""
38
+ echo " =========================================="
39
+ echo " HuggingMess Hermes Gateway"
40
+ echo " =========================================="
41
+ echo ""
42
+
43
+ mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home}
44
+
45
+ if [ -n "${HF_TOKEN:-}" ]; then
46
+ echo "Restoring Hermes state from HF Dataset..."
47
+ python "$APP_DIR/hermes-sync.py" restore || true
48
+ else
49
+ echo "HF_TOKEN not set - dataset persistence is disabled."
50
+ fi
51
+
52
+ CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
53
+ export CLOUDFLARE_WORKERS_TOKEN
54
+ if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
55
+ export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
56
+ echo "Preparing Cloudflare Telegram proxy..."
57
+ python "$APP_DIR/cloudflare-proxy-setup.py" || true
58
+ if [ -f "$CF_PROXY_ENV_FILE" ]; then
59
+ . "$CF_PROXY_ENV_FILE"
60
+ fi
61
+ fi
62
+
63
+ if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
64
+ export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"
65
+ elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
66
+ export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"
67
+ fi
68
+
69
+ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then
70
+ if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then
71
+ export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"
72
+ fi
73
+ fi
74
+
75
+ if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then
76
+ SECRET_FILE="$HERMES_HOME/.huggingmess-telegram-webhook-secret"
77
+ if [ -f "$SECRET_FILE" ]; then
78
+ export TELEGRAM_WEBHOOK_SECRET
79
+ TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"
80
+ else
81
+ TELEGRAM_WEBHOOK_SECRET="$(python - <<'PY'
82
+ import secrets
83
+ print(secrets.token_hex(32))
84
+ PY
85
+ )"
86
+ printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"
87
+ chmod 600 "$SECRET_FILE"
88
+ export TELEGRAM_WEBHOOK_SECRET
89
+ fi
90
+ fi
91
+
92
+ MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"
93
+ MODEL_FOR_CONFIG="$MODEL_INPUT"
94
+ PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"
95
+ LLM_API_KEY="${LLM_API_KEY:-}"
96
+
97
+ if [ -n "$MODEL_INPUT" ]; then
98
+ MODEL_PREFIX="${MODEL_INPUT%%/*}"
99
+ else
100
+ MODEL_PREFIX=""
101
+ fi
102
+
103
+ case "$MODEL_PREFIX" in
104
+ openrouter)
105
+ [ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"
106
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"
107
+ MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"
108
+ ;;
109
+ huggingface)
110
+ [ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"
111
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"
112
+ MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"
113
+ ;;
114
+ vercel-ai-gateway|ai-gateway)
115
+ [ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"
116
+ [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"
117
+ MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
118
+ ;;
119
+ anthropic)
120
+ [ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"
121
+ ;;
122
+ openai|openai-codex)
123
+ [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
124
+ ;;
125
+ google|gemini)
126
+ [ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"
127
+ ;;
128
+ deepseek)
129
+ [ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"
130
+ ;;
131
+ kimi-coding|moonshot)
132
+ [ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"
133
+ ;;
134
+ minimax)
135
+ [ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"
136
+ ;;
137
+ xiaomi)
138
+ [ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"
139
+ ;;
140
+ zai|z-ai|z.ai|glm)
141
+ [ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"
142
+ ;;
143
+ nvidia)
144
+ [ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"
145
+ ;;
146
+ xai|grok)
147
+ [ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"
148
+ ;;
149
+ kilocode)
150
+ [ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"
151
+ ;;
152
+ opencode-zen)
153
+ [ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"
154
+ ;;
155
+ opencode-go)
156
+ [ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"
157
+ ;;
158
+ esac
159
+
160
+ if [ -n "${CUSTOM_BASE_URL:-}" ]; then
161
+ PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"
162
+ [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
163
+ fi
164
+
165
+ if [ -z "$MODEL_FOR_CONFIG" ]; then
166
+ echo "Missing LLM_MODEL or HERMES_MODEL."
167
+ echo "Add it in HF Spaces -> Settings -> Variables or Secrets."
168
+ exit 1
169
+ fi
170
+
171
+ export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG
172
+ export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
173
+ export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"
174
+ export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"
175
+ export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"
176
+ export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"
177
+ export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"
178
+
179
+ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
180
+ CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"
181
+ export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"
182
+ export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
183
+ fi
184
+
185
+ python - <<'PY'
186
+ import os
187
+ from pathlib import Path
188
+
189
+ import yaml
190
+
191
+ home = Path(os.environ["HERMES_HOME"])
192
+ path = home / "config.yaml"
193
+ try:
194
+ config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
195
+ except FileNotFoundError:
196
+ config = {}
197
+
198
+ model = config.setdefault("model", {})
199
+ model["default"] = os.environ["MODEL_FOR_CONFIG"]
200
+ model["provider"] = os.environ["PROVIDER_FOR_CONFIG"]
201
+
202
+ custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()
203
+ if custom_base:
204
+ model["base_url"] = custom_base.rstrip("/")
205
+ if os.environ.get("CUSTOM_API_KEY"):
206
+ model["api_key"] = os.environ["CUSTOM_API_KEY"]
207
+ try:
208
+ model["context_length"] = int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072"))
209
+ model["max_tokens"] = int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192"))
210
+ except ValueError:
211
+ pass
212
+
213
+ config.setdefault("terminal", {})["cwd"] = os.environ.get("MESSAGING_CWD", str(home / "workspace"))
214
+ config.setdefault("compression", {}).setdefault("enabled", True)
215
+ config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))
216
+
217
+ platforms = config.setdefault("platforms", {})
218
+
219
+ if os.environ.get("TELEGRAM_BOT_TOKEN"):
220
+ telegram = platforms.setdefault("telegram", {})
221
+ telegram["enabled"] = True
222
+ extra = telegram.setdefault("extra", {})
223
+ if os.environ.get("TELEGRAM_BASE_URL"):
224
+ extra["base_url"] = os.environ["TELEGRAM_BASE_URL"]
225
+ extra["base_file_url"] = os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"]
226
+ if os.environ.get("TELEGRAM_ALLOWED_USERS"):
227
+ config.setdefault("telegram", {})["allow_from"] = [
228
+ item.strip()
229
+ for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")
230
+ if item.strip()
231
+ ]
232
+
233
+ path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
234
+ path.chmod(0o600)
235
+ PY
236
+
237
+ echo ""
238
+ echo "Hermes model : ${MODEL_FOR_CONFIG}"
239
+ echo "Provider : ${PROVIDER_FOR_CONFIG}"
240
+ echo "Public port : ${PUBLIC_PORT}"
241
+ if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
242
+ echo "Telegram : enabled"
243
+ if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ]; then
244
+ echo "Telegram mode: webhook (${TELEGRAM_WEBHOOK_URL})"
245
+ else
246
+ echo "Telegram mode: polling"
247
+ fi
248
+ else
249
+ echo "Telegram : not configured"
250
+ fi
251
+ if [ -n "${HF_TOKEN:-}" ]; then
252
+ echo "Backup : ${BACKUP_DATASET} every ${SYNC_INTERVAL}s"
253
+ else
254
+ echo "Backup : disabled"
255
+ fi
256
+ if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
257
+ echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
258
+ fi
259
+ if [ -n "${SPACE_HOST:-}" ]; then
260
+ echo "Space URL : https://${SPACE_HOST}"
261
+ fi
262
+ echo ""
263
+
264
+ graceful_shutdown() {
265
+ echo "Shutting down HuggingMess..."
266
+ if [ -n "${HF_TOKEN:-}" ]; then
267
+ python "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: shutdown sync failed."
268
+ fi
269
+ kill $(jobs -p) 2>/dev/null || true
270
+ exit 0
271
+ }
272
+ trap graceful_shutdown SIGTERM SIGINT
273
+
274
+ node "$APP_DIR/health-server.js" &
275
+ HEALTH_PID=$!
276
+
277
+ if [ -n "${UPTIMEROBOT_API_KEY:-}" ] && [ -n "${SPACE_HOST:-}" ]; then
278
+ echo "Setting up UptimeRobot monitor..."
279
+ bash "$APP_DIR/setup-uptimerobot.sh" "${SPACE_HOST}" || true
280
+ fi
281
+
282
+ if [ -n "${WEBHOOK_URL:-}" ]; then
283
+ python - <<'PY' >/dev/null 2>&1 &
284
+ import json, os, urllib.request
285
+ body = json.dumps({
286
+ "event": "restart",
287
+ "status": "success",
288
+ "message": "HuggingMess Hermes gateway has started.",
289
+ "model": os.environ.get("MODEL_FOR_CONFIG", ""),
290
+ }).encode()
291
+ req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST", headers={"Content-Type": "application/json"})
292
+ urllib.request.urlopen(req, timeout=10).read()
293
+ PY
294
+ fi
295
+
296
+ echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
297
+ (hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
298
+ DASHBOARD_PID=$!
299
+
300
+ echo "Launching Hermes gateway..."
301
+ (hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
302
+ GATEWAY_PID=$!
303
+
304
+ GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
305
+ ready=false
306
+ for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
307
+ if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
308
+ ready=true
309
+ break
310
+ fi
311
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
312
+ break
313
+ fi
314
+ sleep 1
315
+ done
316
+
317
+ if [ "$ready" != "true" ]; then
318
+ echo ""
319
+ echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
320
+ echo "----------------------------------------"
321
+ tail -40 "$HERMES_HOME/logs/gateway.log" || true
322
+ exit 1
323
+ fi
324
+
325
+ if [ -n "${HF_TOKEN:-}" ]; then
326
+ python -u "$APP_DIR/hermes-sync.py" loop &
327
+ fi
328
+
329
+ wait "$GATEWAY_PID"