diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..09a1106 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: + - "main" + paths-ignore: + - "README.md" + pull_request: + branches: + - "main" + paths-ignore: + - "README.md" + workflow_dispatch: + +jobs: + test: + name: Run Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install Python dependencies + run: pip install pyyaml + - name: Run Node.js Tests + run: npm run test:node + - name: Run Python Tests + run: npm run test:python diff --git a/.gitignore b/.gitignore index 06df315..6591596 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ build_*/ # Node node_modules/ +# Python +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ + # OS files .DS_Store Thumbs.db diff --git a/README.md b/README.md index de4b137..fff3649 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,43 @@ DAppNode package for [Hermes Agent](https://hermes-agent.nousresearch.com/) by [ ## Features -- **Multi-LLM Support**: OpenRouter (200+ models), OpenAI, Anthropic, Google Gemini, Ollama (local), Groq, DeepSeek, and more -- **Messaging Gateway**: Telegram, Discord, Slack, WhatsApp, Signal — all from a single process +- **Multi-LLM Support**: DAppNode Nexus (private AI), OpenRouter (200+ models), OpenAI, Anthropic, Google Gemini, Ollama (local), Groq, Mistral AI, DeepSeek, Hugging Face, GitHub Copilot, and custom endpoints +- **Messaging Gateway**: Telegram, WhatsApp, Discord, Slack, Signal — all from a single process - **Self-Improving Skills**: Agent creates and refines skills from experience - **Persistent Memory**: Cross-session recall with user modeling - **Cron Scheduling**: Automated tasks delivered to any platform -- **Setup Wizard**: Web-based configuration for providers, models, and integrations +- **Setup Wizard & Dashboard**: Web-based configuration with live model discovery, auto-login proxy, and built-in system diagnostics +- **Web Terminal**: Built-in browser terminal (`ttyd`) for running `hermes` CLI commands + +## Network Services & Ports + +| Service | Container Port | URL | Description | +|---|---|---|---| +| **Setup Wizard** | `8080` | `http://hermes-agent.dappnode:8080` | Web UI for provider configuration & overview | +| **Hermes Dashboard** | `8081` | `http://hermes-agent.dappnode:8080/dashboard` | Web dashboard for sessions, memory, & skills | +| **Gateway API** | `3000` | `http://hermes-agent.dappnode:3000` | OpenAI-compatible HTTP API & web interface | +| **Web Terminal** | `7681` | `http://hermes-agent.dappnode:7681` | Browser terminal for `hermes` CLI commands | ## Getting Started -1. Install the package from the DAppNode Package Store -2. Open the **Setup Wizard** at `http://hermes-agent.dappnode:8080` to configure your AI provider and API key -3. Open the **Gateway Web UI** at `http://hermes-agent.dappnode:3000` to start chatting +1. Install the package from the DAppNode Package Store. +2. Open the **Setup Wizard** at `http://hermes-agent.dappnode:8080` to configure your AI provider and integrations. +3. Open the **Hermes Dashboard** at `http://hermes-agent.dappnode:8080/dashboard` or run `hermes chat` in the **Web Terminal**. + +## Development & Testing + +Run unit tests for both Node.js and Python hooks: + +```bash +# Run all tests +npm test + +# Run Node.js tests +npm run test:node + +# Run Python bootstrapping & config tests +npm run test:python +``` ## Building @@ -32,6 +57,7 @@ npx @dappnode/dappnodesdk build - [Nous Research](https://nousresearch.com/) - [Upstream Repository](https://github.com/NousResearch/hermes-agent) - [DAppNode SDK](https://docs.dappnode.io/docs/dev/sdk/overview) +- [DAppNode Nexus](https://nexus.dappnode.com/) ## License diff --git a/dappnode/bootstrap-env.py b/dappnode/bootstrap-env.py index 317b421..6263830 100644 --- a/dappnode/bootstrap-env.py +++ b/dappnode/bootstrap-env.py @@ -75,7 +75,10 @@ def write_env(path: Path, lines: list[str], updates: dict[str, str]) -> None: out.append(f"{key}={value}") path.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") - path.chmod(0o600) + try: + path.chmod(0o600) + except OSError: + pass def has_usable_secret(value: str, min_length: int = 16) -> bool: @@ -123,18 +126,22 @@ def repair_profile_env(profile_home: Path, *, is_default: bool) -> dict[str, str return env -def iter_profile_homes() -> list[Path]: - profiles_root = HERMES_HOME / "profiles" +def iter_profile_homes(hermes_home: Path = HERMES_HOME) -> list[Path]: + profiles_root = hermes_home / "profiles" if not profiles_root.is_dir(): return [] return sorted(path for path in profiles_root.iterdir() if path.is_dir()) -def main() -> None: - repair_profile_env(HERMES_HOME, is_default=True) - for profile_home in iter_profile_homes(): +def bootstrap_all(hermes_home: Path = HERMES_HOME) -> None: + repair_profile_env(hermes_home, is_default=True) + for profile_home in iter_profile_homes(hermes_home): repair_profile_env(profile_home, is_default=False) +def main() -> None: + bootstrap_all(HERMES_HOME) + + if __name__ == "__main__": main() diff --git a/dappnode/patch-config.py b/dappnode/patch-config.py index 2513610..07191e3 100644 --- a/dappnode/patch-config.py +++ b/dappnode/patch-config.py @@ -5,6 +5,8 @@ upstream's stage2-hook has seeded config.yaml from cli-config.yaml.example and run its schema migration. Idempotent: safe to run on every boot. """ +from __future__ import annotations + import json import os import secrets @@ -13,13 +15,35 @@ import yaml -hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) -config_path = hermes_home / "config.yaml" -dashboard_login_path = hermes_home / "dashboard-login.txt" -skip_dashboard_auth = os.environ.get("DAPPNODE_SKIP_DASHBOARD_AUTH") == "1" - -def fetch_nexus_context_size(base_url, model_id): +def parse_env_line(line: str) -> tuple[str | None, str | None]: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + return None, None + if stripped.startswith("export "): + stripped = stripped[7:].lstrip() + key, _, value = stripped.partition("=") + key = key.strip() + value = value.strip() + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + return key, value + + +def read_env(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + env: dict[str, str] = {} + for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + key, value = parse_env_line(line) + if key: + env[key] = value or "" + return env + + +def fetch_nexus_context_size(base_url: str, model_id: str) -> int | None: """Return the context_size Nexus reports for model_id, or None. Queries the OpenAI-compatible ``{base_url}/models`` listing, which Nexus @@ -39,9 +63,9 @@ def fetch_nexus_context_size(base_url, model_id): return None -def read_dashboard_password(username): +def read_dashboard_password(dashboard_login_path: Path, username: str) -> str | None: try: - values = {} + values: dict[str, str] = {} for line in dashboard_login_path.read_text(encoding="utf-8").splitlines(): key, _, value = line.partition(":") values[key.strip().lower()] = value.strip() @@ -52,7 +76,7 @@ def read_dashboard_password(username): return None -def write_dashboard_password(username, password): +def write_dashboard_password(dashboard_login_path: Path, username: str, password: str) -> None: dashboard_login_path.write_text( "\n".join( [ @@ -65,10 +89,13 @@ def write_dashboard_password(username, password): ), encoding="utf-8", ) - dashboard_login_path.chmod(0o600) + try: + dashboard_login_path.chmod(0o600) + except OSError: + pass -def has_whatsapp_creds(profile_home): +def has_whatsapp_creds(profile_home: Path) -> bool: candidates = [ profile_home / "platforms" / "whatsapp" / "session" / "creds.json", profile_home / "whatsapp" / "session" / "creds.json", @@ -76,29 +103,51 @@ def has_whatsapp_creds(profile_home): return any(path.is_file() for path in candidates) -def configure_dashboard_auth(config): +def configure_dashboard_auth( + config: dict, + hermes_home: Path, + dashboard_login_path: Path, + skip_dashboard_auth: bool = False, +) -> bool: if skip_dashboard_auth: return False dashboard = config.setdefault("dashboard", {}) basic = dashboard.setdefault("basic_auth", {}) - username = str(basic.get("username") or "").strip() or "dappnode" + env = read_env(hermes_home / ".env") + env_username = env.get("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "").strip() + env_password = env.get("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "").strip() + env_secret = env.get("HERMES_DASHBOARD_BASIC_AUTH_SECRET", "").strip() + + username = env_username or str(basic.get("username") or "").strip() or "dappnode" basic["username"] = username - if not str(basic.get("secret") or "").strip(): + if env_secret: + basic["secret"] = env_secret + elif not str(basic.get("secret") or "").strip(): basic["secret"] = secrets.token_urlsafe(32) - password = read_dashboard_password(username) - has_config_password = bool(str(basic.get("password_hash") or "").strip() or str(basic.get("password") or "").strip()) - if password and has_config_password: - return False + saved_password = read_dashboard_password(dashboard_login_path, username) + has_config_password = bool( + str(basic.get("password_hash") or "").strip() + or str(basic.get("password") or "").strip() + ) - # If Hermes already has only a password hash but DAppNode has no saved - # plaintext credential, the setup wizard cannot perform its auto-login - # handoff. Generate a new DAppNode-managed password and keep both files in - # sync so users are not stranded at the raw dashboard login screen. - password = password or secrets.token_urlsafe(24) + # If the user explicitly provided a new password in .env (e.g. via Setup Wizard), + # treat it as authoritative and sync both config.yaml and dashboard-login.txt. + if env_password: + if has_config_password and saved_password == env_password and basic.get("username") == username: + return False + password = env_password + elif saved_password and has_config_password and basic.get("username") == username: + return False + else: + # If Hermes already has only a password hash but DAppNode has no saved + # plaintext credential, the setup wizard cannot perform its auto-login + # handoff. Generate a new DAppNode-managed password and keep both files in + # sync so users are not stranded at the raw dashboard login screen. + password = saved_password or secrets.token_urlsafe(24) try: from plugins.dashboard_auth.basic import hash_password @@ -111,75 +160,87 @@ def configure_dashboard_auth(config): basic["password_hash"] = "" basic["password"] = password - write_dashboard_password(username, password) + write_dashboard_password(dashboard_login_path, username, password) return True -try: - with open(config_path) as f: - config = yaml.safe_load(f) or {} -except FileNotFoundError: - # Nothing to patch — upstream seeding should have created it, but don't - # fail the boot if it hasn't. - raise SystemExit(0) -except Exception: - config = {} - -# --- Network access: bind the gateway to the LAN on the DAppNode port --- -gw = config.setdefault("gateway", {}) -gw["port"] = 3000 -gw["bind"] = "lan" -cui = gw.setdefault("controlUi", {}) -cui.setdefault("dangerouslyAllowHostHeaderOriginFallback", True) -cui.setdefault("allowInsecureAuth", True) -cui.setdefault("dangerouslyDisableDeviceAuth", True) - -term = config.setdefault("terminal", {}) -term["cwd"] = os.environ.get("HERMES_HOME", "/opt/data") - -generated_dashboard_auth = configure_dashboard_auth(config) - -platforms = config.setdefault("platforms", {}) -if isinstance(platforms, dict): - whatsapp = platforms.setdefault("whatsapp", {}) - if isinstance(whatsapp, dict): - if not has_whatsapp_creds(hermes_home): - whatsapp["enabled"] = False + +def patch_config(hermes_home: Path, skip_dashboard_auth: bool = False) -> dict: + config_path = hermes_home / "config.yaml" + dashboard_login_path = hermes_home / "dashboard-login.txt" + + try: + with open(config_path) as f: + config = yaml.safe_load(f) or {} + except FileNotFoundError: + # Nothing to patch — upstream seeding should have created it, but don't + # fail the boot if it hasn't. + return {} + except Exception: + config = {} + + # --- Network access: bind the gateway to the LAN on the DAppNode port --- + gw = config.setdefault("gateway", {}) + gw["port"] = 3000 + gw["bind"] = "lan" + cui = gw.setdefault("controlUi", {}) + cui.setdefault("dangerouslyAllowHostHeaderOriginFallback", True) + cui.setdefault("allowInsecureAuth", True) + cui.setdefault("dangerouslyDisableDeviceAuth", True) + + term = config.setdefault("terminal", {}) + term["cwd"] = str(hermes_home) + + generated_dashboard_auth = configure_dashboard_auth( + config, hermes_home, dashboard_login_path, skip_dashboard_auth + ) + + platforms = config.setdefault("platforms", {}) + if isinstance(platforms, dict): + whatsapp = platforms.setdefault("whatsapp", {}) + if isinstance(whatsapp, dict): + if not has_whatsapp_creds(hermes_home): + whatsapp["enabled"] = False + else: + whatsapp.setdefault("enabled", False) + extra = whatsapp.setdefault("extra", {}) + if isinstance(extra, dict) and extra.get("bridge_port") in (None, 3000, "3000"): + extra["bridge_port"] = 3010 + + with open(config_path, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + + dashboard_auth_status = "skipped" if skip_dashboard_auth else "basic" + msg = f"Patched config.yaml for DAppNode (api_port=3000, dashboard_auth={dashboard_auth_status}, whatsapp_bridge_port=3010)" + if generated_dashboard_auth: + msg += f"; dashboard credentials saved to {dashboard_login_path}" + print(msg) + + # --- Nexus context length: source the real value from /v1/models --- + model_section = config.setdefault("model", {}) + provider = model_section.get("provider", "") + base_url = str(model_section.get("base_url", "")) + model_id = model_section.get("default") or model_section.get("model") or "" + + if provider == "custom" and "nexus-api.dappnode.com" in base_url and model_id: + ctx = fetch_nexus_context_size(base_url, model_id) + if ctx and model_section.get("context_length") != ctx: + model_section["context_length"] = ctx + with open(config_path, "w") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + print(f"Nexus: set model.context_length={ctx} for '{model_id}' (from /v1/models)") + elif ctx: + print(f"Nexus: model.context_length already {ctx} for '{model_id}', leaving as-is") else: - whatsapp.setdefault("enabled", False) - extra = whatsapp.setdefault("extra", {}) - if isinstance(extra, dict) and extra.get("bridge_port") in (None, 3000, "3000"): - extra["bridge_port"] = 3010 - -with open(config_path, "w") as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) -dashboard_auth_status = "skipped" if skip_dashboard_auth else "basic" -msg = f"Patched config.yaml for DAppNode (api_port=3000, dashboard_auth={dashboard_auth_status}, whatsapp_bridge_port=3010)" -if generated_dashboard_auth: - msg += "; dashboard credentials saved to /opt/data/dashboard-login.txt" -print(msg) - -# --- Nexus context length: source the real value from /v1/models --- -# nexus-api.dappnode.com is not in Hermes' URL-to-provider map, so the agent -# cannot auto-detect a model's context window and falls back to 256K. Rather -# than hardcode a single number (wrong for the smaller models -- e.g. Kimi is -# 262K, MiniMax M2.7 is 205K), query the endpoint Nexus already exposes: -# GET /v1/models returns `context_size` per model. Set model.context_length to -# that authoritative value for the configured model. -model_section = config.setdefault("model", {}) -provider = model_section.get("provider", "") -base_url = str(model_section.get("base_url", "")) -model_id = model_section.get("default") or model_section.get("model") or "" - -if provider == "custom" and "nexus-api.dappnode.com" in base_url and model_id: - ctx = fetch_nexus_context_size(base_url, model_id) - if ctx and model_section.get("context_length") != ctx: - model_section["context_length"] = ctx - with open(config_path, "w") as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) - print(f"Nexus: set model.context_length={ctx} for '{model_id}' (from /v1/models)") - elif ctx: - print(f"Nexus: model.context_length already {ctx} for '{model_id}', leaving as-is") - else: - # Endpoint unreachable or model not listed. Leave context_length alone: - # Hermes' own 256K fallback is safe (under-, never over-estimating). - print(f"Nexus: could not resolve context_size for '{model_id}'; using Hermes default") + print(f"Nexus: could not resolve context_size for '{model_id}'; using Hermes default") + + return config + + +def main() -> None: + hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) + skip_dashboard_auth = os.environ.get("DAPPNODE_SKIP_DASHBOARD_AUTH") == "1" + patch_config(hermes_home, skip_dashboard_auth=skip_dashboard_auth) + + +if __name__ == "__main__": + main() diff --git a/dappnode_package.json b/dappnode_package.json index 523a0c4..924a06f 100644 --- a/dappnode_package.json +++ b/dappnode_package.json @@ -18,7 +18,7 @@ "Developer tools", "Communications" ], - "description": "Hermes Agent is a self-improving AI agent built by Nous Research. It features a built-in learning loop — creating skills from experience, improving them during use, and building a deepening model of who you are across sessions.\n\n- **Web Gateway**: Full-featured web interface for interacting with AI\n- **Multi-LLM Support**: OpenRouter, OpenAI, Anthropic, Google Gemini, Ollama, Groq, and more\n- **Messaging Gateway**: Telegram, Discord, Slack, WhatsApp, Signal — all from a single process\n- **Skills System**: Agent-curated procedural memory that self-improves\n- **Persistent Memory**: Cross-session recall with user modeling\n- **Cron Scheduling**: Automated tasks with delivery to any platform\n- **Subagent Delegation**: Spawn isolated subagents for parallel workstreams\n\nRun your own AI agent with full control over your data and API keys.", + "description": "Hermes Agent is a self-improving AI agent built by Nous Research. It features a built-in learning loop — creating skills from experience, improving them during use, and building a deepening model of who you are across sessions.\n\n- **Web Gateway**: Full-featured web interface for interacting with AI\n- **Multi-LLM Support**: OpenRouter, OpenAI, Anthropic, Google Gemini, Ollama, Groq, Mistral, and more\n- **Messaging Gateway**: Telegram, Discord, Slack, WhatsApp, Signal — all from a single process\n- **Skills System**: Agent-curated procedural memory that self-improves\n- **Persistent Memory**: Cross-session recall with user modeling\n- **Cron Scheduling**: Automated tasks with delivery to any platform\n- **Subagent Delegation**: Spawn isolated subagents for parallel workstreams\n\nRun your own AI agent with full control over your data and API keys.", "exposable": [ { "description": "OpenAI-compatible API for programmatic access to Hermes AI agent", @@ -32,6 +32,12 @@ "port": 8080, "serviceName": "hermes-agent" }, + { + "description": "Direct access to Hermes Agent Web Dashboard (password protected)", + "name": "Hermes Agent Dashboard", + "port": 8081, + "serviceName": "hermes-agent" + }, { "description": "Browser-based terminal for running hermes CLI commands", "name": "Hermes Web Terminal", @@ -53,6 +59,7 @@ "telegram", "discord", "whatsapp", + "slack", "skills" ], "license": "MIT", diff --git a/getting-started.md b/getting-started.md index 8b29950..ed58cfe 100644 --- a/getting-started.md +++ b/getting-started.md @@ -1,9 +1,9 @@ # Hermes Agent -A **self-hosted AI agent** by [Nous Research](https://nousresearch.com) that runs on your DAppNode. Hermes learns from experience, remembers you across sessions, and connects to Telegram, Discord, Slack, and more — all from a single process. +A **self-hosted AI agent** by [Nous Research](https://nousresearch.com) that runs on your DAppNode. Hermes learns from experience, remembers you across sessions, and connects to Telegram, WhatsApp, Discord, Slack, and more — all from a single process. ## Getting started -1. **Set up a provider** — Open the [Setup Wizard](http://hermes-agent.dappnode:8080) and pick an AI provider. -2. **Talk to Hermes** — Message your bot on Telegram/Discord, run `hermes chat` in the [Web Terminal](http://hermes-agent.dappnode:7681), or connect any OpenAI-compatible client to `http://hermes-agent.dappnode:3000`. -3. **Manage your agent** — The [Dashboard](http://hermes-agent.dappnode:8080/dashboard) lets you view sessions, manage API keys, configure skills, set up scheduled tasks, and check logs. +1. **Set up a provider** — Open the [Setup Wizard](http://hermes-agent.dappnode:8080) to select an AI provider (such as DAppNode Nexus, OpenRouter, OpenAI, Anthropic, Google Gemini, Groq, Mistral, or local Ollama). +2. **Talk to Hermes** — Message your bot on Telegram, Discord, or Slack, run `hermes chat` in the [Web Terminal](http://hermes-agent.dappnode:7681), or connect any OpenAI-compatible client to `http://hermes-agent.dappnode:3000`. +3. **Manage your agent** — The [Dashboard](http://hermes-agent.dappnode:8080/dashboard) lets you view sessions, manage API keys, configure skills, set up scheduled tasks, check logs, and run diagnostics. diff --git a/package.json b/package.json new file mode 100644 index 0000000..c95ff9a --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "dappnode-package-hermes-agent", + "version": "0.1.7", + "description": "DAppNode package wrapper for Hermes Agent by Nous Research", + "private": true, + "scripts": { + "start": "node setup-wizard/server.cjs", + "test:node": "node --test test/*.test.js", + "test:python": "python3 -m unittest discover -s test -p \"test_*.py\"", + "test": "npm run test:node && npm run test:python" + }, + "keywords": [ + "dappnode", + "hermes-agent", + "ai", + "llm" + ], + "author": "DAppNode Association ", + "license": "MIT" +} diff --git a/setup-wizard/index.html b/setup-wizard/index.html index 0e97b77..89054ef 100644 --- a/setup-wizard/index.html +++ b/setup-wizard/index.html @@ -111,7 +111,7 @@ } .page { - max-width: 720px; + max-width: 760px; margin: 0 auto; padding: 2rem 1rem; } @@ -160,6 +160,36 @@ color: var(--warning); } + .diagnostics-panel { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1.5rem; + } + + .diagnostics-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + } + + .diagnostics-output { + background: #000; + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.82rem; + line-height: 1.5; + max-height: 250px; + overflow-y: auto; + white-space: pre-wrap; + color: var(--text); + display: none; + } + .quick-links { display: flex; flex-direction: column; @@ -297,7 +327,7 @@ background: linear-gradient(135deg, rgba(255, 215, 0, 0.08), rgba(255, 215, 0, 0.02)); border-color: var(--primary); position: relative; - padding: 1.25rem 1.25rem 1.25rem 1.25rem; + padding: 1.25rem; } .card.featured:hover { @@ -379,6 +409,22 @@ margin-left: 6px; } + .integration-section { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1.25rem; + } + + .integration-section h3 { + font-size: 1rem; + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 8px; + } + .model-input-wrap { position: relative; } @@ -641,6 +687,21 @@

Agent Overview

+ + +
+
+
+ System Diagnostics +
Run Hermes doctor to verify AI providers, database, and skills
+
+ +
+

+      
+

Quick Links