diff --git a/README.md b/README.md index 6520247..d7191b4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # nix-hermes -Nix package and NixOS module for [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research. +Declarative Nix package and NixOS module for [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research. + +Everything is configured in Nix. Config, documents, secrets, service — one `nixos-rebuild switch` and it's live. ## Quick Start -### Flake usage +### 1. Add to your flake ```nix { @@ -14,60 +16,196 @@ Nix package and NixOS module for [Hermes Agent](https://github.com/NousResearch/ nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { modules = [ nix-hermes.nixosModules.hermes-agent - { - services.hermes-agent = { - enable = true; - environmentFile = "/run/secrets/hermes-env"; # API keys - }; - } + ./hermes.nix # your config (see below) ]; }; }; } ``` -### Just the package +### 2. Configure declaratively -```bash -nix run github:0xrsydn/nix-hermes -- --help +```nix +# hermes.nix +{ ... }: +{ + services.hermes-agent = { + enable = true; + + # ── Declarative config (renders to cli-config.yaml) ── + config = { + model = { + default = "anthropic/claude-opus-4.6"; + provider = "openrouter"; + }; + terminal = { + backend = "local"; + timeout = 180; + lifetime_seconds = 300; + }; + agent = { + max_turns = 60; + reasoning_effort = "medium"; + }; + memory = { + memory_enabled = true; + user_profile_enabled = true; + memory_char_limit = 2200; + nudge_interval = 10; + }; + compression = { + enabled = true; + threshold = 0.85; + summary_model = "google/gemini-3-flash-preview"; + }; + toolsets = [ "all" ]; + }; + + # ── Secrets (not in Nix store) ── + environmentFiles = [ + "/run/secrets/hermes-env" # ANTHROPIC_API_KEY, TELEGRAM_TOKEN, etc. + ]; + + # ── Non-secret env vars ── + environment = { + LLM_MODEL = "anthropic/claude-opus-4.6"; + }; + + # ── Workspace documents (inline or file paths) ── + documents = { + "SOUL.md" = '' + # SOUL.md + You are a sharp, pragmatic AI assistant. + ''; + "AGENTS.md" = '' + # AGENTS.md + Read SOUL.md first. Then help the user. + ''; + "USER.md" = '' + # USER.md + Name: Your Human + ''; + # Or reference a file: + # "SOUL.md" = ./documents/SOUL.md; + }; + + # ── MCP servers ── + mcpServers = { + context7 = { + command = "npx"; + args = [ "-y" "@upstash/context7-mcp@latest" ]; + }; + }; + + # ── Extra tools on PATH ── + extraPackages = with pkgs; [ jq ripgrep curl ]; + }; +} ``` -### NixOS module options +### 3. Create secrets file + +```bash +# /run/secrets/hermes-env (or wherever you manage secrets) +OPENROUTER_API_KEY=sk-or-... +ANTHROPIC_API_KEY=sk-ant-... +TELEGRAM_TOKEN=123456:ABC... +TELEGRAM_ALLOWED_USERS=your_user_id +``` + +### 4. Deploy + +```bash +nixos-rebuild switch +systemctl status hermes-agent +journalctl -u hermes-agent -f +``` + +## Architecture + +``` +You (Telegram/Discord/WhatsApp/Slack) → Gateway → Tools → Machine does things +``` + +### How it works + +1. `services.hermes-agent.config` attrset is deep-merged and rendered to `cli-config.yaml` +2. Documents are installed into the workspace directory +3. Secrets stay outside the Nix store via `environmentFiles` +4. systemd service runs `hermes gateway` with everything wired up + +### Directory layout + +``` +/var/lib/hermes/ # stateDir +├── .hermes/ # Hermes home (HERMES_HOME) +│ ├── cli-config.yaml # Generated from config option +│ ├── .env # Secrets (from environmentFiles) +│ ├── memory/ # Agent memory (runtime) +│ ├── skills/ # Skills (runtime) +│ └── logs/ # Session logs +├── workspace/ # workingDirectory +│ ├── SOUL.md # From documents option +│ ├── AGENTS.md +│ └── USER.md +└── logs/ + └── gateway.log # Service log +``` + +## Module Options | Option | Type | Default | Description | |--------|------|---------|-------------| -| `enable` | bool | `false` | Enable the Hermes Agent gateway service | +| `enable` | bool | `false` | Enable Hermes Agent gateway | +| `config` | attrset | `{}` | Declarative config (→ cli-config.yaml) | +| `configFile` | path | `null` | Use existing config file (overrides `config`) | +| `documents` | attrset | `{}` | Workspace files (string or path values) | +| `environmentFiles` | list | `[]` | Secret env files (systemd EnvironmentFile) | +| `environment` | attrset | `{}` | Non-secret env vars | +| `mcpServers` | attrset | `{}` | MCP server configs (merged into config) | | `user` | string | `"hermes"` | Service user | | `group` | string | `"hermes"` | Service group | -| `homeDir` | path | `/var/lib/hermes` | State directory | -| `workDir` | path | `${homeDir}/workspace` | Working directory | -| `environmentFile` | path | `null` | Secrets file (API keys, tokens) | -| `extraEnvironment` | attrs | `{}` | Extra env vars | -| `extraArgs` | list | `[]` | Extra CLI args for `hermes gateway` | +| `stateDir` | path | `/var/lib/hermes` | State directory | +| `workingDirectory` | path | `${stateDir}/workspace` | Working directory | | `extraPackages` | list | `[]` | Extra packages on PATH | +| `extraArgs` | list | `[]` | Extra `hermes gateway` args | +| `logPath` | path | `${stateDir}/logs/gateway.log` | Log file | +| `restart` | string | `"always"` | systemd Restart policy | +| `restartSec` | int | `5` | Restart delay | -### Environment file example +## Config Reference + +The `config` attrset maps directly to Hermes' `cli-config.yaml`. Key sections: + +| Section | Purpose | +|---------|---------| +| `model` | Default model, provider, base_url | +| `terminal` | Backend (local/ssh/docker/modal), cwd, timeout | +| `agent` | max_turns, verbose, reasoning_effort, personalities | +| `memory` | memory_enabled, user_profile_enabled, char limits | +| `compression` | Context compression settings | +| `session_reset` | Auto-reset policy for messaging | +| `skills` | Skill creation nudge settings | +| `toolsets` | Which tool groups to enable | +| `mcp_servers` | MCP server connections | +| `delegation` | Subagent settings | +| `browser` | Browser tool settings | +| `stt` | Voice transcription config | +| `display` | UI/skin settings | + +See the [full config reference](https://raw.githubusercontent.com/NousResearch/hermes-agent/main/cli-config.yaml.example). + +## Just the Package ```bash -ANTHROPIC_API_KEY=sk-ant-... -OPENROUTER_API_KEY=sk-or-... -TELEGRAM_TOKEN=123456:ABC... -OPENAI_API_KEY=sk-... -``` +# Run directly +nix run github:0xrsydn/nix-hermes -- --help -## What's included +# In a dev shell +nix develop github:0xrsydn/nix-hermes -- **`hermes`** — Interactive CLI -- **`hermes-agent`** — Agent runner -- **`hermes-acp`** — ACP adapter -- **NixOS module** — systemd service for gateway mode -- Runtime deps wrapped: Node.js 22, ripgrep, ffmpeg, git - -## Development - -```bash -nix develop # Enter dev shell with hermes on PATH -nix build # Build the package +# Use the overlay +nixpkgs.overlays = [ nix-hermes.overlays.default ]; ``` ## License diff --git a/module.nix b/module.nix index c5a9e12..b336e1e 100644 --- a/module.nix +++ b/module.nix @@ -4,122 +4,329 @@ self: let cfg = config.services.hermes-agent; hermes-agent = self.packages.${pkgs.system}.hermes-agent; + + # Deep-merge config type (same pattern as nix-openclaw) + deepConfigType = lib.types.mkOptionType { + name = "hermes-config-attrs"; + description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate."; + check = builtins.isAttrs; + merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs); + }; + + # Convert Nix attrset → YAML via JSON intermediary + # (Hermes reads YAML but YAML is a superset of JSON, so JSON works) + configJson = builtins.toJSON cfg.config; + generatedConfigFile = pkgs.writeText "cli-config.yaml" configJson; + configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile; + + # Generate .env file from environment attrset (non-secret env vars) + envFileContent = lib.concatStringsSep "\n" ( + lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment + ); + generatedEnvFile = pkgs.writeText "hermes-env" envFileContent; + + # Document files → symlinked into workspace + documentDerivation = pkgs.runCommand "hermes-documents" { } ( + '' + mkdir -p $out + '' + lib.concatStringsSep "\n" ( + lib.mapAttrsToList (name: value: + if builtins.isPath value || lib.isStorePath value + then "cp ${value} $out/${name}" + else "cat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF" + ) cfg.documents + ) + ); + in { - options.services.hermes-agent = { - enable = lib.mkEnableOption "Hermes Agent gateway service"; + options.services.hermes-agent = with lib; { + enable = mkEnableOption "Hermes Agent gateway service"; - package = lib.mkOption { - type = lib.types.package; + # ── Package ────────────────────────────────────────────────────────── + package = mkOption { + type = types.package; default = hermes-agent; description = "The hermes-agent package to use."; }; - user = lib.mkOption { - type = lib.types.str; - default = "hermes"; - description = "User under which Hermes Agent runs."; + # ── Service identity ───────────────────────────────────────────────── + unitName = mkOption { + type = types.str; + default = "hermes-agent"; + description = "systemd unit name (.service)."; }; - group = lib.mkOption { - type = lib.types.str; + user = mkOption { + type = types.str; default = "hermes"; - description = "Group under which Hermes Agent runs."; + description = "System user running the gateway."; }; - homeDir = lib.mkOption { - type = lib.types.path; + group = mkOption { + type = types.str; + default = "hermes"; + description = "System group running the gateway."; + }; + + createUser = mkOption { + type = types.bool; + default = true; + description = "Create the user/group automatically."; + }; + + # ── Directories ────────────────────────────────────────────────────── + stateDir = mkOption { + type = types.str; default = "/var/lib/hermes"; - description = "Home directory for Hermes Agent state."; + description = "State directory (HERMES_HOME base). Contains .hermes/ subdir."; }; - workDir = lib.mkOption { - type = lib.types.path; - default = "${cfg.homeDir}/workspace"; - defaultText = lib.literalExpression ''"''${cfg.homeDir}/workspace"''; - description = "Working directory for Hermes Agent."; + workingDirectory = mkOption { + type = types.str; + default = "${cfg.stateDir}/workspace"; + defaultText = literalExpression ''"''${cfg.stateDir}/workspace"''; + description = "Working directory for the agent (MESSAGING_CWD)."; }; - environmentFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; + # ── Config (declarative YAML) ──────────────────────────────────────── + configFile = mkOption { + type = types.nullOr types.path; default = null; description = '' - Path to an environment file containing secrets (API keys, tokens). - This file should contain lines like: - ANTHROPIC_API_KEY=sk-... - TELEGRAM_TOKEN=... - OPENROUTER_API_KEY=... + Path to an existing cli-config.yaml. If set, takes precedence over + the declarative `config` option. ''; }; - extraEnvironment = lib.mkOption { - type = lib.types.attrsOf lib.types.str; + config = mkOption { + type = deepConfigType; default = { }; - description = "Extra environment variables for the Hermes Agent service."; - example = lib.literalExpression '' + description = '' + Declarative Hermes config (attrset). Deep-merged across module definitions + and rendered as cli-config.yaml. See upstream cli-config.yaml.example for + all available keys. + ''; + example = literalExpression '' { - HERMES_DEFAULT_MODEL = "anthropic/claude-sonnet-4-20250514"; + model = { + default = "anthropic/claude-sonnet-4-20250514"; + provider = "openrouter"; + }; + terminal = { + backend = "local"; + timeout = 180; + }; + agent = { + max_turns = 60; + reasoning_effort = "medium"; + }; + memory = { + memory_enabled = true; + user_profile_enabled = true; + }; + toolsets = [ "all" ]; } ''; }; - extraArgs = lib.mkOption { - type = lib.types.listOf lib.types.str; + # ── Secrets / environment ──────────────────────────────────────────── + environmentFiles = mkOption { + type = types.listOf types.str; default = [ ]; - description = "Extra command-line arguments to pass to hermes gateway."; + description = '' + Paths to environment files containing secrets (API keys, tokens). + These are passed as systemd EnvironmentFile= entries. + Use leading '-' to ignore missing files. + + Example file contents: + ANTHROPIC_API_KEY=sk-ant-... + OPENROUTER_API_KEY=sk-or-... + TELEGRAM_TOKEN=123456:ABC... + ''; }; - extraPackages = lib.mkOption { - type = lib.types.listOf lib.types.package; + environment = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Non-secret environment variables for Hermes. These are written to + an env file (visible in the Nix store — do NOT put secrets here). + Use `environmentFiles` for secrets. + ''; + example = literalExpression '' + { + LLM_MODEL = "anthropic/claude-opus-4.6"; + MESSAGING_CWD = "/var/lib/hermes/workspace"; + } + ''; + }; + + # ── Documents (SOUL.md, AGENTS.md, etc.) ───────────────────────────── + documents = mkOption { + type = types.attrsOf (types.either types.str types.path); + default = { }; + description = '' + Workspace document files. Keys are filenames, values are either + inline strings or paths to files. These are symlinked into the + workspace directory on activation. + ''; + example = literalExpression '' + { + "SOUL.md" = ''' + # SOUL.md + You are a helpful AI assistant. + '''; + "AGENTS.md" = ./my-agents.md; + "USER.md" = ''' + # USER.md + Name: Fay + '''; + } + ''; + }; + + # ── Service behavior ───────────────────────────────────────────────── + logPath = mkOption { + type = types.str; + default = "${cfg.stateDir}/logs/gateway.log"; + defaultText = literalExpression ''"''${cfg.stateDir}/logs/gateway.log"''; + description = "Log file path."; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra command-line arguments for `hermes gateway`."; + }; + + execStart = mkOption { + type = types.nullOr types.str; + default = null; + description = "Override ExecStart command. If unset, runs: hermes gateway."; + }; + + extraPackages = mkOption { + type = types.listOf types.package; default = [ ]; description = "Extra packages to make available on PATH."; }; + + restart = mkOption { + type = types.str; + default = "always"; + description = "systemd Restart= policy."; + }; + + restartSec = mkOption { + type = types.int; + default = 5; + description = "systemd RestartSec= value."; + }; + + # ── MCP servers ────────────────────────────────────────────────────── + mcpServers = mkOption { + type = types.attrsOf (types.submodule { + options = { + command = mkOption { type = types.str; description = "MCP server command."; }; + args = mkOption { type = types.listOf types.str; default = [ ]; }; + env = mkOption { type = types.attrsOf types.str; default = { }; }; + timeout = mkOption { type = types.nullOr types.int; default = null; }; + }; + }); + default = { }; + description = "MCP server configurations (merged into config.mcp_servers)."; + }; }; config = lib.mkIf cfg.enable { - users.users.${cfg.user} = { - isSystemUser = true; - group = cfg.group; - home = cfg.homeDir; - createHome = true; - description = "Hermes Agent service user"; + # ── Merge MCP servers into config ──────────────────────────────────── + services.hermes-agent.config = lib.mkIf (cfg.mcpServers != { }) { + mcp_servers = lib.mapAttrs (_name: srv: + { inherit (srv) command args; } + // lib.optionalAttrs (srv.env != { }) { inherit (srv) env; } + // lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; } + ) cfg.mcpServers; }; - users.groups.${cfg.group} = { }; + # ── User / group ───────────────────────────────────────────────────── + users.groups.${cfg.group} = lib.mkIf cfg.createUser { }; + users.users.${cfg.user} = lib.mkIf cfg.createUser { + isSystemUser = true; + group = cfg.group; + home = cfg.stateDir; + createHome = true; + shell = pkgs.bashInteractive; + }; - systemd.services.hermes-agent = { + # ── Directories ────────────────────────────────────────────────────── + systemd.tmpfiles.rules = [ + "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -" + "d ${builtins.dirOf cfg.logPath} 0750 ${cfg.user} ${cfg.group} - -" + ]; + + # ── Activation: link config + documents into state dir ─────────────── + system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] '' + # Link config file + install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/cli-config.yaml + + # Link documents into workspace + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: '' + install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name} + '') cfg.documents)} + ''; + + # ── systemd service ────────────────────────────────────────────────── + systemd.services.${cfg.unitName} = { description = "Hermes Agent Gateway"; + wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; - wantedBy = [ "multi-user.target" ]; environment = { - HOME = cfg.homeDir; - HERMES_HOME = "${cfg.homeDir}/.hermes"; - } // cfg.extraEnvironment; + HOME = cfg.stateDir; + HERMES_HOME = "${cfg.stateDir}/.hermes"; + MESSAGING_CWD = cfg.workingDirectory; + }; serviceConfig = { - Type = "simple"; User = cfg.user; Group = cfg.group; - WorkingDirectory = cfg.workDir; - ExecStart = lib.concatStringsSep " " ([ - "${cfg.package}/bin/hermes" - "gateway" - ] ++ cfg.extraArgs); - Restart = "on-failure"; - RestartSec = 5; + WorkingDirectory = cfg.workingDirectory; + + EnvironmentFile = + [ generatedEnvFile ] + ++ cfg.environmentFiles; + + ExecStart = + if cfg.execStart != null then cfg.execStart + else lib.concatStringsSep " " ([ + "${cfg.package}/bin/hermes" + "gateway" + ] ++ cfg.extraArgs); + + Restart = cfg.restart; + RestartSec = cfg.restartSec; + + StandardOutput = "append:${cfg.logPath}"; + StandardError = "append:${cfg.logPath}"; + # Hardening NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = false; - ReadWritePaths = [ cfg.homeDir ]; + ReadWritePaths = [ cfg.stateDir ]; PrivateTmp = true; - } // lib.optionalAttrs (cfg.environmentFile != null) { - EnvironmentFile = cfg.environmentFile; }; - path = [ cfg.package ] ++ cfg.extraPackages; + path = [ + cfg.package + pkgs.bash + pkgs.coreutils + pkgs.git + ] ++ cfg.extraPackages; }; }; }