mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
Compare commits
6 Commits
opencode-p
...
fix/bundle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d645d98c4 | ||
|
|
242659f5af | ||
|
|
42df7ec597 | ||
|
|
42e166c7ea | ||
|
|
279504d5b8 | ||
|
|
42627b4eaf |
@@ -45,13 +45,7 @@ COPY --chown=hermes:hermes . .
|
|||||||
|
|
||||||
# Build browser dashboard and terminal UI assets.
|
# Build browser dashboard and terminal UI assets.
|
||||||
RUN cd web && npm run build && \
|
RUN cd web && npm run build && \
|
||||||
cd ../ui-tui && npm run build && \
|
cd ../ui-tui && npm run build
|
||||||
rm -rf node_modules/@hermes/ink && \
|
|
||||||
rm -rf packages/hermes-ink/node_modules && \
|
|
||||||
cp -R packages/hermes-ink node_modules/@hermes/ink && \
|
|
||||||
npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \
|
|
||||||
rm -rf node_modules/@hermes/ink/node_modules/react && \
|
|
||||||
node --input-type=module -e "await import('@hermes/ink')"
|
|
||||||
|
|
||||||
# ---------- Permissions ----------
|
# ---------- Permissions ----------
|
||||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||||
|
|||||||
@@ -841,6 +841,11 @@ _NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"})
|
|||||||
def _tui_need_npm_install(root: Path) -> bool:
|
def _tui_need_npm_install(root: Path) -> bool:
|
||||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||||
|
|
||||||
|
Prebuilt bundle mode: when ``dist/entry.js`` exists and there is no
|
||||||
|
``package-lock.json`` (nix install layout only ships ``dist/`` +
|
||||||
|
``package.json``), skip reinstall entirely — the bundle is self-contained
|
||||||
|
and there is nothing to install.
|
||||||
|
|
||||||
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
||||||
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
||||||
rewrites can bump the root lockfile's timestamp even when installed deps
|
rewrites can bump the root lockfile's timestamp even when installed deps
|
||||||
@@ -858,10 +863,16 @@ def _tui_need_npm_install(root: Path) -> bool:
|
|||||||
we'd rather not force a reinstall for them. Falls back to mtime
|
we'd rather not force a reinstall for them. Falls back to mtime
|
||||||
comparison if either lockfile is unparseable.
|
comparison if either lockfile is unparseable.
|
||||||
"""
|
"""
|
||||||
|
lock = root / "package-lock.json"
|
||||||
|
entry = root / "dist" / "entry.js"
|
||||||
|
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
|
||||||
|
# shipped, dist/entry.js is the single runtime artefact.
|
||||||
|
if entry.is_file() and not lock.is_file():
|
||||||
|
return False
|
||||||
|
|
||||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||||
if not ink.is_file():
|
if not ink.is_file():
|
||||||
return True
|
return True
|
||||||
lock = root / "package-lock.json"
|
|
||||||
if not lock.is_file():
|
if not lock.is_file():
|
||||||
return False
|
return False
|
||||||
marker = root / "node_modules" / ".package-lock.json"
|
marker = root / "node_modules" / ".package-lock.json"
|
||||||
@@ -911,9 +922,39 @@ def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def _tui_build_needed(tui_dir: Path) -> bool:
|
def _tui_build_needed(tui_dir: Path) -> bool:
|
||||||
|
entry = tui_dir / "dist" / "entry.js"
|
||||||
|
# In the esbuild pipeline, ink is bundled into dist/entry.js directly.
|
||||||
|
# If the main bundle exists and is up to date with all source files,
|
||||||
|
# no separate ink rebuild is needed.
|
||||||
|
if entry.exists():
|
||||||
|
dist_m = entry.stat().st_mtime
|
||||||
|
skip = frozenset({"node_modules", "dist"})
|
||||||
|
stale = False
|
||||||
|
for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True):
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in skip]
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.endswith((".ts", ".tsx")):
|
||||||
|
if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m:
|
||||||
|
stale = True
|
||||||
|
break
|
||||||
|
if stale:
|
||||||
|
break
|
||||||
|
if not stale:
|
||||||
|
for meta in (
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json",
|
||||||
|
"tsconfig.json",
|
||||||
|
"tsconfig.build.json",
|
||||||
|
):
|
||||||
|
mp = tui_dir / meta
|
||||||
|
if mp.exists() and mp.stat().st_mtime > dist_m:
|
||||||
|
stale = True
|
||||||
|
break
|
||||||
|
if not stale:
|
||||||
|
return False
|
||||||
|
|
||||||
if _hermes_ink_bundle_stale(tui_dir):
|
if _hermes_ink_bundle_stale(tui_dir):
|
||||||
return True
|
return True
|
||||||
entry = tui_dir / "dist" / "entry.js"
|
|
||||||
if not entry.exists():
|
if not entry.exists():
|
||||||
return True
|
return True
|
||||||
dist_m = entry.stat().st_mtime
|
dist_m = entry.stat().st_mtime
|
||||||
|
|||||||
@@ -154,8 +154,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
|||||||
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
|
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
|
||||||
echo "PASS: compiled entry.js present"
|
echo "PASS: compiled entry.js present"
|
||||||
|
|
||||||
test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1)
|
# self-contained bundle; no runtime node_modules expected
|
||||||
echo "PASS: node_modules present"
|
|
||||||
|
|
||||||
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
|
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
|
||||||
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)
|
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)
|
||||||
|
|||||||
12
nix/tui.nix
12
nix/tui.nix
@@ -4,7 +4,7 @@ let
|
|||||||
src = ../ui-tui;
|
src = ../ui-tui;
|
||||||
npmDeps = pkgs.fetchNpmDeps {
|
npmDeps = pkgs.fetchNpmDeps {
|
||||||
inherit src;
|
inherit src;
|
||||||
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
|
hash = "sha256-hxBD2zsPwdSoUL57feFFGqZ2Z1xIHxERwmQa/jIqNZw=";
|
||||||
};
|
};
|
||||||
|
|
||||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||||
@@ -24,16 +24,10 @@ pkgs.buildNpmPackage (npm // {
|
|||||||
|
|
||||||
mkdir -p $out/lib/hermes-tui
|
mkdir -p $out/lib/hermes-tui
|
||||||
|
|
||||||
|
# Single self-contained bundle built by scripts/build.mjs (esbuild).
|
||||||
cp -r dist $out/lib/hermes-tui/dist
|
cp -r dist $out/lib/hermes-tui/dist
|
||||||
|
|
||||||
# runtime node_modules
|
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||||
cp -r node_modules $out/lib/hermes-tui/node_modules
|
|
||||||
|
|
||||||
# @hermes/ink is a file: dependency, we need to copy it in fr
|
|
||||||
rm -f $out/lib/hermes-tui/node_modules/@hermes/ink
|
|
||||||
cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink
|
|
||||||
|
|
||||||
# package.json needed for "type": "module" resolution
|
|
||||||
cp package.json $out/lib/hermes-tui/
|
cp package.json $out/lib/hermes-tui/
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ session-picker flow.
|
|||||||
Environment overrides:
|
Environment overrides:
|
||||||
HERMES_PERF_LOG (default ~/.hermes/perf.log)
|
HERMES_PERF_LOG (default ~/.hermes/perf.log)
|
||||||
HERMES_PERF_NODE (default node from $PATH)
|
HERMES_PERF_NODE (default node from $PATH)
|
||||||
HERMES_TUI_DIR (default /home/bb/hermes-agent/ui-tui)
|
HERMES_TUI_DIR (default: <repo>/ui-tui relative to this script)
|
||||||
|
|
||||||
Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed
|
Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed
|
||||||
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
|
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
|
||||||
@@ -36,7 +36,10 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TUI_DIR = Path(os.environ.get("HERMES_TUI_DIR", "/home/bb/hermes-agent/ui-tui"))
|
DEFAULT_TUI_DIR = Path(
|
||||||
|
os.environ.get("HERMES_TUI_DIR")
|
||||||
|
or str(Path(__file__).resolve().parent.parent / "ui-tui")
|
||||||
|
)
|
||||||
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(Path.home() / ".hermes" / "perf.log")))
|
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(Path.home() / ".hermes" / "perf.log")))
|
||||||
DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db"
|
DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db"
|
||||||
|
|
||||||
|
|||||||
@@ -89,12 +89,31 @@ def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod)
|
|||||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||||
|
|
||||||
|
|
||||||
def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None:
|
def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
|
||||||
|
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
|
||||||
|
_touch_tui_entry(tmp_path)
|
||||||
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_needed_when_source_newer_than_entry(tmp_path: Path, main_mod) -> None:
|
||||||
|
_touch_tui_entry(tmp_path)
|
||||||
|
_touch_ink(tmp_path)
|
||||||
|
src = tmp_path / "src" / "entry.tsx"
|
||||||
|
src.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
src.write_text("console.log('newer')")
|
||||||
|
os.utime(src, (200, 200))
|
||||||
|
os.utime(tmp_path / "dist" / "entry.js", (100, 100))
|
||||||
|
|
||||||
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||||
|
assert main_mod._tui_build_needed(tmp_path) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_not_needed_when_entry_exists_and_sources_unchanged(tmp_path: Path, main_mod) -> None:
|
||||||
_touch_tui_entry(tmp_path)
|
_touch_tui_entry(tmp_path)
|
||||||
_touch_ink(tmp_path)
|
_touch_ink(tmp_path)
|
||||||
|
|
||||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||||
assert main_mod._tui_build_needed(tmp_path) is True
|
assert main_mod._tui_build_needed(tmp_path) is False
|
||||||
|
|
||||||
|
|
||||||
def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None:
|
def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None:
|
||||||
|
|||||||
@@ -121,20 +121,6 @@ def test_dockerfile_builds_tui_assets(dockerfile_text):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text):
|
|
||||||
assert any(
|
|
||||||
"ui-tui" in step
|
|
||||||
and "node_modules/@hermes/ink" in step
|
|
||||||
and "packages/hermes-ink" in step
|
|
||||||
and "rm -rf packages/hermes-ink/node_modules" in step
|
|
||||||
and "npm install --omit=dev" in step
|
|
||||||
and "--prefix node_modules/@hermes/ink" in step
|
|
||||||
and "rm -rf node_modules/@hermes/ink/node_modules/react" in step
|
|
||||||
and "await import('@hermes/ink')" in step
|
|
||||||
for step in _run_steps(dockerfile_text)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_dockerignore_excludes_nested_dependency_dirs():
|
def test_dockerignore_excludes_nested_dependency_dirs():
|
||||||
if not DOCKERIGNORE.exists():
|
if not DOCKERIGNORE.exists():
|
||||||
pytest.skip(".dockerignore not present in this checkout")
|
pytest.skip(".dockerignore not present in this checkout")
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ From the repo root, the normal path is:
|
|||||||
hermes --tui
|
hermes --tui
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing:
|
The CLI expects `ui-tui/dist/entry.js` to exist, or the whole source code available in which to run `npm install` and `npm run dev`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ui-tui
|
cd ui-tui
|
||||||
|
|||||||
1
ui-tui/package-lock.json
generated
1
ui-tui/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^8",
|
"@typescript-eslint/eslint-plugin": "^8",
|
||||||
"@typescript-eslint/parser": "^8",
|
"@typescript-eslint/parser": "^8",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-plugin-perfectionist": "^5",
|
"eslint-plugin-perfectionist": "^5",
|
||||||
"eslint-plugin-react": "^7",
|
"eslint-plugin-react": "^7",
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx",
|
"dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx",
|
||||||
"start": "tsx src/entry.tsx",
|
"start": "tsx src/entry.tsx",
|
||||||
"build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile && chmod +x dist/entry.js",
|
"build": "node scripts/build.mjs",
|
||||||
"build:compile": "babel dist --out-dir dist --config-file ./babel.compiler.config.cjs --extensions .js --keep-file-extension",
|
|
||||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||||
"lint": "eslint src/ packages/",
|
"lint": "eslint src/ packages/",
|
||||||
"lint:fix": "eslint src/ packages/ --fix",
|
"lint:fix": "eslint src/ packages/ --fix",
|
||||||
@@ -34,6 +33,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^8",
|
"@typescript-eslint/eslint-plugin": "^8",
|
||||||
"@typescript-eslint/parser": "^8",
|
"@typescript-eslint/parser": "^8",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-plugin-perfectionist": "^5",
|
"eslint-plugin-perfectionist": "^5",
|
||||||
"eslint-plugin-react": "^7",
|
"eslint-plugin-react": "^7",
|
||||||
|
|||||||
61
ui-tui/scripts/build.mjs
Normal file
61
ui-tui/scripts/build.mjs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Bundles src/entry.tsx into a single self-contained dist/entry.js.
|
||||||
|
// No runtime node_modules needed.
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = resolve(here, '..')
|
||||||
|
const out = resolve(root, 'dist/entry.js')
|
||||||
|
|
||||||
|
// `react-devtools-core` is only imported when DEV=true at runtime (Ink dev
|
||||||
|
// mode). Stub it out so the bundle doesn't carry the dep.
|
||||||
|
const stubDevtools = {
|
||||||
|
name: 'stub-react-devtools-core',
|
||||||
|
setup(b) {
|
||||||
|
b.onResolve({ filter: /^react-devtools-core$/ }, args => ({
|
||||||
|
path: args.path,
|
||||||
|
namespace: 'stub-devtools'
|
||||||
|
}))
|
||||||
|
b.onLoad({ filter: /.*/, namespace: 'stub-devtools' }, () => ({
|
||||||
|
contents: 'export default { initialize() {}, connectToDevTools() {} }',
|
||||||
|
loader: 'js'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: [resolve(root, 'src/entry.tsx')],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'node20',
|
||||||
|
outfile: out,
|
||||||
|
jsx: 'automatic',
|
||||||
|
jsxImportSource: 'react',
|
||||||
|
// Skip the prebuilt @hermes/ink bundle — esbuild's __esm helper doesn't
|
||||||
|
// await nested async init, which breaks lazy-initialized exports like
|
||||||
|
// `render`. Bundling from source sidesteps that.
|
||||||
|
alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') },
|
||||||
|
plugins: [stubDevtools],
|
||||||
|
// Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles
|
||||||
|
// don't get a `require` binding automatically, so we inject one.
|
||||||
|
banner: {
|
||||||
|
js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);"
|
||||||
|
},
|
||||||
|
logLevel: 'info'
|
||||||
|
})
|
||||||
|
|
||||||
|
// esbuild preserves the shebang from src/entry.tsx into the bundle, but Nix's
|
||||||
|
// patchShebangs phase mangles `/usr/bin/env -S node --foo --bar` (it strips
|
||||||
|
// the `node` token, leaving a broken interpreter). The hermes_cli launcher
|
||||||
|
// always invokes this file as `node dist/entry.js` anyway, so the shebang is
|
||||||
|
// redundant — strip it.
|
||||||
|
const body = readFileSync(out, 'utf8')
|
||||||
|
if (body.startsWith('#!')) {
|
||||||
|
writeFileSync(out, body.slice(body.indexOf('\n') + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`built ${out}`)
|
||||||
@@ -66,7 +66,7 @@ export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui
|
|||||||
hermes --tui
|
hermes --tui
|
||||||
```
|
```
|
||||||
|
|
||||||
The directory must contain `dist/entry.js` and an up-to-date `node_modules`.
|
The directory must contain `dist/entry.js`.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user