Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
+554
View File
@@ -0,0 +1,554 @@
# nix/checks.nix — Build-time verification tests
#
# Checks are Linux-only: the full Python venv (via uv2nix) includes
# transitive deps like onnxruntime that lack compatible wheels on
# aarch64-darwin. The package and devShell still work on macOS.
{ inputs, ... }: {
perSystem = { pkgs, lib, self', ... }:
let
hermes-agent = self'.packages.default;
hermesVenv = hermes-agent.hermesVenv;
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
# Auto-generated config key reference — always in sync with Python
configKeys = pkgs.runCommand "hermes-config-keys" {} ''
set -euo pipefail
export HOME=$TMPDIR
${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.config import DEFAULT_CONFIG
def leaf_paths(d, prefix=""):
paths = []
for k, v in sorted(d.items()):
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict) and v:
paths.extend(leaf_paths(v, path))
else:
paths.append(path)
return paths
json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
' > $out
'';
in {
packages.configKeys = configKeys;
checks = {
# Cross-platform evaluation — catches "not supported for interpreter"
# errors (e.g. sphinx dropping python311) without needing a darwin builder.
# Evaluation is pure and instant; it doesn't build anything.
cross-eval = let
targetSystems = builtins.filter
(s: inputs.self.packages ? ${s})
[ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
tryEvalPkg = sys:
let pkg = inputs.self.packages.${sys}.default;
in builtins.tryEval (builtins.seq pkg.drvPath true);
results = map (sys: { inherit sys; result = tryEvalPkg sys; }) targetSystems;
failures = builtins.filter (r: !r.result.success) results;
failMsg = lib.concatMapStringsSep "\n" (r: " - ${r.sys}") failures;
in pkgs.runCommand "hermes-cross-eval" { } (
if failures != [] then
throw "Package fails to evaluate on:\n${failMsg}"
else ''
echo "PASS: package evaluates on all ${toString (builtins.length targetSystems)} platforms"
mkdir -p $out
echo "ok" > $out/result
''
);
# Verify the default package builds successfully (cross-platform).
# On Linux the runtime checks below already depend on the package,
# but this ensures darwin builders also build it during flake check.
build-package = pkgs.runCommand "hermes-build-package" { } ''
echo "PASS: package built at ${hermes-agent}"
mkdir -p $out
echo "ok" > $out/result
'';
# Verify the devShell builds successfully (cross-platform).
build-devshell = pkgs.runCommand "hermes-build-devshell" { } ''
echo "PASS: devShell built at ${self'.devShells.default}"
mkdir -p $out
echo "ok" > $out/result
'';
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# Verify binaries exist and are executable
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
set -e
echo "=== Checking binaries ==="
test -x ${hermes-agent}/bin/hermes || (echo "FAIL: hermes binary missing"; exit 1)
test -x ${hermes-agent}/bin/hermes-agent || (echo "FAIL: hermes-agent binary missing"; exit 1)
echo "PASS: All binaries present"
echo "=== Checking version ==="
${hermes-agent}/bin/hermes version 2>&1 | grep -qi "hermes" || (echo "FAIL: version check"; exit 1)
echo "PASS: Version check"
echo "=== All checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify every pyproject.toml [project.scripts] entry has a wrapped binary
entry-points-sync = pkgs.runCommand "hermes-entry-points-sync" { } ''
set -e
echo "=== Checking entry points match pyproject.toml [project.scripts] ==="
for bin in hermes hermes-agent hermes-acp; do
test -x ${hermes-agent}/bin/$bin || (echo "FAIL: $bin binary missing from Nix package"; exit 1)
echo "PASS: $bin present"
done
mkdir -p $out
echo "ok" > $out/result
'';
# Verify CLI subcommands are accessible
cli-commands = pkgs.runCommand "hermes-cli-commands" { } ''
set -e
export HOME=$(mktemp -d)
echo "=== Checking hermes --help ==="
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "gateway" || (echo "FAIL: gateway subcommand missing"; exit 1)
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "config" || (echo "FAIL: config subcommand missing"; exit 1)
echo "PASS: All subcommands accessible"
echo "=== All CLI checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled skills are present in the package
bundled-skills = pkgs.runCommand "hermes-bundled-skills" { } ''
set -e
echo "=== Checking bundled skills ==="
test -d ${hermes-agent}/share/hermes-agent/skills || (echo "FAIL: skills directory missing"; exit 1)
echo "PASS: skills directory exists"
SKILL_COUNT=$(find ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
test "$SKILL_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files found in skills directory"; exit 1)
echo "PASS: $SKILL_COUNT bundled skills found"
grep -q "HERMES_BUNDLED_SKILLS" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_BUNDLED_SKILLS not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_SKILLS set in wrapper"
echo "=== All bundled skills checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled plugins (platforms, memory, context_engine) are present
bundled-plugins = pkgs.runCommand "hermes-bundled-plugins" { } ''
set -e
echo "=== Checking bundled plugins ==="
test -d ${hermes-agent}/share/hermes-agent/plugins || (echo "FAIL: plugins directory missing"; exit 1)
echo "PASS: plugins directory exists"
test -f ${hermes-agent}/share/hermes-agent/plugins/platforms/irc/plugin.yaml || \
(echo "FAIL: irc plugin manifest missing"; exit 1)
echo "PASS: irc plugin manifest present"
grep -q "HERMES_BUNDLED_PLUGINS" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_BUNDLED_PLUGINS not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_PLUGINS set in wrapper"
echo "=== All bundled plugins checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled i18n locale catalogs are present and resolvable.
# Regression for #23943 / #27632 / #35374 — sealed Nix venvs dropped
# locales/, surfacing raw i18n keys like gateway.reset.header_default.
bundled-locales = pkgs.runCommand "hermes-bundled-locales" { } ''
set -e
echo "=== Checking bundled locales ==="
test -d ${hermes-agent}/share/hermes-agent/locales || (echo "FAIL: locales directory missing"; exit 1)
echo "PASS: locales directory exists"
LOC_COUNT=$(find ${hermes-agent}/share/hermes-agent/locales -name "*.yaml" | wc -l)
test "$LOC_COUNT" -ge 16 || (echo "FAIL: expected >=16 catalogs, found $LOC_COUNT"; exit 1)
echo "PASS: $LOC_COUNT locale catalogs found"
test -f ${hermes-agent}/share/hermes-agent/locales/en.yaml || (echo "FAIL: en.yaml missing"; exit 1)
echo "PASS: en.yaml present"
grep -q "HERMES_BUNDLED_LOCALES" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_BUNDLED_LOCALES not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_LOCALES set in wrapper"
echo "=== Rendering via the wrapper override (HERMES_BUNDLED_LOCALES) ==="
export HOME=$(mktemp -d)
RENDERED=$(cd "$HOME" && HERMES_BUNDLED_LOCALES=${hermes-agent}/share/hermes-agent/locales \
${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))")
echo "rendered: $RENDERED"
test "$RENDERED" != "gateway.reset.header_default" || (echo "FAIL: i18n returned the raw key with HERMES_BUNDLED_LOCALES set"; exit 1)
echo "PASS: i18n renders a human string via the wrapper override"
# Defense-in-depth check: the sealed venv must ALSO resolve catalogs
# with NO env var, via the wheel's setuptools data-files materialized
# into the venv data scheme. If a future uv2nix bump drops data-files,
# the wrapper override above would mask the regression at runtime while
# `pip install`/other sealed paths silently break this catches it.
echo "=== Rendering WITHOUT the env var (data-files materialization) ==="
BARE_DIR=$(cd "$HOME" && ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n._locales_dir())")
BARE=$(cd "$HOME" && ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))")
echo "resolved dir (no env var): $BARE_DIR"
echo "rendered: $BARE"
test "$BARE" != "gateway.reset.header_default" || \
(echo "FAIL: sealed venv could not resolve locales without HERMES_BUNDLED_LOCALES data-files materialization regressed"; exit 1)
echo "PASS: sealed venv resolves locales via data-files without the env var"
echo "=== All bundled locales checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled TUI is present and compiled
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
set -e
echo "=== Checking bundled TUI ==="
test -d ${hermes-agent}/ui-tui || (echo "FAIL: ui-tui directory missing"; exit 1)
echo "PASS: ui-tui directory exists"
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
echo "PASS: compiled entry.js present"
# self-contained bundle; no runtime node_modules expected
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)
echo "PASS: HERMES_TUI_DIR set in wrapper"
echo "=== All bundled TUI checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify HERMES_NODE is set in wrapper and points to Node 20+
# (string-width uses the /v regex flag which requires Node 20+)
hermes-node = pkgs.runCommand "hermes-node-version" { } ''
set -e
echo "=== Checking HERMES_NODE in wrapper ==="
grep -q "HERMES_NODE" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_NODE not set in wrapper"; exit 1)
echo "PASS: HERMES_NODE present in wrapper"
HERMES_NODE=$(sed -n "s/^export HERMES_NODE='\(.*\)'/\1/p" ${hermes-agent}/bin/hermes)
test -x "$HERMES_NODE" || (echo "FAIL: HERMES_NODE=$HERMES_NODE not executable"; exit 1)
echo "PASS: HERMES_NODE executable at $HERMES_NODE"
NODE_MAJOR=$("$HERMES_NODE" --version | sed 's/^v//' | cut -d. -f1)
test "$NODE_MAJOR" -ge 20 || \
(echo "FAIL: Node v$NODE_MAJOR < 20, TUI needs /v regex flag support"; exit 1)
echo "PASS: Node v$NODE_MAJOR >= 20"
echo "=== All HERMES_NODE checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify HERMES_MANAGED guard works on all mutation commands
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
set -e
export HOME=$(mktemp -d)
check_blocked() {
local label="$1"
shift
OUTPUT=$(HERMES_MANAGED=true "$@" 2>&1 || true)
echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1)
echo "PASS: $label blocked in managed mode"
}
echo "=== Checking HERMES_MANAGED guards ==="
check_blocked "config set" ${hermes-agent}/bin/hermes config set model foo
check_blocked "config edit" ${hermes-agent}/bin/hermes config edit
echo "=== All guard checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify extraPythonPackages PYTHONPATH injection
extra-python-packages = let
testPkg = pkgs.python312Packages.pyfiglet;
hermesWithExtra = hermes-agent.override {
extraPythonPackages = [ testPkg ];
};
in pkgs.runCommand "hermes-extra-python-packages" { } ''
set -e
echo "=== Checking extraPythonPackages PYTHONPATH injection ==="
grep -q "PYTHONPATH" ${hermesWithExtra}/bin/hermes || \
(echo "FAIL: PYTHONPATH not in wrapper"; exit 1)
echo "PASS: PYTHONPATH present in wrapper"
grep -q "${testPkg}" ${hermesWithExtra}/bin/hermes || \
(echo "FAIL: test package path not in PYTHONPATH"; exit 1)
echo "PASS: test package path found in wrapper"
echo "=== Checking base package has no PYTHONPATH ==="
if grep -q "PYTHONPATH" ${hermes-agent}/bin/hermes; then
echo "FAIL: base package should not have PYTHONPATH"; exit 1
fi
echo "PASS: base package clean"
echo "=== All extraPythonPackages checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify extraDependencyGroups passes through to python.nix
extra-dependency-groups = let
hermesWithGroups = hermes-agent.override {
extraDependencyGroups = [ "honcho" ];
};
in pkgs.runCommand "hermes-extra-dependency-groups" { } ''
set -e
echo "=== Checking extraDependencyGroups override evaluates ==="
# Eval-only: verify the override produces valid derivation paths
# without building the full venv (which is expensive and redundant
# since the mechanism is just list concatenation into python.nix).
echo "derivation: ${hermesWithGroups}"
echo "venv: ${hermesWithGroups.hermesVenv}"
echo "PASS: extraDependencyGroups override evaluates cleanly"
echo "=== All extraDependencyGroups checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Regression guard: messaging deps live outside [all], so the
# #messaging variant must actually ship discord.py — otherwise
# `nix profile install .#messaging` regresses to the broken default.
messaging-variant = pkgs.runCommand "hermes-messaging-variant" { } ''
set -e
echo "=== Checking discord.py importable from messaging variant ==="
${self'.packages.messaging.hermesVenv}/bin/python3 -c \
"import discord; print(discord.__version__)"
echo "PASS: discord.py importable from messaging variant venv"
mkdir -p $out
echo "ok" > $out/result
'';
# ── Config merge + round-trip test ────────────────────────────────
# Tests the merge script (Nix activation behavior) across 7
# scenarios, then verifies Python's load_config() reads correctly.
config-roundtrip = let
# Nix settings used across scenarios
nixSettings = pkgs.writeText "nix-settings.json" (builtins.toJSON {
model = "test/nix-model";
toolsets = ["nix-toolset"];
terminal = { backend = "docker"; timeout = 999; };
mcp_servers = {
nix-server = { command = "echo"; args = ["nix"]; };
};
});
# Pre-built YAML fixtures for each scenario
fixtureB = pkgs.writeText "fixture-b.yaml" ''
model: "old-model"
mcp_servers:
old-server:
url: "http://old"
'';
fixtureC = pkgs.writeText "fixture-c.yaml" ''
skills:
disabled:
- skill-a
- skill-b
session_reset:
mode: idle
idle_minutes: 30
streaming:
enabled: true
fallback_model:
provider: openrouter
model: test-fallback
'';
fixtureD = pkgs.writeText "fixture-d.yaml" ''
model: "user-model"
skills:
disabled:
- skill-x
streaming:
enabled: true
transport: edit
'';
fixtureE = pkgs.writeText "fixture-e.yaml" ''
mcp_servers:
user-server:
url: "http://user-mcp"
nix-server:
command: "old-cmd"
args: ["old"]
'';
fixtureF = pkgs.writeText "fixture-f.yaml" ''
terminal:
cwd: "/user/path"
custom_key: "preserved"
env_passthrough:
- USER_VAR
'';
in pkgs.runCommand "hermes-config-roundtrip" {
nativeBuildInputs = [ pkgs.jq ];
} ''
set -e
export HOME=$(mktemp -d)
ERRORS=""
fail() { ERRORS="$ERRORS\nFAIL: $1"; }
# Helper: run merge then load with Python, output merged JSON
merge_and_load() {
local hermes_home="$1"
export HERMES_HOME="$hermes_home"
${configMergeScript} ${nixSettings} "$hermes_home/config.yaml"
${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.config import load_config
json.dump(load_config(), sys.stdout, default=str)
'
}
#
# Scenario A: Fresh install no existing config.yaml
#
echo "=== Scenario A: Fresh install ==="
A_HOME=$(mktemp -d)
A_CONFIG=$(merge_and_load "$A_HOME")
echo "$A_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "A: model not set from Nix"
echo "$A_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|| fail "A: MCP nix-server missing"
echo "PASS: Scenario A"
#
# Scenario B: Nix keys override existing values
#
echo "=== Scenario B: Nix overrides ==="
B_HOME=$(mktemp -d)
install -m 0644 ${fixtureB} "$B_HOME/config.yaml"
B_CONFIG=$(merge_and_load "$B_HOME")
echo "$B_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "B: Nix model did not override"
echo "PASS: Scenario B"
#
# Scenario C: User-only keys preserved
#
echo "=== Scenario C: User keys preserved ==="
C_HOME=$(mktemp -d)
install -m 0644 ${fixtureC} "$C_HOME/config.yaml"
C_CONFIG=$(merge_and_load "$C_HOME")
echo "$C_CONFIG" | jq -e '.skills.disabled == ["skill-a", "skill-b"]' > /dev/null \
|| fail "C: skills.disabled not preserved"
echo "$C_CONFIG" | jq -e '.session_reset.mode == "idle"' > /dev/null \
|| fail "C: session_reset.mode not preserved"
echo "$C_CONFIG" | jq -e '.session_reset.idle_minutes == 30' > /dev/null \
|| fail "C: session_reset.idle_minutes not preserved"
echo "$C_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|| fail "C: streaming.enabled not preserved"
echo "$C_CONFIG" | jq -e '.fallback_model.provider == "openrouter"' > /dev/null \
|| fail "C: fallback_model not preserved"
echo "PASS: Scenario C"
#
# Scenario D: Mixed Nix wins for its keys, user keys preserved
#
echo "=== Scenario D: Mixed merge ==="
D_HOME=$(mktemp -d)
install -m 0644 ${fixtureD} "$D_HOME/config.yaml"
D_CONFIG=$(merge_and_load "$D_HOME")
echo "$D_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "D: Nix model did not override user model"
echo "$D_CONFIG" | jq -e '.skills.disabled == ["skill-x"]' > /dev/null \
|| fail "D: user skills not preserved"
echo "$D_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|| fail "D: user streaming not preserved"
echo "PASS: Scenario D"
#
# Scenario E: MCP additive merge
#
echo "=== Scenario E: MCP additive merge ==="
E_HOME=$(mktemp -d)
install -m 0644 ${fixtureE} "$E_HOME/config.yaml"
E_CONFIG=$(merge_and_load "$E_HOME")
echo "$E_CONFIG" | jq -e '.mcp_servers."user-server".url == "http://user-mcp"' > /dev/null \
|| fail "E: user MCP server not preserved"
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|| fail "E: Nix MCP server did not override same-name user server"
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".args == ["nix"]' > /dev/null \
|| fail "E: Nix MCP server args wrong"
echo "PASS: Scenario E"
#
# Scenario F: Nested deep merge
#
echo "=== Scenario F: Nested deep merge ==="
F_HOME=$(mktemp -d)
install -m 0644 ${fixtureF} "$F_HOME/config.yaml"
F_CONFIG=$(merge_and_load "$F_HOME")
echo "$F_CONFIG" | jq -e '.terminal.backend == "docker"' > /dev/null \
|| fail "F: Nix terminal.backend did not override"
echo "$F_CONFIG" | jq -e '.terminal.timeout == 999' > /dev/null \
|| fail "F: Nix terminal.timeout did not override"
echo "$F_CONFIG" | jq -e '.terminal.custom_key == "preserved"' > /dev/null \
|| fail "F: terminal.custom_key not preserved"
echo "$F_CONFIG" | jq -e '.terminal.cwd == "/user/path"' > /dev/null \
|| fail "F: user terminal.cwd not preserved when Nix does not set it"
echo "$F_CONFIG" | jq -e '.terminal.env_passthrough == ["USER_VAR"]' > /dev/null \
|| fail "F: user terminal.env_passthrough not preserved"
echo "PASS: Scenario F"
#
# Scenario G: Idempotency merging twice yields the same result
#
echo "=== Scenario G: Idempotency ==="
G_HOME=$(mktemp -d)
install -m 0644 ${fixtureD} "$G_HOME/config.yaml"
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
FIRST=$(cat "$G_HOME/config.yaml")
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
SECOND=$(cat "$G_HOME/config.yaml")
if [ "$FIRST" != "$SECOND" ]; then
fail "G: second merge produced different output"
echo "--- first ---"
echo "$FIRST"
echo "--- second ---"
echo "$SECOND"
fi
echo "PASS: Scenario G"
#
# Report
#
if [ -n "$ERRORS" ]; then
echo ""
echo "FAILURES:"
echo -e "$ERRORS"
exit 1
fi
echo ""
echo "=== All 7 merge scenarios passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
};
};
}
+33
View File
@@ -0,0 +1,33 @@
# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
#
# Used by the NixOS module activation script and by checks.nix tests.
# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
{ pkgs }:
pkgs.writeScript "hermes-config-merge" ''
#!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
import json, yaml, sys
from pathlib import Path
nix_json, config_path = sys.argv[1], Path(sys.argv[2])
with open(nix_json) as f:
nix = json.load(f)
existing = {}
if config_path.exists():
with open(config_path) as f:
existing = yaml.safe_load(f) or {}
def deep_merge(base, override):
result = dict(base)
for k, v in override.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = v
return result
merged = deep_merge(existing, nix)
with open(config_path, "w") as f:
yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
''
+151
View File
@@ -0,0 +1,151 @@
# nix/desktop.nix — Hermes Desktop (Electron) app build + wrapper
#
# `hermesAgent` is the fully-built `.#default` package — it ships the
# `hermes` binary with the venv, runtime PATH, bundled skills/plugins, etc.
# already wired up. We point the desktop at it via the existing
# `HERMES_DESKTOP_HERMES` override env var, so the desktop's resolver
# uses our fully wrapped binary at step 4 ("existing Hermes CLI").
# No reimplementation of the agent resolution in this wrapper.
{
pkgs,
lib,
stdenv,
makeWrapper,
hermesNpmLib,
electron,
hermesAgent,
...
}:
let
npm = hermesNpmLib.mkNpmPassthru {
folder = "apps/desktop";
attr = "desktop";
pname = "hermes-desktop";
};
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (
npm
// {
pname = "hermes-desktop-renderer";
inherit version;
doCheck = true;
buildPhase = ''
runHook preBuild
# write-build-stamp.cjs replacement. Packaged Electron reads this
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
# patch shebangs in node_modules/.bin so npm exec can find the
# nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox)
patchShebangs .
pushd apps/desktop
# stage node-pty native binaries into build/native-deps for the final nix output
npm rebuild node-pty --build-from-source
node scripts/stage-native-deps.cjs
npm exec tsc -b
npm exec vite build
popd
runHook postBuild
'';
checkPhase = ''
runHook preCheck
pushd apps/desktop
npm run postbuild
# validate staged node-pty native binary is present
STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node"
if [ ! -f "$STAGED_PTY_NODE" ]; then
echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE"
echo "node-pty must be compiled natively"
exit 1
fi
popd
runHook postCheck
'';
installPhase = ''
runHook preInstall
mkdir -p $out
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
# apps/desktop/build was created before the cd. electron/ is source.
cp -rn apps/desktop/dist $out/
cp -rn apps/desktop/electron $out/
# flatten native-deps and install-stamp.json to the root level, exactly like
# electron-builder's extraResources does ("from": "build/native-deps", "to": "native-deps")
# so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty'
cp -rn apps/desktop/build/native-deps $out/
cp -n apps/desktop/build/install-stamp.json $out/
cp -n apps/desktop/package.json $out/
runHook postInstall
'';
}
);
in
# Electron wrapper: nixpkgs' electron binary pointed at the renderer dir.
stdenv.mkDerivation {
pname = "hermes-desktop";
inherit version;
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/hermes-desktop $out/bin
cp -r ${renderer}/* $out/share/hermes-desktop/
# Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath
# to point to the app's directory. In Nix, unpackaged electron defaults this
# to the electron distribution's resources path, breaking extraResources lookups.
substituteInPlace $out/share/hermes-desktop/electron/main.cjs \
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
# Wrap the nixpkgs electron binary to launch our app. Set
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
# PATH") uses our fully wrapped binary venv with all deps,
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
# No reimplementation of the agent resolver in the wrapper.
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
--add-flags "$out/share/hermes-desktop" \
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
--set ELECTRON_IS_DEV 0
runHook postInstall
'';
passthru = {
inherit (renderer.passthru) packageJsonPath;
};
meta = with lib; {
description = "Native Electron desktop shell for Hermes Agent";
homepage = "https://github.com/NousResearch/hermes-agent";
license = licenses.mit;
platforms = platforms.unix;
mainProgram = "hermes-desktop";
};
}
+41
View File
@@ -0,0 +1,41 @@
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
# "ui-tui/package.json"). This file collects them all and passes the
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
# then runs a single `npm i --package-lock-only` if any changed and
# `npm ci` if the lockfile changed.
{ ... }:
{
perSystem =
{ pkgs, self', ... }:
let
packages = builtins.attrValues self'.packages;
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
# Collect all packageJsonPath values from npm workspace packages.
npmPackageJsonPaths = builtins.filter (p: p != null) (
map (p: p.passthru.packageJsonPath or null) packages
);
# Non-npm packages may have their own devShellHook (e.g. hermes-agent
# stamps pyproject.toml + uv.lock for Python venv setup).
nonNpmHooks = map (p: p.passthru.devShellHook or "") packages;
combinedNonNpm = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") nonNpmHooks);
in
{
devShells.default = pkgs.mkShell {
inputsFrom = packages;
packages = with pkgs; [
uv
];
shellHook = ''
echo "Hermes Agent dev shell"
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
echo "Ready. Run 'hermes' to start."
'';
};
};
}
+250
View File
@@ -0,0 +1,250 @@
# nix/hermes-agent.nix — Overridable Hermes Agent package
#
# callPackage auto-wires nixpkgs args; flake inputs are passed explicitly.
# Users override via:
# pkgs.hermes-agent.override { extraPythonPackages = [...]; }
# pkgs.hermes-agent.override { extraDependencyGroups = [ "hindsight" ]; }
{
lib,
stdenv,
makeWrapper,
callPackage,
python312,
nodejs_22,
electron,
ripgrep,
git,
openssh,
ffmpeg,
tirith,
# linux-only deps
wl-clipboard,
xclip,
# Flake inputs — passed explicitly by packages.nix and overlays.nix
uv2nix,
pyproject-nix,
pyproject-build-systems,
npm-lockfile-fix,
# Locked git revision of the flake source — embedded so banner.py can
# check for updates without needing a local .git directory. Null for
# impure / dirty builds where flakes can't determine a rev.
rev ? null,
# Overridable parameters
extraPythonPackages ? [ ],
extraDependencyGroups ? [ ],
}:
let
nodejs = nodejs_22;
hermesVenv = callPackage ./python.nix {
inherit uv2nix pyproject-nix pyproject-build-systems;
dependency-groups = [ "all" ] ++ extraDependencyGroups;
};
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix nodejs;
};
hermesTui = callPackage ./tui.nix {
inherit hermesNpmLib;
};
hermesWeb = callPackage ./web.nix {
inherit hermesNpmLib;
};
bundledSkills = lib.cleanSourceWith {
src = ../skills;
filter = path: _type: !(lib.hasInfix "/index-cache/" path);
};
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
# them out of the Python site-packages keeps import semantics identical
# to a dev checkout — the loader reads them from HERMES_BUNDLED_PLUGINS.
bundledPlugins = lib.cleanSourceWith {
src = ../plugins;
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
};
# i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed
# at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human
# strings instead of raw i18n keys (#23943 / #27632 / #35374).
#
# Defense-in-depth, not load-bearing: the wheel already declares locales/ as
# setuptools data-files, so uv2nix materializes them into the venv's data
# scheme and agent/i18n.py resolves them with no env var. The wrapper override
# pins the store path so a future uv2nix change that drops data-files can't
# silently ship raw keys via `nix build` (checks don't run on a plain build).
# The bundled-locales flake check verifies BOTH paths independently.
#
# Plain cleanSource (no __pycache__ filter): locales/ is bare *.yaml, never
# compiled, so it never carries a __pycache__ dir to exclude.
bundledLocales = lib.cleanSource ../locales;
runtimeDeps = [
nodejs
ripgrep
git
openssh
ffmpeg
tirith
]
++ lib.optionals stdenv.isLinux [
wl-clipboard
xclip
];
runtimePath = lib.makeBinPath runtimeDeps;
sitePackagesPath = python312.sitePackages;
# Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH.
# Without this, a plugin listing e.g. requests as a dep would fail at runtime
# if requests isn't already in the sealed uv2nix venv.
allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages;
pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages;
pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml);
uvLockHash =
if builtins.pathExists ../uv.lock then
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
else
"none";
checkPackageCollisions = ''
import pathlib, sys, re
def canonical(name):
return re.sub(r'[-_.]+', '-', name).lower()
# Collect core venv package names
core = set()
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
for di in venv_sp.glob('*.dist-info'):
meta = di / 'METADATA'
if meta.exists():
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
core.add(canonical(line.split(':', 1)[1].strip()))
break
# Check each extra package for collisions
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
for edir in extras_dirs:
sp = pathlib.Path(edir) / '${sitePackagesPath}'
if not sp.exists():
continue
for di in sp.glob('*.dist-info'):
meta = di / 'METADATA'
if not meta.exists():
continue
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
pkg = canonical(line.split(':', 1)[1].strip())
if pkg in core:
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
print(f' from: {di}', file=sys.stderr)
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
sys.exit(1)
break
print('No collisions found.')
'';
in
stdenv.mkDerivation (finalAttrs: {
pname = "hermes-agent";
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/hermes-agent $out/bin
cp -r ${bundledSkills} $out/share/hermes-agent/skills
cp -r ${bundledPlugins} $out/share/hermes-agent/plugins
cp -r ${bundledLocales} $out/share/hermes-agent/locales
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
mkdir -p $out/ui-tui
cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/
${lib.concatMapStringsSep "\n"
(name: ''
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--suffix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
--set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${lib.getExe nodejs} \
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
'')
[
"hermes"
"hermes-agent"
"hermes-acp"
]
}
${lib.optionalString (extraPythonPackages != [ ]) ''
echo "=== Checking for plugin/core package collisions ==="
${hermesVenv}/bin/python3 -c "${checkPackageCollisions}"
echo "=== No collisions ==="
''}
runHook postInstall
'';
passthru = {
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
# derivation, after all overrides are applied) so the desktop wrapper
# can prepend its `/bin` to PATH. The desktop's resolver step 4
# ("existing hermes on PATH") then picks up the fully wrapped
# `hermes` binary — venv with all deps, bundled skills/plugins,
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
# of the agent resolution in the desktop wrapper.
hermesDesktop = callPackage ./desktop.nix {
inherit hermesNpmLib electron;
hermesAgent = finalAttrs.finalPackage;
};
devShellHook = ''
STAMP=".nix-stamps/hermes-agent"
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "hermes-agent: installing Python dependencies..."
uv venv .venv --python ${python312}/bin/python3 2>/dev/null || true
source .venv/bin/activate
uv pip install -e ".[all]"
[ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true
mkdir -p .nix-stamps
echo "$STAMP_VALUE" > "$STAMP"
else
source .venv/bin/activate
export HERMES_PYTHON=${hermesVenv}/bin/python3
fi
'';
};
meta = with lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
mainProgram = "hermes";
license = licenses.mit;
platforms = platforms.unix;
};
})
+329
View File
@@ -0,0 +1,329 @@
# nix/lib.nix — Shared helpers for nix stuff
#
# All npm packages in this repo are workspace members sharing a single
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
# duplicate them. One hash to rule them all.
#
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
# instead of a per-package devShellHook. The root devshell hook
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
# and if any changed, runs a single `npm i --package-lock-only` from
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
let
# The workspace root — where the single package-lock.json lives.
src = ../.;
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-RLraluZYEWfg1cP4SFDlMo2qJ4eHWVkmQevMGThvxHA=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
fetcherVersion = 2;
hash = npmDepsHash;
};
in
{
# Returns a buildNpmPackage-compatible attrs set that provides:
# src, npmDeps, npmRoot, npmDepsFetcherVersion
# patchPhase — ensures root lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
# npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing
# newlines the lockfile has. The patchPhase normalizes to exactly one
# trailing newline so both sides always match.
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
# pkgs.buildNpmPackage (npm // {
# sourceRoot = "ui-tui";
# buildPhase = '' ... '';
# installPhase = '' ... '';
# })
mkNpmPassthru =
{
folder, # repo-relative folder with package.json, e.g. "ui-tui"
attr, # flake package attr, e.g. "tui"
...
}:
let
# No sourceRoot — the workspace root (with the single package-lock.json)
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
# so npmConfigHook finds the lockfile there.
in
{
inherit src npmDeps nodejs;
npmRoot = ".";
npmDepsFetcherVersion = 2;
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
patchPhase = ''
runHook prePatch
# Normalize trailing newlines on the root lockfile so source and
# npm-deps always match, regardless of what fetchNpmDeps preserves.
sed -i -z 's/\\n*$/\\n/' package-lock.json
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
# replacing its hardcoded /nix/store/.../diff with a wrapper that
# normalizes trailing newlines on both sides before comparing.
mkdir -p "$TMPDIR/bin"
cat > "$TMPDIR/bin/diff" << DIFFWRAP
#!/bin/sh
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
rm -f "\\$f1" "\\$f2"
exit \\$rc
DIFFWRAP
chmod +x "$TMPDIR/bin/diff"
export PATH="$TMPDIR/bin:$PATH"
runHook postPatch
'';
nativeBuildInputs = [
(pkgs.writeShellScriptBin "update_${attr}_lockfile" ''
set -euox pipefail
REPO_ROOT=$(git rev-parse --show-toplevel)
# All workspace packages share the root lockfile.
cd "$REPO_ROOT"
rm -rf node_modules/
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
# Hash lives in lib.nix just rebuild to verify.
nix build .#${attr}
echo "Lockfile updated and build verified for .#${attr}"
'')
];
passthru = {
packageJsonPath = "${folder}/package.json";
};
};
# Single devshell hook for all npm workspace packages.
#
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
# stamps all of them, and if any changed:
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
#
# fixLockfilesExe: absolute path to the fix-lockfiles binary
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
mkNpmDevShellHook =
packageJsonPaths: fixLockfilesExe:
pkgs.writeShellScript "npm-dev-hook" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
# Stamp all workspace package.jsons into one file.
STAMP_DIR=".nix-stamps"
STAMP="$STAMP_DIR/npm-package-jsons"
STAMP_VALUE=$(
${pkgs.coreutils}/bin/sha256sum ${
pkgs.lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
} 2>/dev/null | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/sha256sum | awk '{print $1}'
)
PKG_CHANGED=false
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
PKG_CHANGED=true
echo "npm: package.json changed, updating lockfile..."
( cd "$REPO_ROOT" && ${pkgs.lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$STAMP_VALUE" > "$STAMP"
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
'';
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
# fix-lockfiles --check # exit 1 if any hash is stale
# fix-lockfiles --apply # rewrite stale hashes in place
# fix-lockfiles # alias of --apply
# Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT
# when set, so CI workflows can post a sticky PR comment directly.
mkFixLockfiles =
{
attr, # flake package attr for fallback verification build, e.g. "tui"
}:
pkgs.writeShellScriptBin "fix-lockfiles" ''
set -uox pipefail
MODE="''${1:---apply}"
case "$MODE" in
--check|--apply) ;;
-h|--help)
echo "usage: fix-lockfiles [--check|--apply]"
exit 0 ;;
*)
echo "usage: fix-lockfiles [--check|--apply]" >&2
exit 2 ;;
esac
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
# When running in GH Actions, emit Markdown links in the report pointing
# at the offending line of the nix file (and the lockfile) at the exact
# commit that was checked. LINK_SHA should be set by the workflow to the
# PR head SHA; falls back to GITHUB_SHA (which on pull_request is the
# test-merge commit, still browseable).
LINK_SERVER="''${GITHUB_SERVER_URL:-https://github.com}"
LINK_REPO="''${GITHUB_REPOSITORY:-}"
LINK_SHA="''${LINK_SHA:-''${GITHUB_SHA:-}}"
STALE=0
FIXED=0
REPORT=""
# All workspace packages share the root package-lock.json, so
# we only need to check the hash once.
LOCK_FILE="package-lock.json"
LIB_FILE="nix/lib.nix"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
echo "prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "ok (via nix build)"
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
exit 0
fi
echo "build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
| sed -E 's/npmDepsHash = "(.*)"/\1/')
# prefetch-npm-deps says the hash already matches but it only hashes the
# lockfile *contents* and can disagree with fetchNpmDeps + npmConfigHook,
# which validate the full source lockfile against the realized deps cache.
# Trusting prefetch alone produced false "ok" results while the actual
# build was broken (e.g. lockfile engines/os/cpu fields the pinned nixpkgs
# strips from the deps cache, tripping npmConfigHook). So when prefetch
# claims the hash is current, confirm with a real consumer build before
# believing it.
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
if VERIFY_OUT=$(nix build ".#${attr}" --no-link --print-build-logs 2>&1); then
echo "ok"
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{ echo "stale=false"; echo "changed=false"; } >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# Build failed despite a matching hash. A fixed-output 'got:' means
# prefetch genuinely disagreed with fetchNpmDeps adopt the real hash
# and fall through to the stale-handling path below.
CORRECT_HASH=$(echo "$VERIFY_OUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps reported current ($OLD_HASH) but fetchNpmDeps wants $CORRECT_HASH" >&2
NEW_HASH="$CORRECT_HASH"
elif echo "$VERIFY_OUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$VERIFY_OUT" | tail -8 >&2
exit 0
else
# Not a stale-hash problem surface it honestly instead of "ok".
echo "::error::nix build .#${attr} failed and it is NOT a stale npmDepsHash (no 'got:' hash in output)." >&2
echo "The committed lockfile may be incompatible with the pinned nixpkgs" >&2
echo "(e.g. engines/os/cpu fields that prefetch-npm-deps strips from the" >&2
echo "deps cache, tripping npmConfigHook). fix-lockfiles cannot repair this." >&2
echo "$VERIFY_OUT" | tail -40 >&2
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{ echo "stale=false"; echo "changed=false"; } >> "$GITHUB_OUTPUT"
fi
exit 1
fi
fi
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
else
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` \`$NEW_HASH\`"$'\\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
# the lockfile contents, not the full source tree). Extract the
# correct hash from the nix build error and retry.
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH retrying" >&2
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
echo "verification build failed after hash retry" >&2
exit 1
fi
NEW_HASH="$CORRECT_HASH"
else
echo "verification build failed after hash update" >&2
exit 1
fi
fi
FIXED=1
echo "fixed"
fi
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{
[ "$STALE" -eq 1 ] && echo "stale=true" || echo "stale=false"
[ "$FIXED" -eq 1 ] && echo "changed=true" || echo "changed=false"
if [ -n "$REPORT" ]; then
echo "report<<REPORT_EOF"
printf "%s" "$REPORT"
echo "REPORT_EOF"
fi
} >> "$GITHUB_OUTPUT"
fi
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
echo
echo "Stale lockfile hash detected. Run:"
echo " nix run .#fix-lockfiles"
exit 1
fi
exit 0
'';
}
+1010
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs
{ inputs, ... }:
{
flake.overlays.default = final: _: {
hermes-agent = final.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;
rev = inputs.self.rev or null;
};
};
}
+57
View File
@@ -0,0 +1,57 @@
# nix/packages.nix — Hermes Agent package built with uv2nix
{ inputs, ... }:
{
perSystem =
{ pkgs, lib, inputs', ... }:
let
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
# Only embed clean revs — dirtyRev doesn't represent any upstream
# commit, so comparing it would always claim "update available".
rev = inputs.self.rev or null;
};
in
{
packages = {
default = hermesAgent;
# Ships discord.py + python-telegram-bot + slack-sdk so a plain
# `nix profile install .#messaging` connects to Discord/Telegram/Slack
# on first run — lazy-install can't write to the read-only /nix/store.
messaging = hermesAgent.override {
extraDependencyGroups = [ "messaging" ];
};
# All platform-portable optional integrations pre-built.
# matrix is Linux-only (oqs/liboqs lacks aarch64-darwin wheels).
full = hermesAgent.override {
extraDependencyGroups = [
"anthropic"
"azure-identity"
"bedrock"
"daytona"
"dingtalk"
"edge-tts"
"exa"
"fal"
"feishu"
"firecrawl"
"hindsight"
"honcho"
"messaging"
"modal"
"parallel-web"
"tts-premium"
"voice"
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
};
tui = hermesAgent.hermesTui;
web = hermesAgent.hermesWeb;
desktop = hermesAgent.hermesDesktop;
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
};
};
}
+101
View File
@@ -0,0 +1,101 @@
# nix/python.nix — uv2nix virtual environment builder
{
python312,
lib,
callPackage,
uv2nix,
pyproject-nix,
pyproject-build-systems,
stdenv,
dependency-groups ? [ "all" ],
}:
let
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
hacks = callPackage pyproject-nix.build.hacks { };
overlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
};
isAarch64Darwin = stdenv.hostPlatform.system == "aarch64-darwin";
# Keep the workspace locked through uv2nix, but supply the local voice stack
# from nixpkgs so wheel-only transitive artifacts do not break evaluation.
mkPrebuiltPassthru = dependencies: {
inherit dependencies;
optional-dependencies = { };
dependency-groups = { };
};
mkPrebuiltOverride = final: from: dependencies:
hacks.nixpkgsPrebuilt {
inherit from;
prev = {
nativeBuildInputs = [ final.pyprojectHook ];
passthru = mkPrebuiltPassthru dependencies;
};
};
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
# and no pyproject.toml, so setuptools isn't declared as a build dep.
buildSystemOverrides = final: prev: builtins.mapAttrs
(name: _: prev.${name}.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
}))
(lib.genAttrs [
"alibabacloud-credentials-api"
"alibabacloud-endpoint-util"
"alibabacloud-gateway-dingtalk"
"alibabacloud-gateway-spi"
"alibabacloud-tea"
] (_: null));
pythonPackageOverrides = final: _prev:
if isAarch64Darwin then {
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
av = mkPrebuiltOverride final python312.pkgs.av { };
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
humanfriendly = [ ];
};
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
coloredlogs = [ ];
numpy = [ ];
packaging = [ ];
};
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
numpy = [ ];
pyyaml = [ ];
};
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
av = [ ];
ctranslate2 = [ ];
huggingface-hub = [ ];
onnxruntime = [ ];
tokenizers = [ ];
tqdm = [ ];
};
} else {};
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python312;
}).overrideScope
(lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
buildSystemOverrides
pythonPackageOverrides
]);
in
pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = dependency-groups;
}
+33
View File
@@ -0,0 +1,33 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, hermesNpmLib, ... }:
let
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-tui";
inherit version;
doCheck = false;
buildPhase = ''
# esbuild bundles everything no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp ui-tui/package.json $out/lib/hermes-tui/
runHook postInstall
'';
})
+34
View File
@@ -0,0 +1,34 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ pkgs, hermesNpmLib, ... }:
let
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-web";
inherit version;
doCheck = false;
buildPhase = ''
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ..
'';
installPhase = ''
runHook preInstall
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
})