mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
* fix(nix): fold makeWrapper line continuations into optionalStrings
When rev == null (any dirty-tree build), the empty optionalString
expansion left the previous line's trailing backslash dangling onto a
blank line, ending the makeWrapper command early and running
`--suffix PYTHONPATH ...` as its own shell command (`--suffix: command
not found`, exit 127). Clean trees passed CI; dirty trees with
extraPythonPackages failed — exactly the path the NixOS module
exercises.
The continuation now lives inside each optionalString (" \\\n --set
..."), so the makeWrapper chain stays intact whether or not the
optional flags expand.
Verified by building with rev = null + extraPythonPackages =
[ pyfiglet ]: wrapper builds, PYTHONPATH suffix lands inside the
makeWrapper call, wrapped `hermes --version` runs, and the collision
check still executes (certifi correctly rejected).
* perf(nix): filter derivation sources to shrink rebuild scope
Every derivation previously saw the whole repo, so any file change
rebuilt everything. Each derivation now gets a filtered src with only
the files it consumes:
- lib.nix: derive npm workspace topology from the root package.json
`workspaces` globs (single source of truth — a new workspace member
is picked up with zero nix edits). pythonSrc (cleanSourceWith)
excludes the JS workspace trees, docs/website, docker/.github,
tests, nix/, flake.nix/flake.lock, root docs, and skills/ +
optional-skills/. importNpmLock reads from a fileset-filtered
npmRoot (root manifests + member package.jsons only).
- mkNpmPassthru takes `dirs` — the workspace dirs the package
contains — and builds a per-package fileset src from them. web and
desktop include apps/shared (file: dep). One shared
`nix run .#update-npm-lockfile` replaces the per-package
update_*_lockfile bins that only existed inside build sandboxes.
- python.nix: release venv loads the uv2nix workspace from pythonSrc.
The editable venv keeps an unfiltered ./.. root —
mkEditablePyprojectOverlay calls lib.path.splitRoot, which rejects
a cleanSourceWith set, and the editable install reads the live
checkout anyway.
- hermes-agent.nix: skills ship exclusively via HERMES_BUNDLED_SKILLS
/ HERMES_OPTIONAL_SKILLS (same mechanism as Homebrew packaging;
setup.py's _data_file_tree returns [] for missing dirs), so
SKILL.md edits no longer rebuild the venv. optional-mcps stays in
the wheel — pyproject.toml lists its manifests as explicit
data-files. Bundled assets are symlinked instead of copied, making
the wrapper drv near-instant when only an input changed.
__pycache__ filtered from bundled skills.
- checks.nix: find -L through the new symlinks; assert
optional-skills presence + HERMES_OPTIONAL_SKILLS in the wrapper.
- run_tests.sh: fall back to $HERMES_PYTHON when no local venv
exists, guarded by an `import pytest` probe (HERMES_PYTHON from a
wrapped hermes binary points at the release venv, which has no
pytest — without the guard every test file dies with "No module
named pytest" while the runner exits 0).
Verified: nix flake check exit 0; built .#default .#tui .#web
.#desktop; SKILL.md and flake.nix edits leave the venv drvPath
unchanged; .py edits leave the tui drvPath unchanged; .tsx edits
leave the venv drvPath unchanged (and do change the tui drv);
scripts/run_tests.sh runs 299 tests green through both the venv and
HERMES_PYTHON paths, and rejects a pytest-less HERMES_PYTHON.
* refactor(nix): overlay aliases the flake's own package instead of re-instantiating
The overlay previously re-called callPackage against the consumer's
nixpkgs (final), so pkgs.hermes-agent could be a different derivation
than nix build .#default and the NixOS module's default — an untested
build matrix against arbitrary consumer nixpkgs versions, for a
package whose Python side is uv2nix-locked anyway.
Now the overlay is a pure alias for the flake's own locked package:
one callPackage site (packages.nix), everything else references it.
.override { ... } still works — callPackage's makeOverridable travels
with the derivation.
Verified: direct drvPath == overlaid drvPath; .override produces a
distinct drv.
* fix(nix): dedupe extraPlugins assertions, replace MESSAGING_CWD with terminal.cwd
- Delete the duplicated extraPlugins duplicate-name assertions block
(same assertion declared twice back to back).
- Stop setting the deprecated MESSAGING_CWD env var, which made the
module trip hermes' own startup deprecation warning. The working
directory is now injected as terminal.cwd into the generated
config.yaml; cfg.settings wins via recursiveUpdate, and container
mode maps to the in-container mount path.
303 lines
12 KiB
Nix
303 lines
12 KiB
Nix
# 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 npmDeps,
|
|
# npmRoot, and npmConfigHook so individual .nix files don't duplicate them.
|
|
#
|
|
# Source filters (pythonSrc, per-package npm srcs) reduce rebuild scope so
|
|
# that e.g. a .tsx change doesn't trigger a Python venv rebuild, and a .py
|
|
# change doesn't trigger a TUI/Web/Desktop rebuild. Each derivation gets a
|
|
# filtered src that only includes files it actually needs, while keeping
|
|
# the repo-root directory layout intact for buildNpmPackage /
|
|
# npmConfigHook workspace resolution.
|
|
#
|
|
# 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.
|
|
{
|
|
lib,
|
|
pkgs,
|
|
npm-lockfile-fix,
|
|
nodejs,
|
|
}:
|
|
let
|
|
repoRoot = ./..;
|
|
|
|
# ── npm workspace discovery ────────────────────────────────────────
|
|
# Single source of truth: the `workspaces` field of the root
|
|
# package.json. Everything below (workspace package.json discovery,
|
|
# the Python source's JS-dir exclusions) is derived from this so the
|
|
# topology is never duplicated. Add a workspace to package.json and
|
|
# the nix build picks it up automatically.
|
|
rootPackageJson = builtins.fromJSON (builtins.readFile (repoRoot + "/package.json"));
|
|
|
|
# Expand a workspace glob (e.g. "apps/*") into concrete member dirs
|
|
# relative to the repo root. Only trailing "*" globs are supported —
|
|
# that's all npm uses here. Literal patterns (e.g. "ui-tui") pass
|
|
# through unchanged.
|
|
expandWorkspace =
|
|
pattern:
|
|
let
|
|
parts = lib.splitString "/" pattern;
|
|
in
|
|
if lib.last parts == "*" then
|
|
let
|
|
parent = lib.concatStringsSep "/" (lib.init parts);
|
|
entries = builtins.readDir (repoRoot + "/${parent}");
|
|
dirs = lib.filterAttrs (_: t: t == "directory") entries;
|
|
in
|
|
map (d: "${parent}/${d}") (builtins.attrNames dirs)
|
|
else
|
|
[ pattern ];
|
|
|
|
# All workspace member directories (relative paths), filtered to those
|
|
# that actually carry a package.json — a glob like apps/* may match a
|
|
# dir that isn't really a package.
|
|
workspaceMemberDirs = builtins.filter (d: builtins.pathExists (repoRoot + "/${d}/package.json")) (
|
|
lib.concatMap expandWorkspace rootPackageJson.workspaces
|
|
);
|
|
|
|
# Top-level directory of each workspace member, deduplicated. Used to
|
|
# exclude JS/TS workspace trees from the Python source filter. E.g.
|
|
# apps/desktop + apps/shared + ui-tui + web → [ "apps" "ui-tui" "web" ].
|
|
jsWorkspaceTopDirs = lib.unique (map (d: builtins.head (lib.splitString "/" d)) workspaceMemberDirs);
|
|
|
|
# ── Source filters for reducing rebuild scope ──────────────────────
|
|
# Changing a .tsx/.mjs file should NOT trigger a Python venv rebuild,
|
|
# and changing a .py file should NOT trigger a TUI/Web/Desktop rebuild.
|
|
|
|
# Python source: everything except JS/TS/docs/infra directories.
|
|
pythonSrc = lib.cleanSourceWith {
|
|
src = repoRoot;
|
|
name = "hermes-python-source";
|
|
filter =
|
|
path: type:
|
|
let
|
|
relPath = lib.removePrefix (toString repoRoot + "/") (toString path);
|
|
components = lib.splitString "/" relPath;
|
|
topComponent = if components == [ ] then "" else builtins.head components;
|
|
excludedDirs =
|
|
# JS/TS workspace directories — derived from the npm workspaces
|
|
# so a new workspace member is excluded from the Python source
|
|
# without touching this list.
|
|
jsWorkspaceTopDirs
|
|
++ [
|
|
# Documentation
|
|
"docs"
|
|
"website"
|
|
# CI/infra
|
|
"docker"
|
|
".github"
|
|
# Content/examples
|
|
"infographic"
|
|
"datagen-config-examples"
|
|
# unused packaging infra
|
|
"packaging"
|
|
# Test infrastructure
|
|
"tests"
|
|
# Plan/temp files
|
|
"plans"
|
|
# Nix build definitions (Python build doesn't need these)
|
|
"nix"
|
|
# Skills are shipped via HERMES_BUNDLED_SKILLS /
|
|
# HERMES_OPTIONAL_SKILLS (see hermes-agent.nix), not via the
|
|
# wheel's data_files — setup.py's _data_file_tree returns []
|
|
# for a missing dir, so the wheel builds fine without them.
|
|
# This keeps SKILL.md edits from rebuilding the Python venv.
|
|
# NOTE: optional-mcps must stay — pyproject.toml lists its
|
|
# manifests as explicit data-files, which error when missing.
|
|
"skills"
|
|
"optional-skills"
|
|
];
|
|
excludedFiles = [
|
|
# JS root manifests
|
|
"package.json"
|
|
"package-lock.json"
|
|
# Docker files
|
|
"Dockerfile"
|
|
"docker-compose.yml"
|
|
"docker-compose.windows.yml"
|
|
# Nix build definitions — editing the flake shouldn't rebuild
|
|
# the venv. (Input changes rebuild regardless, via the lock.)
|
|
"flake.nix"
|
|
"flake.lock"
|
|
# Root docs the wheel doesn't consume. README.md and LICENSE
|
|
# must stay — pyproject.toml references them (readme /
|
|
# license-files).
|
|
"AGENTS.md"
|
|
"CONTRIBUTING.md"
|
|
"SECURITY.md"
|
|
"README.zh-CN.md"
|
|
".gitignore"
|
|
"setup-hermes.sh"
|
|
];
|
|
in
|
|
if relPath == "" then
|
|
true
|
|
else if builtins.elem relPath excludedFiles then
|
|
false
|
|
else if builtins.elem topComponent excludedDirs then
|
|
false
|
|
else
|
|
true;
|
|
};
|
|
|
|
# Common npm workspace resolution files needed by all npm builds.
|
|
# npm ci requires all workspace package.json files to resolve
|
|
# workspace: protocol dependencies correctly. Discovered from the
|
|
# root package.json workspaces — root manifests + every member's
|
|
# package.json.
|
|
npmWorkspaceFiles = lib.fileset.unions (
|
|
[
|
|
(repoRoot + "/package.json")
|
|
(repoRoot + "/package-lock.json")
|
|
]
|
|
++ map (d: repoRoot + "/${d}/package.json") workspaceMemberDirs
|
|
);
|
|
|
|
# npm deps source: just what importNpmLock needs (root manifests +
|
|
# workspace member package.jsons). Much smaller than the full repo,
|
|
# so changing source files won't invalidate the npmDeps derivation.
|
|
npmDepsSrc = lib.fileset.toSource {
|
|
root = repoRoot;
|
|
fileset = npmWorkspaceFiles;
|
|
};
|
|
|
|
# npm dependencies for the workspace, shared by all members. importNpmLock
|
|
# resolves each package from the lockfile's own `integrity` hashes, so the
|
|
# lockfile is the single source of truth — no separate dependency hash to
|
|
# keep in sync with it.
|
|
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = npmDepsSrc; };
|
|
|
|
# Build a per-package npm source: workspace resolution files + the
|
|
# package's own directory tree(s). Source ROOT is always the repo
|
|
# root, preserving the workspace layout that buildNpmPackage and
|
|
# npmConfigHook expect. Callers pass the dirs they need (relative to
|
|
# the repo root), so each package owns its own source scope.
|
|
mkNpmSrc =
|
|
dirs:
|
|
lib.fileset.toSource {
|
|
root = repoRoot;
|
|
fileset = lib.fileset.union npmWorkspaceFiles (
|
|
lib.fileset.unions (map (d: repoRoot + "/${d}") dirs)
|
|
);
|
|
};
|
|
in
|
|
{
|
|
inherit pythonSrc;
|
|
|
|
# Regenerate the shared root lockfile from scratch and verify all npm
|
|
# packages still build. Exposed as a runnable package — `nix run
|
|
# .#update-npm-lockfile` — so it's actually usable, unlike a bin buried
|
|
# in a build sandbox's PATH. All workspace packages share one lockfile,
|
|
# so there's a single script (not one per package).
|
|
updateNpmLockfile = pkgs.writeShellScriptBin "update-npm-lockfile" ''
|
|
set -euo pipefail
|
|
# DEBUG=1 nix run .#update-npm-lockfile — trace every command
|
|
[ -n "''${DEBUG:-}" ] && set -x
|
|
|
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
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
|
|
|
|
# importNpmLock reads hashes from the lockfile itself — rebuild every
|
|
# npm package to verify the new lockfile resolves offline.
|
|
nix build .#tui .#web .#desktop
|
|
echo "Lockfile updated and all npm packages built."
|
|
'';
|
|
|
|
# Returns a buildNpmPackage-compatible attrs set that provides:
|
|
# src, npmDeps, npmRoot — filtered workspace source + importNpmLock dep set
|
|
# npmConfigHook — importNpmLock's offline `npm install` hook
|
|
# passthru.packageJsonPath — relative path to this workspace's package.json
|
|
# nodejs — fixed nodejs version for all packages we use in the repo
|
|
#
|
|
# `dirs` is the single source of truth for what the package contains:
|
|
# its first entry is the package's own folder (→ packageJsonPath), and
|
|
# all entries scope the filtered src. Packages that import source from
|
|
# another workspace member (file: deps) must list that member's dir too,
|
|
# e.g. apps/desktop depends on apps/shared.
|
|
#
|
|
# Usage:
|
|
# npm = hermesNpmLib.mkNpmPassthru { dirs = [ "ui-tui" ]; };
|
|
# npm = hermesNpmLib.mkNpmPassthru { dirs = [ "apps/desktop" "apps/shared" ]; };
|
|
# pkgs.buildNpmPackage (npm // {
|
|
# pname = "hermes-tui";
|
|
# inherit version;
|
|
# buildPhase = '' ... '';
|
|
# installPhase = '' ... '';
|
|
# })
|
|
mkNpmPassthru =
|
|
{ dirs }:
|
|
let
|
|
# The package's own folder is the first dir; it carries the
|
|
# package.json that buildNpmPackage reads.
|
|
folder = builtins.head dirs;
|
|
# 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 nodejs npmDeps;
|
|
src = mkNpmSrc dirs;
|
|
# importNpmLock's hook installs the rewritten lockfile (every `resolved`
|
|
# rewritten to a /nix/store file: path) into the unpacked workspace and
|
|
# runs `npm install` offline, so every workspace member's dependencies
|
|
# resolve without network access.
|
|
npmConfigHook = pkgs.importNpmLock.npmConfigHook;
|
|
npmRoot = ".";
|
|
|
|
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
|
|
|
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`
|
|
mkNpmDevShellHook =
|
|
packageJsonPaths:
|
|
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
|
|
'';
|
|
}
|