Hermes-agent
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Tool Search live test harness
|
||||
|
||||
Runs five scenarios against a real model (Claude Haiku 4.5 via OpenRouter) to
|
||||
verify that the bridge tools work end-to-end. Records transcripts in
|
||||
`scripts/out/`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd <repo root>
|
||||
python3 scripts/tool_search_livetest.py # runs all 5 scenarios x 2 modes
|
||||
python3 scripts/analyze_livetest.py # side-by-side report
|
||||
```
|
||||
|
||||
Requires `OPENROUTER_API_KEY` set or present in `~/.hermes/.env`.
|
||||
|
||||
## What it verifies
|
||||
|
||||
| Scenario | Tests |
|
||||
|----------|-------|
|
||||
| A obvious_single | BM25 retrieval on an obvious tool name (github_create_issue) |
|
||||
| B vague_paraphrased | Retrieval when the model has to paraphrase ("schedule meeting" → evt_create) |
|
||||
| C multi_tool_chain | Multi-step task chaining two deferred tools (GitHub + Slack) |
|
||||
| D core_plus_deferred | Mixed: core tool (read_file) called directly, deferred tool (Slack) via bridge |
|
||||
| E no_tool_needed | Pure-knowledge prompt; verify no spurious tool_search invocations |
|
||||
|
||||
Each scenario runs with `tool_search.enabled = on` and again with `off` for an
|
||||
A/B baseline. The harness records:
|
||||
|
||||
- bridge_calls (the tool_search / tool_describe / tool_call sequence the model emitted)
|
||||
- underlying_tool_calls (what actually ran through the registry dispatcher)
|
||||
- final_response, iteration count, elapsed time, any errors
|
||||
|
||||
## Output structure
|
||||
|
||||
```
|
||||
scripts/out/
|
||||
<scenario>__enabled.json # tool_search ON
|
||||
<scenario>__disabled.json # tool_search OFF
|
||||
_summary.json # one-line summary across all runs
|
||||
```
|
||||
|
||||
The 2026-05 baseline run is checked in for reference. Re-running may produce
|
||||
slightly different transcripts (the model is non-deterministic) but the
|
||||
expected_underlying_tools assertions should remain satisfied.
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare enabled vs disabled runs and produce a readable report.
|
||||
|
||||
Reads scripts/out/_summary.json and the per-scenario JSONs, prints a side-by-
|
||||
side comparison of what happened, and flags anomalies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
OUT = HERE / "out"
|
||||
|
||||
|
||||
def load_record(scenario_id: str, mode: str):
|
||||
path = OUT / f"{scenario_id}__{mode}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def fmt_tool_seq(calls):
|
||||
if not calls:
|
||||
return "(none)"
|
||||
return " → ".join(c["name"] for c in calls)
|
||||
|
||||
|
||||
def fmt_bridge_seq(calls):
|
||||
if not calls:
|
||||
return "(none)"
|
||||
parts = []
|
||||
for c in calls:
|
||||
if c["name"] == "tool_call":
|
||||
inner = (c.get("args") or {}).get("name", "?")
|
||||
parts.append(f"tool_call→{inner}")
|
||||
elif c["name"] == "tool_search":
|
||||
q = (c.get("args") or {}).get("query", "?")
|
||||
parts.append(f"search('{q[:30]}')")
|
||||
elif c["name"] == "tool_describe":
|
||||
n = (c.get("args") or {}).get("name", "?")
|
||||
parts.append(f"describe({n})")
|
||||
return " → ".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
if not OUT.exists():
|
||||
print("No output directory at", OUT)
|
||||
sys.exit(1)
|
||||
summary_path = OUT / "_summary.json"
|
||||
if not summary_path.exists():
|
||||
print("No _summary.json yet")
|
||||
sys.exit(1)
|
||||
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
scenarios = sorted({row["scenario"] for row in summary})
|
||||
|
||||
print(f"{'='*78}")
|
||||
print(f" Live test results: tool_search ENABLED vs DISABLED")
|
||||
print(f"{'='*78}\n")
|
||||
|
||||
fails = 0
|
||||
for sid in scenarios:
|
||||
en = load_record(sid, "enabled")
|
||||
di = load_record(sid, "disabled")
|
||||
if not en or not di:
|
||||
continue
|
||||
expected = set(en["expected_underlying_tools"])
|
||||
|
||||
print(f"┌─ {sid} ({en['scenario_description']})")
|
||||
print(f"│ Prompt: {en['prompt'][:120]}")
|
||||
print(f"│ Expected underlying tools: {sorted(expected) or '(none)'}")
|
||||
print(f"│")
|
||||
|
||||
for label, rec in [("ENABLED ", en), ("DISABLED", di)]:
|
||||
called_under = [c["name"] for c in rec["underlying_tool_calls"]]
|
||||
called_set = set(called_under)
|
||||
missing = expected - called_set
|
||||
extra = called_set - expected - {"read_file", "search_files", "terminal", "todo", "memory"}
|
||||
|
||||
mark = "✓" if (expected.issubset(called_set) and not rec["error"]) else "✗"
|
||||
if mark == "✗":
|
||||
fails += 1
|
||||
|
||||
print(f"│ {label} {mark} bridges={len(rec['bridge_calls']):2} underlying={len(rec['underlying_tool_calls']):2} "
|
||||
f"iters={rec['n_iterations']:2} elapsed={rec['elapsed_seconds']:5.1f}s err={bool(rec['error'])}")
|
||||
print(f"│ underlying: {fmt_tool_seq(rec['underlying_tool_calls'])}")
|
||||
if rec["bridge_calls"]:
|
||||
print(f"│ bridges: {fmt_bridge_seq(rec['bridge_calls'])}")
|
||||
if missing:
|
||||
print(f"│ ⚠ MISSING expected tools: {sorted(missing)}")
|
||||
if extra:
|
||||
print(f"│ ⓘ extra tools called: {sorted(extra)}")
|
||||
if rec["error"]:
|
||||
print(f"│ 💥 error: {rec['error'][:200]}")
|
||||
# Bridge-trip count vs direct (interesting comparator)
|
||||
en_bridges = len(en["bridge_calls"])
|
||||
di_underlying = len(di["underlying_tool_calls"])
|
||||
en_underlying = len(en["underlying_tool_calls"])
|
||||
overhead = en_bridges + en_underlying - di_underlying
|
||||
print(f"│ Δ round-trip cost: enabled used {en_bridges + en_underlying} calls vs disabled {di_underlying} → +{overhead}")
|
||||
print(f"│ Final (enabled): {(en.get('final_response') or '')[:140]}")
|
||||
print(f"│ Final (disabled): {(di.get('final_response') or '')[:140]}")
|
||||
print(f"└──")
|
||||
print()
|
||||
|
||||
print(f"\nFails: {fails}/{2*len(scenarios)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Quick benchmark: subprocess eval vs supervisor-WS eval.
|
||||
|
||||
Runs both paths against the same live Chrome and prints a comparison table.
|
||||
Not a pytest — a script you run manually for the PR description.
|
||||
|
||||
Usage:
|
||||
.venv/bin/python scripts/benchmark_browser_eval.py [--iterations N]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
|
||||
def _find_chrome() -> str:
|
||||
for c in ("google-chrome", "chromium", "chromium-browser"):
|
||||
p = shutil.which(c)
|
||||
if p:
|
||||
return p
|
||||
print("No Chrome binary found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _start_chrome(port: int):
|
||||
profile = tempfile.mkdtemp(prefix="hermes-bench-eval-")
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
_find_chrome(),
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={profile}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1) as r:
|
||||
info = json.loads(r.read().decode())
|
||||
return proc, profile, info["webSocketDebuggerUrl"]
|
||||
except Exception:
|
||||
time.sleep(0.25)
|
||||
proc.terminate()
|
||||
raise RuntimeError("Chrome didn't expose CDP")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--iterations", type=int, default=50)
|
||||
parser.add_argument("--port", type=int, default=9333)
|
||||
args = parser.parse_args()
|
||||
|
||||
proc, profile, cdp_url = _start_chrome(args.port)
|
||||
try:
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY
|
||||
|
||||
# Warm up: start the supervisor, navigate to a page.
|
||||
supervisor = SUPERVISOR_REGISTRY.get_or_start(
|
||||
task_id="bench-eval", cdp_url=cdp_url
|
||||
)
|
||||
# Give it a moment to attach.
|
||||
time.sleep(1.0)
|
||||
|
||||
# Sanity check: one eval over WS should succeed.
|
||||
sanity = supervisor.evaluate_runtime("1 + 1")
|
||||
if not sanity.get("ok") or sanity.get("result") != 2:
|
||||
print(f"sanity check failed: {sanity}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# ── Bench 1: supervisor WS path ──────────────────────────────────
|
||||
ws_times: list[float] = []
|
||||
for _ in range(args.iterations):
|
||||
t0 = time.monotonic()
|
||||
out = supervisor.evaluate_runtime("1 + 1")
|
||||
t1 = time.monotonic()
|
||||
assert out.get("ok"), out
|
||||
ws_times.append((t1 - t0) * 1000)
|
||||
|
||||
# ── Bench 2: agent-browser subprocess path ────────────────────────
|
||||
# Skip if agent-browser isn't installed — the WS bench still tells
|
||||
# us what we need.
|
||||
if shutil.which("agent-browser") is None and shutil.which("npx") is None:
|
||||
print("agent-browser CLI not found — skipping subprocess bench.")
|
||||
sub_times = []
|
||||
else:
|
||||
from tools.browser_tool import _run_browser_command, _last_session_key
|
||||
task_id = _last_session_key("bench-eval")
|
||||
sub_times = []
|
||||
for _ in range(args.iterations):
|
||||
t0 = time.monotonic()
|
||||
_run_browser_command(task_id, "eval", ["1 + 1"])
|
||||
t1 = time.monotonic()
|
||||
sub_times.append((t1 - t0) * 1000)
|
||||
|
||||
def fmt(name: str, ts: list[float]) -> str:
|
||||
if not ts:
|
||||
return f" {name:<40} (skipped)"
|
||||
mean = statistics.mean(ts)
|
||||
median = statistics.median(ts)
|
||||
mn, mx = min(ts), max(ts)
|
||||
return (
|
||||
f" {name:<40} mean={mean:>7.2f}ms median={median:>7.2f}ms "
|
||||
f"min={mn:>7.2f}ms max={mx:>7.2f}ms"
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"browser_eval benchmark — {args.iterations} iterations of `1 + 1`")
|
||||
print("-" * 90)
|
||||
print(fmt("supervisor WS (Runtime.evaluate)", ws_times))
|
||||
print(fmt("agent-browser subprocess (eval)", sub_times))
|
||||
if ws_times and sub_times:
|
||||
speedup = statistics.mean(sub_times) / statistics.mean(ws_times)
|
||||
print()
|
||||
print(f"Speedup: {speedup:.1f}x (mean)")
|
||||
|
||||
finally:
|
||||
SUPERVISOR_REGISTRY.stop_all()
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Hermes Model Catalog — a centralized JSON manifest of curated models.
|
||||
|
||||
This script reads the in-repo hardcoded curated lists (``OPENROUTER_MODELS``,
|
||||
``_PROVIDER_MODELS["nous"]``) and writes them to a JSON manifest that the
|
||||
Hermes CLI fetches at runtime. Publishing the catalog through the docs site
|
||||
lets maintainers update model lists without shipping a Hermes release.
|
||||
|
||||
The runtime fetcher falls back to the same in-repo hardcoded lists if the
|
||||
manifest is unreachable, so this script is a convenience for keeping the
|
||||
manifest in sync — not a source of truth.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/build_model_catalog.py
|
||||
|
||||
Output: ``website/static/api/model-catalog.json``
|
||||
|
||||
Live URL (after ``deploy-site.yml`` runs on merge to main):
|
||||
``https://hermes-agent.nousresearch.com/docs/api/model-catalog.json``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
|
||||
# Ensure HERMES_HOME is set for imports that touch it at module level.
|
||||
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes"))
|
||||
|
||||
from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS # noqa: E402
|
||||
|
||||
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "model-catalog.json")
|
||||
CATALOG_VERSION = 1
|
||||
|
||||
|
||||
def build_catalog() -> dict:
|
||||
return {
|
||||
"version": CATALOG_VERSION,
|
||||
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"metadata": {
|
||||
"source": "hermes-agent repo",
|
||||
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog",
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"metadata": {
|
||||
"display_name": "OpenRouter",
|
||||
"note": (
|
||||
"Descriptions drive picker badges. Live /api/v1/models "
|
||||
"filters curated ids by tool-calling support and free pricing."
|
||||
),
|
||||
},
|
||||
"models": [
|
||||
{"id": mid, "description": desc}
|
||||
for mid, desc in OPENROUTER_MODELS
|
||||
],
|
||||
},
|
||||
"nous": {
|
||||
"metadata": {
|
||||
"display_name": "Nous Portal",
|
||||
"note": (
|
||||
"Free-tier gating is determined live via Portal pricing "
|
||||
"(partition_nous_models_by_tier), not this manifest."
|
||||
),
|
||||
},
|
||||
"models": [
|
||||
{"id": mid}
|
||||
for mid in _PROVIDER_MODELS.get("nous", [])
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
catalog = build_catalog()
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(catalog, fh, indent=2)
|
||||
fh.write("\n")
|
||||
|
||||
print(f"Wrote {OUTPUT_PATH}")
|
||||
for provider, block in catalog["providers"].items():
|
||||
print(f" {provider}: {len(block['models'])} models")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Hermes Skills Index — a centralized JSON catalog of all skills.
|
||||
|
||||
This script crawls every skill source (skills.sh, GitHub taps, official,
|
||||
clawhub, lobehub, claude-marketplace) and writes a JSON index with resolved
|
||||
GitHub paths. The index is served as a static file on the docs site so that
|
||||
`hermes skills search/install` can use it without hitting the GitHub API.
|
||||
|
||||
Usage:
|
||||
# Local (uses gh CLI or GITHUB_TOKEN for auth)
|
||||
python scripts/build_skills_index.py
|
||||
|
||||
# CI (set GITHUB_TOKEN as secret)
|
||||
GITHUB_TOKEN=ghp_... python scripts/build_skills_index.py
|
||||
|
||||
Output: website/static/api/skills-index.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Allow importing from repo root
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
|
||||
# Ensure HERMES_HOME is set (needed by tools/skills_hub.py imports)
|
||||
os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes"))
|
||||
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth,
|
||||
GitHubSource,
|
||||
SkillsShSource,
|
||||
OptionalSkillSource,
|
||||
WellKnownSkillSource,
|
||||
ClawHubSource,
|
||||
ClaudeMarketplaceSource,
|
||||
LobeHubSource,
|
||||
BrowseShSource,
|
||||
SkillMeta,
|
||||
)
|
||||
import httpx
|
||||
|
||||
OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "skills-index.json")
|
||||
INDEX_VERSION = 1
|
||||
|
||||
|
||||
def _meta_to_dict(meta: SkillMeta) -> dict:
|
||||
"""Convert a SkillMeta to a serializable dict."""
|
||||
return {
|
||||
"name": meta.name,
|
||||
"description": meta.description,
|
||||
"source": meta.source,
|
||||
"identifier": meta.identifier,
|
||||
"trust_level": meta.trust_level,
|
||||
"repo": meta.repo or "",
|
||||
"path": meta.path or "",
|
||||
"tags": meta.tags or [],
|
||||
"extra": meta.extra or {},
|
||||
}
|
||||
|
||||
|
||||
def crawl_source(source, source_name: str, limit: int) -> list:
|
||||
"""Crawl a single source and return skill dicts."""
|
||||
print(f" Crawling {source_name}...", flush=True)
|
||||
start = time.time()
|
||||
try:
|
||||
results = source.search("", limit=limit)
|
||||
except Exception as e:
|
||||
print(f" Error crawling {source_name}: {e}", file=sys.stderr)
|
||||
return []
|
||||
skills = [_meta_to_dict(m) for m in results]
|
||||
elapsed = time.time() - start
|
||||
print(f" {source_name}: {len(skills)} skills ({elapsed:.1f}s)", flush=True)
|
||||
return skills
|
||||
|
||||
|
||||
def crawl_skills_sh(source: SkillsShSource) -> list:
|
||||
"""Crawl skills.sh via its sitemap to enumerate the full catalog (~20k entries).
|
||||
|
||||
Previously walked a hardcoded list of ~28 popular keywords (each capped at
|
||||
50 results) which yielded ~850 unique skills — about 4% of the real catalog.
|
||||
The SkillsShSource.search("") path now hits the sitemap directly, returning
|
||||
the full 20k-entry catalog deduplicated by canonical identifier.
|
||||
"""
|
||||
print(" Crawling skills.sh (sitemap)...", flush=True)
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
results = source.search("", limit=0) # 0 = no cap, return the whole catalog
|
||||
except Exception as e:
|
||||
print(f" Warning: skills.sh sitemap walk failed: {e}", file=sys.stderr)
|
||||
results = []
|
||||
|
||||
all_skills: dict[str, dict] = {}
|
||||
for meta in results:
|
||||
entry = _meta_to_dict(meta)
|
||||
if entry["identifier"] not in all_skills:
|
||||
all_skills[entry["identifier"]] = entry
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f" skills.sh: {len(all_skills)} unique skills ({elapsed:.1f}s)",
|
||||
flush=True)
|
||||
return list(all_skills.values())
|
||||
|
||||
|
||||
def _fetch_repo_tree(repo: str, auth: GitHubAuth) -> list:
|
||||
"""Fetch the recursive tree for a repo. Returns list of tree entries."""
|
||||
headers = auth.get_headers()
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"https://api.github.com/repos/{repo}",
|
||||
headers=headers, timeout=15, follow_redirects=True,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
branch = resp.json().get("default_branch", "main")
|
||||
|
||||
resp = httpx.get(
|
||||
f"https://api.github.com/repos/{repo}/git/trees/{branch}",
|
||||
params={"recursive": "1"},
|
||||
headers=headers, timeout=30, follow_redirects=True,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
data = resp.json()
|
||||
if data.get("truncated"):
|
||||
return []
|
||||
return data.get("tree", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list:
|
||||
"""Resolve GitHub paths for skills.sh entries using batch tree lookups.
|
||||
|
||||
Instead of resolving each skill individually (N×M API calls), we:
|
||||
1. Group skills by repo
|
||||
2. Fetch one tree per repo (2 API calls per repo)
|
||||
3. Find all SKILL.md files in the tree
|
||||
4. Match skills to their resolved paths
|
||||
"""
|
||||
# Filter to skills.sh entries that need resolution
|
||||
skills_sh = [s for s in skills if s["source"] in {"skills.sh", "skills-sh"}]
|
||||
if not skills_sh:
|
||||
return skills
|
||||
|
||||
print(f" Resolving paths for {len(skills_sh)} skills.sh entries...",
|
||||
flush=True)
|
||||
start = time.time()
|
||||
|
||||
# Group by repo
|
||||
by_repo: dict[str, list] = defaultdict(list)
|
||||
for s in skills_sh:
|
||||
repo = s.get("repo", "")
|
||||
if repo:
|
||||
by_repo[repo].append(s)
|
||||
|
||||
print(f" {len(by_repo)} unique repos to scan", flush=True)
|
||||
|
||||
resolved_count = 0
|
||||
|
||||
# Fetch trees in parallel (up to 6 concurrent)
|
||||
def _resolve_repo(repo: str, entries: list):
|
||||
tree = _fetch_repo_tree(repo, auth)
|
||||
if not tree:
|
||||
return 0
|
||||
|
||||
# Find all SKILL.md paths in this repo
|
||||
skill_paths = {} # skill_dir_name -> full_path
|
||||
for item in tree:
|
||||
if item.get("type") != "blob":
|
||||
continue
|
||||
path = item.get("path", "")
|
||||
if path.endswith("/SKILL.md"):
|
||||
skill_dir = path[: -len("/SKILL.md")]
|
||||
dir_name = skill_dir.split("/")[-1]
|
||||
skill_paths[dir_name.lower()] = f"{repo}/{skill_dir}"
|
||||
|
||||
# Also check SKILL.md frontmatter name if we can match by path
|
||||
# For now, just index by directory name
|
||||
elif path == "SKILL.md":
|
||||
# Root-level SKILL.md
|
||||
skill_paths["_root_"] = f"{repo}"
|
||||
|
||||
count = 0
|
||||
for entry in entries:
|
||||
# Try to match the skill's name/path to a tree entry
|
||||
skill_name = entry.get("name", "").lower()
|
||||
skill_path = entry.get("path", "").lower()
|
||||
identifier = entry.get("identifier", "")
|
||||
|
||||
# Extract the skill token from the identifier
|
||||
# e.g. "skills-sh/d4vinci/scrapling/scrapling-official" -> "scrapling-official"
|
||||
parts = identifier.replace("skills-sh/", "").replace("skills.sh/", "")
|
||||
skill_token = parts.split("/")[-1].lower() if "/" in parts else ""
|
||||
|
||||
# Try matching in order of likelihood
|
||||
for candidate in [skill_token, skill_name, skill_path]:
|
||||
if not candidate:
|
||||
continue
|
||||
matched = skill_paths.get(candidate)
|
||||
if matched:
|
||||
entry["resolved_github_id"] = matched
|
||||
count += 1
|
||||
break
|
||||
else:
|
||||
# Try fuzzy: skill_token with common transformations
|
||||
for tree_name, tree_path in skill_paths.items():
|
||||
if (skill_token and (
|
||||
tree_name.replace("-", "") == skill_token.replace("-", "")
|
||||
or skill_token in tree_name
|
||||
or tree_name in skill_token
|
||||
)):
|
||||
entry["resolved_github_id"] = tree_path
|
||||
count += 1
|
||||
break
|
||||
|
||||
return count
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
futures = {
|
||||
pool.submit(_resolve_repo, repo, entries): repo
|
||||
for repo, entries in by_repo.items()
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
resolved_count += future.result()
|
||||
except Exception as e:
|
||||
repo = futures[future]
|
||||
print(f" Warning: {repo}: {e}", file=sys.stderr)
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f" Resolved {resolved_count}/{len(skills_sh)} paths ({elapsed:.1f}s)",
|
||||
flush=True)
|
||||
return skills
|
||||
|
||||
|
||||
def main():
|
||||
print("Building Hermes Skills Index...", flush=True)
|
||||
overall_start = time.time()
|
||||
|
||||
auth = GitHubAuth()
|
||||
print(f"GitHub auth: {auth.auth_method()}")
|
||||
if auth.auth_method() == "anonymous":
|
||||
print("WARNING: No GitHub authentication — rate limit is 60/hr. "
|
||||
"Set GITHUB_TOKEN for better results.", file=sys.stderr)
|
||||
|
||||
skills_sh_source = SkillsShSource(auth=auth)
|
||||
sources = {
|
||||
"official": OptionalSkillSource(),
|
||||
"well-known": WellKnownSkillSource(),
|
||||
"github": GitHubSource(auth=auth),
|
||||
"clawhub": ClawHubSource(),
|
||||
"claude-marketplace": ClaudeMarketplaceSource(auth=auth),
|
||||
"lobehub": LobeHubSource(),
|
||||
"browse-sh": BrowseShSource(),
|
||||
}
|
||||
|
||||
all_skills: list[dict] = []
|
||||
|
||||
# Crawl skills.sh
|
||||
all_skills.extend(crawl_skills_sh(skills_sh_source))
|
||||
|
||||
# Crawl other sources in parallel.
|
||||
# Per-source soft caps — sources stop returning when they run out, so these
|
||||
# are ceilings, not targets. ClawHub has 20k+ skills; bumping to 100k
|
||||
# (well above current catalog size) lets the full catalog land in the
|
||||
# index instead of being truncated at an arbitrary build-time limit.
|
||||
SOURCE_LIMITS = {
|
||||
# 0 = unbounded catalog walk (max_items=0 in ClawHubSource). A positive
|
||||
# limit bounds the walk and also enables the interactive 12s budget.
|
||||
"clawhub": 0,
|
||||
"lobehub": 100_000,
|
||||
"browse-sh": 5_000,
|
||||
"claude-marketplace": 5_000,
|
||||
"github": 5_000,
|
||||
"well-known": 5_000,
|
||||
"official": 5_000,
|
||||
}
|
||||
DEFAULT_SOURCE_LIMIT = 500
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
futures = {}
|
||||
for name, source in sources.items():
|
||||
limit = SOURCE_LIMITS.get(name, DEFAULT_SOURCE_LIMIT)
|
||||
futures[pool.submit(crawl_source, source, name, limit)] = name
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
all_skills.extend(future.result())
|
||||
except Exception as e:
|
||||
print(f" Error: {e}", file=sys.stderr)
|
||||
|
||||
# Batch resolve GitHub paths for skills.sh entries
|
||||
all_skills = batch_resolve_paths(all_skills, auth)
|
||||
|
||||
# Collect which sources hit a GitHub API rate limit during the crawl.
|
||||
# github / claude-marketplace / well-known all read api.github.com, so a
|
||||
# rate-limited token zeroes all three at once — surfaced below so the
|
||||
# failure message names the real cause instead of "source returned 0".
|
||||
rate_limited_sources = {
|
||||
name for name, source in sources.items()
|
||||
if getattr(source, "is_rate_limited", False)
|
||||
}
|
||||
if rate_limited_sources:
|
||||
print(
|
||||
" WARNING: GitHub API rate limit hit for: "
|
||||
+ ", ".join(sorted(rate_limited_sources)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Deduplicate by identifier
|
||||
seen: dict[str, dict] = {}
|
||||
for skill in all_skills:
|
||||
key = skill["identifier"]
|
||||
if key not in seen:
|
||||
seen[key] = skill
|
||||
deduped = list(seen.values())
|
||||
|
||||
# Sort
|
||||
source_order = {"official": 0, "skills-sh": 1, "skills.sh": 1,
|
||||
"github": 2, "well-known": 3, "clawhub": 4,
|
||||
"browse-sh": 5, "claude-marketplace": 6, "lobehub": 7}
|
||||
deduped.sort(key=lambda s: (source_order.get(s["source"], 99), s["name"]))
|
||||
|
||||
from collections import Counter
|
||||
by_source = Counter(s["source"] for s in deduped)
|
||||
print(f"\nCrawled {len(deduped)} skills in {time.time() - overall_start:.0f}s")
|
||||
for src, count in sorted(by_source.items(), key=lambda x: -x[1]):
|
||||
resolved = sum(1 for s in deduped
|
||||
if s["source"] == src and s.get("resolved_github_id"))
|
||||
extra = f" ({resolved} resolved)" if resolved else ""
|
||||
print(f" {src}: {count}{extra}")
|
||||
|
||||
# Health check: catch silent breakage early. Every source listed below
|
||||
# has historically returned at least `floor` entries; a zero (or near-
|
||||
# zero) result almost certainly means a tap path moved, an API changed,
|
||||
# or rate limiting kicked in. Failing here forces a human look before
|
||||
# the broken index reaches the live docs.
|
||||
EXPECTED_FLOORS = {
|
||||
# skills.sh now uses the sitemap walker (~20k catalog as of May 2026).
|
||||
# Anything under 10k means the sitemap shape changed or fetches failed
|
||||
# — better to fail loudly than ship a regression to the 858-skill
|
||||
# popular-queries era.
|
||||
"skills.sh": 10000,
|
||||
"lobehub": 100,
|
||||
# ClawHub had 49,698+ skills as of May 2026 — anything under 20k means
|
||||
# pagination broke or the API surface changed. Fail loudly rather
|
||||
# than ship a degenerate index (we shipped 200/50000 silently for
|
||||
# weeks because the floor was 50).
|
||||
"clawhub": 20000,
|
||||
"official": 50,
|
||||
"github": 30, # collapsed across all GitHub taps
|
||||
"browse-sh": 50,
|
||||
}
|
||||
health_errors = []
|
||||
for src, floor in EXPECTED_FLOORS.items():
|
||||
# 'skills-sh' and 'skills.sh' are the same source; both labels exist.
|
||||
count = by_source.get(src, 0)
|
||||
if src == "skills.sh":
|
||||
count = by_source.get("skills.sh", 0) + by_source.get("skills-sh", 0)
|
||||
if count < floor:
|
||||
health_errors.append(f" {src}: {count} < expected floor {floor}")
|
||||
|
||||
MIN_TOTAL = 1500
|
||||
if len(deduped) < MIN_TOTAL:
|
||||
health_errors.append(
|
||||
f" total: {len(deduped)} < expected floor {MIN_TOTAL}"
|
||||
)
|
||||
|
||||
if health_errors:
|
||||
print(
|
||||
"\nERROR: skills index health check failed — refusing to ship "
|
||||
"a degenerate index. Investigate the following sources:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for line in health_errors:
|
||||
print(line, file=sys.stderr)
|
||||
if rate_limited_sources:
|
||||
print(
|
||||
"\nGitHub API rate limit was hit during this crawl for: "
|
||||
+ ", ".join(sorted(rate_limited_sources))
|
||||
+ ". This is the usual cause of an all-GitHub-tap collapse "
|
||||
"(github / claude-marketplace / well-known dropping to zero "
|
||||
"together). Re-run with a higher-quota GITHUB_TOKEN.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nIf the drop is expected (e.g. a hub is genuinely shutting "
|
||||
"down), lower the floor in scripts/build_skills_index.py "
|
||||
"EXPECTED_FLOORS in the same PR.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# IMPORTANT: do NOT write OUTPUT_PATH on failure. The index file is
|
||||
# gitignored, so a fresh deploy checkout has no copy on disk — leaving
|
||||
# it absent lets website/scripts/extract-skills.py fall back to the
|
||||
# legacy snapshot cache (or skip the unified index) instead of reading
|
||||
# a degenerate file. Writing-then-exiting-2 was the bug that shipped an
|
||||
# index with every GitHub-API source dropped to zero: deploy-site.yml
|
||||
# swallows the exit code with `|| echo non-fatal`, and the partial file
|
||||
# was already on disk for extract-skills to pick up.
|
||||
sys.exit(2)
|
||||
|
||||
# Healthy — only now write the index out for the docs build to consume.
|
||||
index = {
|
||||
"version": INDEX_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"skill_count": len(deduped),
|
||||
"skills": deduped,
|
||||
}
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
|
||||
file_size = os.path.getsize(OUTPUT_PATH)
|
||||
print(f"\nDone! {len(deduped)} skills indexed in "
|
||||
f"{time.time() - overall_start:.0f}s")
|
||||
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Grep-based checker for Windows cross-platform footguns.
|
||||
|
||||
Flags common patterns that break silently on Windows. Run before PRs —
|
||||
cheap, fast, catches regressions in a codebase that runs on three OSes.
|
||||
|
||||
Usage:
|
||||
# Scan staged changes (default when run from a git checkout)
|
||||
python scripts/check-windows-footguns.py
|
||||
|
||||
# Scan the full tree (full-repo audit)
|
||||
python scripts/check-windows-footguns.py --all
|
||||
|
||||
# Scan a specific file or directory
|
||||
python scripts/check-windows-footguns.py path/to/file.py path/to/dir/
|
||||
|
||||
# Scan only modified files vs. main
|
||||
python scripts/check-windows-footguns.py --diff main
|
||||
|
||||
Exit status:
|
||||
0 — no Windows footguns found (or all matches suppressed)
|
||||
1 — at least one unsuppressed match
|
||||
|
||||
Suppress an intentional use (e.g. tests or platform-gated code) with:
|
||||
os.kill(pid, 0) # windows-footgun: ok — only called on POSIX
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE)
|
||||
|
||||
# Line-level guard hints. If a line contains any of these tokens, we assume
|
||||
# the programmer wrote the line in full awareness of the Windows pitfall —
|
||||
# e.g. `if hasattr(os, 'setsid'): ... os.setsid()`, or the classic
|
||||
# `getattr(signal, 'SIGKILL', signal.SIGTERM)`, or `shutil.which("wmic")`.
|
||||
# False negatives are fine here — the inline `# windows-footgun: ok` marker
|
||||
# is still the authoritative suppression. This is just to reduce the noise
|
||||
# floor on obviously-guarded lines so the signal-to-noise stays useful.
|
||||
GUARD_HINTS = (
|
||||
"hasattr(os,",
|
||||
"hasattr(signal,",
|
||||
"getattr(os,",
|
||||
"getattr(signal,",
|
||||
"shutil.which(",
|
||||
"if platform.system() != \"Windows\"",
|
||||
"if platform.system() != 'Windows'",
|
||||
"if sys.platform == \"win32\"",
|
||||
"if sys.platform != \"win32\"",
|
||||
"if sys.platform == 'win32'",
|
||||
"if sys.platform != 'win32'",
|
||||
"IS_WINDOWS",
|
||||
"is_windows",
|
||||
)
|
||||
|
||||
# Dirs we never scan.
|
||||
EXCLUDED_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"venv",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
".tox",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
"site-packages",
|
||||
"website/build",
|
||||
"optional-skills", # external skills
|
||||
}
|
||||
|
||||
# File globs we never scan (beyond the dirs above).
|
||||
EXCLUDED_SUFFIXES = {
|
||||
".pyc",
|
||||
".pyo",
|
||||
".so",
|
||||
".dll",
|
||||
".exe",
|
||||
".png",
|
||||
".jpg",
|
||||
".gif",
|
||||
".ico",
|
||||
".svg",
|
||||
".mp4",
|
||||
".mp3",
|
||||
".wav",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".whl",
|
||||
".lock",
|
||||
".min.js",
|
||||
".min.css",
|
||||
}
|
||||
|
||||
# Files we never scan (self-referential — this script mentions the
|
||||
# patterns it detects — and the CONTRIBUTING docs that list them).
|
||||
EXCLUDED_FILES = {
|
||||
"scripts/check-windows-footguns.py",
|
||||
"CONTRIBUTING.md",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Footgun:
|
||||
"""A Windows cross-platform footgun pattern."""
|
||||
|
||||
name: str
|
||||
pattern: re.Pattern
|
||||
message: str
|
||||
fix: str
|
||||
# If set, matches in files/paths containing any of these substrings are
|
||||
# silently ignored (e.g. tests that legitimately exercise the footgun
|
||||
# behind a platform guard). Prefer `# windows-footgun: ok` inline
|
||||
# suppression over this list; only use path_allowlist for whole files
|
||||
# that are inherently tests of the footgun itself.
|
||||
path_allowlist: tuple[str, ...] = ()
|
||||
# Optional post-match predicate. Takes the re.Match and returns True
|
||||
# if the match is a REAL footgun (not a false positive). Use this when
|
||||
# the regex can't fully distinguish (e.g. open() where mode may contain
|
||||
# "b" for binary, or the line may have `encoding=` elsewhere).
|
||||
post_filter: "callable | None" = None
|
||||
|
||||
|
||||
FOOTGUNS: list[Footgun] = [
|
||||
Footgun(
|
||||
name="open() without encoding= on text mode",
|
||||
# Match builtins.open() specifically — NOT os.open(), .open()
|
||||
# method calls (Path.open, tarfile.open, zf.open, webbrowser.open,
|
||||
# Image.open, wave.open, etc), or `async def open()` method
|
||||
# definitions. The pattern requires a start-of-identifier boundary
|
||||
# before `open(` so `os.open`, `.open`, `def open` are all skipped.
|
||||
# Note: Path.open() is ALSO affected by the encoding default, but
|
||||
# rather than flagging all `.open(` (huge noise), we require an
|
||||
# explicit builtins-style open() call. Path.open() is rare in the
|
||||
# codebase compared to open() and can be audited separately.
|
||||
pattern=re.compile(
|
||||
r"""(?:^|[\s\(,;=])(?<![.\w])open\s*\(\s*[^,)]+\s*(?:,\s*['"](?P<mode>[^'"]*)['"])?"""
|
||||
),
|
||||
message=(
|
||||
"open() without an explicit encoding= uses the platform default "
|
||||
"(UTF-8 on POSIX, cp1252/mbcs on Windows) — files round-tripped "
|
||||
"between hosts get mojibake. Always pass encoding='utf-8' for "
|
||||
"text files, or use open(path, 'rb')/'wb' for binary."
|
||||
),
|
||||
fix=(
|
||||
"open(path, 'r', encoding='utf-8') # or 'utf-8-sig' if the "
|
||||
"file may have a BOM"
|
||||
),
|
||||
# Filter: only flag if mode is missing-or-text AND the line doesn't
|
||||
# already pass encoding=. Skip binary mode (contains "b").
|
||||
post_filter=lambda m, line: (
|
||||
"b" not in (m.group("mode") or "")
|
||||
and "encoding=" not in line
|
||||
and "encoding =" not in line
|
||||
# Skip `def open(` and `async def open(` (method definitions)
|
||||
and not line.lstrip().startswith("def ")
|
||||
and not line.lstrip().startswith("async def ")
|
||||
# Skip open(path, **kwargs) patterns — encoding may be in the dict.
|
||||
# Too expensive to trace; require the author to set encoding in
|
||||
# the dict and trust them (or they can add a # windows-footgun: ok).
|
||||
and "**" not in line
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="os.kill(pid, 0)",
|
||||
pattern=re.compile(r"\bos\.kill\s*\(\s*[^,]+,\s*0\s*\)"),
|
||||
message=(
|
||||
"os.kill(pid, 0) is NOT a no-op on Windows — it sends "
|
||||
"CTRL_C_EVENT to the target's console process group, "
|
||||
"hard-killing the target and potentially unrelated siblings. "
|
||||
"See bpo-14484."
|
||||
),
|
||||
fix=(
|
||||
"Use psutil.pid_exists(pid) (psutil is a core dependency). "
|
||||
"Or gateway.status._pid_exists(pid) for the hermes wrapper "
|
||||
"with a stdlib fallback."
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare os.setsid",
|
||||
pattern=re.compile(r"(?<!hasattr\()\bos\.setsid\b"),
|
||||
message=(
|
||||
"os.setsid does not exist on Windows and raises "
|
||||
"AttributeError. Subprocesses that need detachment on "
|
||||
"Windows use creationflags instead."
|
||||
),
|
||||
fix=(
|
||||
"if platform.system() != 'Windows':\n"
|
||||
" kwargs['preexec_fn'] = os.setsid\n"
|
||||
"else:\n"
|
||||
" kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP"
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare os.killpg",
|
||||
pattern=re.compile(r"\bos\.killpg\b"),
|
||||
message="os.killpg does not exist on Windows.",
|
||||
fix=(
|
||||
"Use psutil for cross-platform process-tree kill:\n"
|
||||
" p = psutil.Process(pid)\n"
|
||||
" for c in p.children(recursive=True): c.kill()\n"
|
||||
" p.kill()"
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare os.getuid / os.geteuid / os.getgid",
|
||||
pattern=re.compile(r"\bos\.(?:getuid|geteuid|getgid|getegid)\b"),
|
||||
message=(
|
||||
"os.getuid / os.geteuid / os.getgid do not exist on Windows "
|
||||
"and raise AttributeError at import time if referenced."
|
||||
),
|
||||
fix=(
|
||||
"Use getpass.getuser() for the username, or gate with "
|
||||
"hasattr(os, 'getuid')."
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare os.fork",
|
||||
pattern=re.compile(r"(?<!hasattr\()\bos\.fork\s*\("),
|
||||
message="os.fork does not exist on Windows.",
|
||||
fix=(
|
||||
"Use subprocess.Popen for daemonization, or guard with "
|
||||
"hasattr(os, 'fork') and a Windows fallback path."
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare signal.SIGKILL",
|
||||
pattern=re.compile(r"\bsignal\.SIGKILL\b"),
|
||||
message=(
|
||||
"signal.SIGKILL does not exist on Windows and raises "
|
||||
"AttributeError at import time."
|
||||
),
|
||||
fix="Use getattr(signal, 'SIGKILL', signal.SIGTERM).",
|
||||
),
|
||||
Footgun(
|
||||
name="bare signal.SIGHUP / SIGUSR1 / SIGUSR2 / SIGALRM / SIGCHLD / SIGPIPE / SIGQUIT",
|
||||
pattern=re.compile(
|
||||
r"\bsignal\.(?:SIGHUP|SIGUSR1|SIGUSR2|SIGALRM|SIGCHLD|SIGPIPE|SIGQUIT)\b"
|
||||
),
|
||||
message=(
|
||||
"These POSIX signals don't exist on Windows; referencing "
|
||||
"them raises AttributeError at import time."
|
||||
),
|
||||
fix=(
|
||||
"Use getattr(signal, 'SIGXXX', None) and check for None "
|
||||
"before using, or gate the whole block behind a platform check."
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="subprocess shebang script invocation",
|
||||
pattern=re.compile(
|
||||
r"subprocess\.(?:run|Popen|call|check_output|check_call)\s*\(\s*\[\s*['\"]\./"
|
||||
),
|
||||
message=(
|
||||
"Running a script via './scriptname' doesn't work on Windows — "
|
||||
"shebang lines aren't honored. CreateProcessW can't execute "
|
||||
"bash/python scripts without an explicit interpreter."
|
||||
),
|
||||
fix="Use [sys.executable, 'scriptname.py', ...] explicitly.",
|
||||
),
|
||||
Footgun(
|
||||
name="wmic invocation without shutil.which guard",
|
||||
# Match wmic appearing as a subprocess argument — NOT the
|
||||
# shutil.which("wmic") guard pattern itself. Looks for wmic in a
|
||||
# list or as first arg of subprocess.run/Popen.
|
||||
pattern=re.compile(
|
||||
r"""(?:subprocess\.\w+\s*\(\s*\[\s*['"]wmic['"]|['"]wmic\.exe['"])"""
|
||||
),
|
||||
message=(
|
||||
"wmic was removed in Windows 10 21H1 and later. Always "
|
||||
"gate with shutil.which('wmic') and fall back to "
|
||||
"PowerShell (Get-CimInstance Win32_Process)."
|
||||
),
|
||||
fix=(
|
||||
"if shutil.which('wmic'):\n"
|
||||
" ... wmic path ...\n"
|
||||
"else:\n"
|
||||
" subprocess.run(['powershell', '-NoProfile', '-Command',\n"
|
||||
" 'Get-CimInstance Win32_Process | ...'])"
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="hardcoded ~/Desktop (OneDrive trap)",
|
||||
pattern=re.compile(
|
||||
r"""['"](?:~|~/|[A-Z]:[/\\]Users[/\\][^/\\'"]+[/\\])Desktop\b"""
|
||||
),
|
||||
message=(
|
||||
"When OneDrive Backup is enabled on Windows, the real Desktop "
|
||||
"is at %USERPROFILE%\\OneDrive\\Desktop, not %USERPROFILE%\\"
|
||||
"Desktop (which exists as an empty husk)."
|
||||
),
|
||||
fix=(
|
||||
"On Windows, resolve via ctypes + SHGetKnownFolderPath, or "
|
||||
"read the Shell Folders registry key, or run PowerShell "
|
||||
"[Environment]::GetFolderPath('Desktop')."
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="asyncio add_signal_handler without try/except",
|
||||
pattern=re.compile(r"\.add_signal_handler\s*\("),
|
||||
message=(
|
||||
"loop.add_signal_handler raises NotImplementedError on "
|
||||
"Windows — always wrap in try/except or gate with a "
|
||||
"platform check."
|
||||
),
|
||||
fix=(
|
||||
"try:\n"
|
||||
" loop.add_signal_handler(sig, handler, sig)\n"
|
||||
"except NotImplementedError:\n"
|
||||
" pass # Windows asyncio doesn't support signal handlers"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def should_scan_file(path: Path) -> bool:
|
||||
"""Return True if this file is in scope for the checker."""
|
||||
# Skip the excluded dirs
|
||||
parts = set(path.parts)
|
||||
if parts & EXCLUDED_DIRS:
|
||||
return False
|
||||
# Skip excluded suffixes
|
||||
for suffix in EXCLUDED_SUFFIXES:
|
||||
if str(path).endswith(suffix):
|
||||
return False
|
||||
# Skip self and docs that intentionally mention the patterns
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if rel in EXCLUDED_FILES:
|
||||
return False
|
||||
# Only scan text files (rough heuristic — .py, .md, .sh, .ps1, .yaml, etc.)
|
||||
if path.suffix in {".py", ".pyw", ".pyi"}:
|
||||
return True
|
||||
# Other file types are read but only Python-specific patterns would match;
|
||||
# that's fine and cheap to skip.
|
||||
return False
|
||||
|
||||
|
||||
def iter_files(paths: Iterable[Path]) -> Iterable[Path]:
|
||||
for p in paths:
|
||||
if p.is_file():
|
||||
if should_scan_file(p):
|
||||
yield p
|
||||
elif p.is_dir():
|
||||
for root, dirs, files in os.walk(p):
|
||||
# prune excluded dirs in-place for speed
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
|
||||
for fname in files:
|
||||
fpath = Path(root) / fname
|
||||
if should_scan_file(fpath):
|
||||
yield fpath
|
||||
|
||||
|
||||
def _strip_code(line: str) -> str:
|
||||
"""Return just the code portion of a line — strip trailing comments and
|
||||
skip lines that are entirely inside a string literal or comment.
|
||||
|
||||
Heuristic only (we don't parse Python); good enough to avoid flagging
|
||||
our own `# ``os.kill(pid, 0)`` is NOT a no-op` docstring-style comments.
|
||||
"""
|
||||
stripped = line.lstrip()
|
||||
# Line starts with # — entirely a comment.
|
||||
if stripped.startswith("#"):
|
||||
return ""
|
||||
# Remove trailing "# ..." inline comment. Naive — doesn't handle `#`
|
||||
# inside strings — but on balance reduces noise far more than it adds.
|
||||
hash_idx = _find_unquoted_hash(line)
|
||||
if hash_idx is not None:
|
||||
return line[:hash_idx]
|
||||
return line
|
||||
|
||||
|
||||
def _find_unquoted_hash(line: str) -> int | None:
|
||||
"""Index of the first `#` not inside a single/double/triple-quoted string.
|
||||
|
||||
Simple state machine — good enough for the 99% case of "code, then
|
||||
optional trailing comment."
|
||||
"""
|
||||
i = 0
|
||||
n = len(line)
|
||||
in_s = False # single-quote string
|
||||
in_d = False # double-quote string
|
||||
while i < n:
|
||||
c = line[i]
|
||||
if c == "\\" and (in_s or in_d) and i + 1 < n:
|
||||
i += 2
|
||||
continue
|
||||
if not in_d and c == "'":
|
||||
in_s = not in_s
|
||||
elif not in_s and c == '"':
|
||||
in_d = not in_d
|
||||
elif c == "#" and not in_s and not in_d:
|
||||
return i
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footgun]]:
|
||||
"""Return a list of (line_number, line, footgun) for unsuppressed matches."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
matches: list[tuple[int, str, Footgun]] = []
|
||||
|
||||
# Track whether we're inside a triple-quoted string (docstring/raw block).
|
||||
# Simple state machine — handles both ''' and """, toggled by the FIRST
|
||||
# triple-quote we see; we don't try to handle nested or f-string cases.
|
||||
in_triple: str | None = None # None, "'''", or '"""'
|
||||
|
||||
for i, line in enumerate(text.splitlines(), start=1):
|
||||
# Update triple-quote state based on this line's occurrences.
|
||||
code_for_scan = line
|
||||
if in_triple:
|
||||
# We're inside a docstring — skip the whole line's scan.
|
||||
# Check if it closes here.
|
||||
if in_triple in line:
|
||||
# Find the closing delimiter; anything after it is real code.
|
||||
after = line.split(in_triple, 1)[1]
|
||||
in_triple = None
|
||||
code_for_scan = after
|
||||
else:
|
||||
continue
|
||||
# Now check for docstring-open in the (possibly after-triple) portion.
|
||||
# Scan for the first unescaped '''/""" in the current code_for_scan.
|
||||
stripped = code_for_scan.strip()
|
||||
for delim in ('"""', "'''"):
|
||||
if delim in code_for_scan:
|
||||
# Count occurrences — even count means single-line docstring,
|
||||
# odd means we've entered a multi-line one.
|
||||
count = code_for_scan.count(delim)
|
||||
if count % 2 == 1:
|
||||
# Odd — we're now inside the triple-quoted block.
|
||||
# Scan only the part BEFORE the opening delimiter.
|
||||
before = code_for_scan.split(delim, 1)[0]
|
||||
code_for_scan = before
|
||||
in_triple = delim
|
||||
break
|
||||
else:
|
||||
# Even — entire docstring fits on one line. Strip it
|
||||
# from the scan text to avoid matching on prose.
|
||||
parts = code_for_scan.split(delim)
|
||||
# Keep the "outside" parts (every other chunk, starting
|
||||
# with index 0) as code, drop the "inside" parts.
|
||||
code_for_scan = "".join(parts[::2])
|
||||
break
|
||||
|
||||
if SUPPRESS_MARKER.search(line):
|
||||
continue
|
||||
# Skip if the line has an obvious guard — e.g. hasattr/getattr/
|
||||
# shutil.which or a platform check. False negatives are acceptable;
|
||||
# the inline suppression marker is the authoritative override.
|
||||
if any(hint in line for hint in GUARD_HINTS):
|
||||
continue
|
||||
code = _strip_code(code_for_scan)
|
||||
if not code.strip():
|
||||
continue
|
||||
for fg in footguns:
|
||||
if fg.path_allowlist and any(s in str(path) for s in fg.path_allowlist):
|
||||
continue
|
||||
match = fg.pattern.search(code)
|
||||
if not match:
|
||||
continue
|
||||
if fg.post_filter is not None:
|
||||
try:
|
||||
if not fg.post_filter(match, line):
|
||||
continue
|
||||
except (IndexError, AttributeError):
|
||||
# Post-filter assumed a named group that isn't there — skip.
|
||||
continue
|
||||
matches.append((i, line.rstrip(), fg))
|
||||
return matches
|
||||
|
||||
|
||||
def get_staged_files() -> list[Path]:
|
||||
"""Return paths staged in the current git index. Empty on non-git trees."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
cwd=REPO_ROOT,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
return [REPO_ROOT / f for f in out.splitlines() if f.strip()]
|
||||
|
||||
|
||||
def get_diff_files(ref: str) -> list[Path]:
|
||||
"""Return paths modified vs. the given git ref."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", "diff", f"{ref}...HEAD", "--name-only", "--diff-filter=ACMR"],
|
||||
cwd=REPO_ROOT,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
return [REPO_ROOT / f for f in out.splitlines() if f.strip()]
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Flag Windows cross-platform footguns in Python code."
|
||||
)
|
||||
p.add_argument(
|
||||
"paths",
|
||||
nargs="*",
|
||||
type=Path,
|
||||
help="Specific files/dirs to scan (default: staged changes).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Scan the full repository (hermes_cli/, gateway/, tools/, cron/, etc.).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--diff",
|
||||
metavar="REF",
|
||||
help="Scan files changed vs. the given git ref (e.g. --diff main).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List all known footgun rules and exit.",
|
||||
)
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def print_rules() -> None:
|
||||
print("Known Windows footguns checked by this script:\n")
|
||||
for i, fg in enumerate(FOOTGUNS, start=1):
|
||||
print(f"{i:2}. {fg.name}")
|
||||
print(f" {fg.message}")
|
||||
print(f" Fix: {fg.fix}")
|
||||
print()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
# Windows terminals default to cp1252, which can't encode the ✓/✗
|
||||
# characters used in the output. Reconfigure streams to UTF-8 so the
|
||||
# script works correctly on the very platform it is designed to help.
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
args = parse_args(argv)
|
||||
|
||||
if args.list:
|
||||
print_rules()
|
||||
return 0
|
||||
|
||||
if args.all:
|
||||
# Scan main Python packages + scripts
|
||||
roots = [
|
||||
REPO_ROOT / "hermes_cli",
|
||||
REPO_ROOT / "gateway",
|
||||
REPO_ROOT / "tools",
|
||||
REPO_ROOT / "cron",
|
||||
REPO_ROOT / "agent",
|
||||
REPO_ROOT / "plugins",
|
||||
REPO_ROOT / "scripts",
|
||||
REPO_ROOT / "acp_adapter",
|
||||
REPO_ROOT / "acp_registry",
|
||||
]
|
||||
roots = [r for r in roots if r.exists()]
|
||||
elif args.diff:
|
||||
roots = get_diff_files(args.diff)
|
||||
elif args.paths:
|
||||
roots = [p.resolve() for p in args.paths]
|
||||
else:
|
||||
# Default: staged changes
|
||||
roots = get_staged_files()
|
||||
if not roots:
|
||||
print(
|
||||
"No staged files to scan. Pass --all for a full-repo scan, "
|
||||
"--diff <ref> for a range diff, or paths explicitly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
total_matches = 0
|
||||
files_scanned = 0
|
||||
for path in iter_files(roots):
|
||||
files_scanned += 1
|
||||
matches = scan_file(path, FOOTGUNS)
|
||||
for lineno, line, fg in matches:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
print(f"{rel}:{lineno}: [{fg.name}]")
|
||||
print(f" {line.strip()}")
|
||||
print(f" — {fg.message}")
|
||||
print(f" Fix: {fg.fix.splitlines()[0]}")
|
||||
print()
|
||||
total_matches += 1
|
||||
|
||||
if total_matches:
|
||||
print(
|
||||
f"\n✗ {total_matches} Windows footgun(s) found across "
|
||||
f"{files_scanned} file(s) scanned.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" If an individual match is a false positive or intentionally "
|
||||
"platform-gated, suppress it with `# windows-footgun: ok` on "
|
||||
"the same line.\n Run with --list to see all rules.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"✓ No Windows footguns found ({files_scanned} file(s) scanned)."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that subprocess calls in TUI-context code specify stdin=.
|
||||
|
||||
When Hermes runs in TUI mode, the gateway child process communicates with
|
||||
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
|
||||
inherit this fd can cause the gateway to exit with stdin EOF during tool
|
||||
execution (issue #14036, PR #39257).
|
||||
|
||||
This script checks that all subprocess.run() and subprocess.Popen() calls
|
||||
in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly
|
||||
set stdin= to prevent fd inheritance.
|
||||
|
||||
Exit codes:
|
||||
0 — all calls are safe
|
||||
1 — violations found
|
||||
2 — script error
|
||||
|
||||
Usage:
|
||||
python scripts/check_subprocess_stdin.py [--fix]
|
||||
|
||||
With --fix, prints the commands to add stdin=subprocess.DEVNULL to each
|
||||
violation (does not modify files).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Directories that run inside the TUI gateway child process.
|
||||
TUI_CONTEXT_DIRS = [
|
||||
"agent/",
|
||||
"tools/",
|
||||
"plugins/",
|
||||
"tui_gateway/",
|
||||
]
|
||||
|
||||
# Files with intentional stdin= override (e.g. input= creates a pipe).
|
||||
# Format: "filepath:line" or just "filepath" to skip the whole file.
|
||||
KNOWN_SAFE = {
|
||||
"agent/shell_hooks.py", # uses input=stdin_json, creates a pipe
|
||||
"plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls
|
||||
}
|
||||
|
||||
# Inline marker that exempts a single subprocess call from this check.
|
||||
# Put it in a comment on (or within) the call when the process MUST inherit
|
||||
# stdin — e.g. an interactive login the user explicitly invokes. Travels with
|
||||
# the line, so it survives edits that shift line numbers (unlike a pinned
|
||||
# file:line entry).
|
||||
EXEMPT_MARKER = "noqa: subprocess-stdin"
|
||||
|
||||
# Directories to skip entirely.
|
||||
SKIP_DIRS = {
|
||||
"tests/",
|
||||
"scripts/",
|
||||
"skills/",
|
||||
"optional-skills/",
|
||||
"hermes_cli/",
|
||||
"gateway/",
|
||||
"cron/",
|
||||
}
|
||||
|
||||
|
||||
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
|
||||
"""Find all subprocess.run/Popen calls missing stdin= in content."""
|
||||
violations = []
|
||||
lines = content.split("\n")
|
||||
|
||||
# Match only actual function calls — not comments, docstrings, or prose.
|
||||
# The pattern requires an opening paren followed by an arg character
|
||||
# (quote, bracket, letter, or closing paren for empty calls).
|
||||
# This excludes ``subprocess.Popen(...)`` in docstrings and
|
||||
# subprocess.run(...) in comments.
|
||||
pattern = re.compile(r'subprocess\.(run|Popen)\s*\(["\'a-zA-Z_\[\(]')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# Skip comments.
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Skip lines where the match is inside backticks (docstring references).
|
||||
if "``subprocess" in line:
|
||||
continue
|
||||
|
||||
if not pattern.search(line):
|
||||
continue
|
||||
|
||||
# Collect the full call (may span multiple lines).
|
||||
call_start = i
|
||||
paren_depth = 0
|
||||
found_open = False
|
||||
call_lines = []
|
||||
for j in range(i, min(i + 30, len(lines))):
|
||||
call_lines.append(lines[j])
|
||||
for ch in lines[j]:
|
||||
if ch == "(":
|
||||
paren_depth += 1
|
||||
found_open = True
|
||||
elif ch == ")":
|
||||
paren_depth -= 1
|
||||
if found_open and paren_depth == 0:
|
||||
call_text = "\n".join(call_lines)
|
||||
|
||||
# Already has stdin= → safe.
|
||||
if "stdin=" in call_text:
|
||||
break
|
||||
|
||||
# Has input= → creates a pipe, safe.
|
||||
if "input=" in call_text:
|
||||
break
|
||||
|
||||
# Inline exemption marker on the call itself or within
|
||||
# the few comment lines immediately above it → the call
|
||||
# intentionally inherits stdin.
|
||||
window_start = max(0, i - 4)
|
||||
preceding = "\n".join(lines[window_start:i])
|
||||
if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding:
|
||||
break
|
||||
|
||||
violations.append({
|
||||
"file": filepath,
|
||||
"line": i + 1,
|
||||
"snippet": line.strip()[:120],
|
||||
})
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fix_mode = "--fix" in sys.argv
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
os.chdir(repo_root)
|
||||
|
||||
all_violations = []
|
||||
|
||||
for tui_dir in TUI_CONTEXT_DIRS:
|
||||
dirpath = repo_root / tui_dir
|
||||
if not dirpath.exists():
|
||||
continue
|
||||
|
||||
for py_file in dirpath.rglob("*.py"):
|
||||
rel = str(py_file.relative_to(repo_root))
|
||||
|
||||
# Skip known-safe files.
|
||||
if rel in KNOWN_SAFE:
|
||||
continue
|
||||
|
||||
# Skip test files inside tools/ etc.
|
||||
parts = py_file.parts
|
||||
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
|
||||
continue
|
||||
|
||||
content = py_file.read_text()
|
||||
violations = find_subprocess_calls(content, rel)
|
||||
all_violations.extend(violations)
|
||||
|
||||
if all_violations:
|
||||
print(f"❌ {len(all_violations)} subprocess calls missing stdin=:")
|
||||
for v in all_violations:
|
||||
print(f" {v['file']}:{v['line']}: {v['snippet']}")
|
||||
if fix_mode:
|
||||
print("\nAdd stdin=subprocess.DEVNULL to each call above.")
|
||||
return 1
|
||||
else:
|
||||
print("✅ All TUI-context subprocess calls have explicit stdin=")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contributor Audit Script
|
||||
|
||||
Cross-references git authors, Co-authored-by trailers, and salvaged PR
|
||||
descriptions to find any contributors missing from the release notes.
|
||||
|
||||
Usage:
|
||||
# Basic audit since a tag
|
||||
python scripts/contributor_audit.py --since-tag v2026.4.8
|
||||
|
||||
# Audit with a custom endpoint
|
||||
python scripts/contributor_audit.py --since-tag v2026.4.8 --until v2026.4.13
|
||||
|
||||
# Compare against a release notes file
|
||||
python scripts/contributor_audit.py --since-tag v2026.4.8 --release-file RELEASE_v0.9.0.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import AUTHOR_MAP and resolve_author from the sibling release.py module
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from release import resolve_author # noqa: E402
|
||||
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI assistants, bots, and machine accounts to exclude from contributor lists
|
||||
# ---------------------------------------------------------------------------
|
||||
IGNORED_PATTERNS = [
|
||||
re.compile(r"^Claude", re.IGNORECASE),
|
||||
re.compile(r"^Copilot$", re.IGNORECASE),
|
||||
re.compile(r"^Cursor(\s+Agent)?$", re.IGNORECASE),
|
||||
re.compile(r"^Codex$", re.IGNORECASE),
|
||||
re.compile(r"^github-advanced-security(\[bot\])?$", re.IGNORECASE),
|
||||
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
|
||||
re.compile(r"^github-actions(\[bot\])?$", re.IGNORECASE),
|
||||
re.compile(r"^dependabot", re.IGNORECASE),
|
||||
re.compile(r"^renovate", re.IGNORECASE),
|
||||
re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE),
|
||||
re.compile(r"^Ubuntu$", re.IGNORECASE),
|
||||
]
|
||||
|
||||
IGNORED_EMAILS = {
|
||||
"noreply@anthropic.com",
|
||||
"noreply@github.com",
|
||||
"noreply@nousresearch.com",
|
||||
"cursoragent@cursor.com",
|
||||
"hermes@nousresearch.com",
|
||||
"hermes-audit@example.com",
|
||||
"hermes@habibilabs.dev",
|
||||
"omx@oh-my-codex.dev",
|
||||
}
|
||||
|
||||
|
||||
def is_ignored(handle: str, email: str = "") -> bool:
|
||||
"""Return True if this contributor is a bot/AI/machine account."""
|
||||
if email in IGNORED_EMAILS:
|
||||
return True
|
||||
for pattern in IGNORED_PATTERNS:
|
||||
if pattern.search(handle):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def git(*args, cwd=None):
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run(
|
||||
["git"] + list(args),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or str(REPO_ROOT),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" [warn] git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr)
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def gh_pr_list():
|
||||
"""Fetch merged PRs from GitHub using the gh CLI.
|
||||
|
||||
Returns a list of dicts with keys: number, title, body, author.
|
||||
Returns an empty list if gh is not available or the call fails.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh", "pr", "list",
|
||||
"--repo", "NousResearch/hermes-agent",
|
||||
"--state", "merged",
|
||||
"--json", "number,title,body,author,mergedAt",
|
||||
"--limit", "300",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" [warn] gh pr list failed: {result.stderr.strip()}", file=sys.stderr)
|
||||
return []
|
||||
return json.loads(result.stdout)
|
||||
except FileNotFoundError:
|
||||
print(" [warn] 'gh' CLI not found — skipping salvaged PR scan.", file=sys.stderr)
|
||||
return []
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" [warn] gh pr list timed out — skipping salvaged PR scan.", file=sys.stderr)
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
print(" [warn] gh pr list returned invalid JSON — skipping salvaged PR scan.", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contributor collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Patterns that indicate salvaged/cherry-picked/co-authored work in PR bodies
|
||||
SALVAGE_PATTERNS = [
|
||||
# "Salvaged from @username" or "Salvaged from #123"
|
||||
re.compile(r"[Ss]alvaged\s+from\s+@(\w[\w-]*)"),
|
||||
re.compile(r"[Ss]alvaged\s+from\s+#(\d+)"),
|
||||
# "Cherry-picked from @username"
|
||||
re.compile(r"[Cc]herry[- ]?picked\s+from\s+@(\w[\w-]*)"),
|
||||
# "Based on work by @username"
|
||||
re.compile(r"[Bb]ased\s+on\s+work\s+by\s+@(\w[\w-]*)"),
|
||||
# "Original PR by @username"
|
||||
re.compile(r"[Oo]riginal\s+PR\s+by\s+@(\w[\w-]*)"),
|
||||
# "Co-authored with @username"
|
||||
re.compile(r"[Cc]o[- ]?authored\s+with\s+@(\w[\w-]*)"),
|
||||
]
|
||||
|
||||
# Pattern for Co-authored-by trailers in commit messages
|
||||
CO_AUTHORED_RE = re.compile(
|
||||
r"Co-authored-by:\s*(.+?)\s*<([^>]+)>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def collect_commit_authors(since_tag, until="HEAD"):
|
||||
"""Collect contributors from git commit authors.
|
||||
|
||||
Returns:
|
||||
contributors: dict mapping github_handle -> set of source labels
|
||||
unknown_emails: dict mapping email -> git name (for emails not in AUTHOR_MAP)
|
||||
"""
|
||||
range_spec = f"{since_tag}..{until}"
|
||||
log = git(
|
||||
"log", range_spec,
|
||||
"--format=%H|%an|%ae|%s",
|
||||
"--no-merges",
|
||||
)
|
||||
|
||||
contributors = defaultdict(set)
|
||||
unknown_emails = {}
|
||||
|
||||
if not log:
|
||||
return contributors, unknown_emails
|
||||
|
||||
for line in log.split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
_sha, name, email, _subject = parts
|
||||
|
||||
handle = resolve_author(name, email)
|
||||
# resolve_author returns "@handle" or plain name
|
||||
if handle.startswith("@"):
|
||||
contributors[handle.lstrip("@")].add("commit")
|
||||
else:
|
||||
# Could not resolve — record as unknown
|
||||
contributors[handle].add("commit")
|
||||
unknown_emails[email] = name
|
||||
|
||||
return contributors, unknown_emails
|
||||
|
||||
|
||||
def collect_co_authors(since_tag, until="HEAD"):
|
||||
"""Collect contributors from Co-authored-by trailers in commit messages.
|
||||
|
||||
Returns:
|
||||
contributors: dict mapping github_handle -> set of source labels
|
||||
unknown_emails: dict mapping email -> git name
|
||||
"""
|
||||
range_spec = f"{since_tag}..{until}"
|
||||
# Get full commit messages to scan for trailers
|
||||
log = git(
|
||||
"log", range_spec,
|
||||
"--format=__COMMIT__%H%n%b",
|
||||
"--no-merges",
|
||||
)
|
||||
|
||||
contributors = defaultdict(set)
|
||||
unknown_emails = {}
|
||||
|
||||
if not log:
|
||||
return contributors, unknown_emails
|
||||
|
||||
for line in log.split("\n"):
|
||||
match = CO_AUTHORED_RE.search(line)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
email = match.group(2).strip()
|
||||
handle = resolve_author(name, email)
|
||||
if handle.startswith("@"):
|
||||
contributors[handle.lstrip("@")].add("co-author")
|
||||
else:
|
||||
contributors[handle].add("co-author")
|
||||
unknown_emails[email] = name
|
||||
|
||||
return contributors, unknown_emails
|
||||
|
||||
|
||||
def collect_salvaged_contributors(since_tag, until="HEAD"):
|
||||
"""Scan merged PR bodies for salvage/cherry-pick/co-author attribution.
|
||||
|
||||
Uses the gh CLI to fetch PRs, then filters to the date range defined
|
||||
by since_tag..until and scans bodies for salvage patterns.
|
||||
|
||||
Returns:
|
||||
contributors: dict mapping github_handle -> set of source labels
|
||||
pr_refs: dict mapping github_handle -> list of PR numbers where found
|
||||
"""
|
||||
contributors = defaultdict(set)
|
||||
pr_refs = defaultdict(list)
|
||||
|
||||
# Determine the date range from git tags/refs
|
||||
since_date = git("log", "-1", "--format=%aI", since_tag)
|
||||
if until == "HEAD":
|
||||
until_date = git("log", "-1", "--format=%aI", "HEAD")
|
||||
else:
|
||||
until_date = git("log", "-1", "--format=%aI", until)
|
||||
|
||||
if not since_date:
|
||||
print(f" [warn] Could not resolve date for {since_tag}", file=sys.stderr)
|
||||
return contributors, pr_refs
|
||||
|
||||
prs = gh_pr_list()
|
||||
if not prs:
|
||||
return contributors, pr_refs
|
||||
|
||||
for pr in prs:
|
||||
# Filter by merge date if available
|
||||
merged_at = pr.get("mergedAt", "")
|
||||
if merged_at and since_date:
|
||||
if merged_at < since_date:
|
||||
continue
|
||||
if until_date and merged_at > until_date:
|
||||
continue
|
||||
|
||||
body = pr.get("body") or ""
|
||||
pr_number = pr.get("number", "?")
|
||||
|
||||
# Also credit the PR author
|
||||
pr_author = pr.get("author", {})
|
||||
pr_author_login = pr_author.get("login", "") if isinstance(pr_author, dict) else ""
|
||||
|
||||
for pattern in SALVAGE_PATTERNS:
|
||||
for match in pattern.finditer(body):
|
||||
value = match.group(1)
|
||||
# If it's a number, it's a PR reference — skip for now
|
||||
# (would need another API call to resolve PR author)
|
||||
if value.isdigit():
|
||||
continue
|
||||
contributors[value].add("salvage")
|
||||
pr_refs[value].append(pr_number)
|
||||
|
||||
return contributors, pr_refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Release file comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_release_file(release_file, all_contributors):
|
||||
"""Check which contributors are mentioned in the release file.
|
||||
|
||||
Returns:
|
||||
mentioned: set of handles found in the file
|
||||
missing: set of handles NOT found in the file
|
||||
"""
|
||||
try:
|
||||
content = Path(release_file).read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f" [error] Release file not found: {release_file}", file=sys.stderr)
|
||||
return set(), set(all_contributors)
|
||||
|
||||
mentioned = set()
|
||||
missing = set()
|
||||
content_lower = content.lower()
|
||||
|
||||
for handle in all_contributors:
|
||||
# Check for @handle or just handle (case-insensitive)
|
||||
if f"@{handle.lower()}" in content_lower or handle.lower() in content_lower:
|
||||
mentioned.add(handle)
|
||||
else:
|
||||
missing.add(handle)
|
||||
|
||||
return mentioned, missing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit contributors across git history, co-author trailers, and salvaged PRs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--since-tag",
|
||||
required=True,
|
||||
help="Git tag to start from (e.g., v2026.4.8)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--until",
|
||||
default="HEAD",
|
||||
help="Git ref to end at (default: HEAD)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-file",
|
||||
default=None,
|
||||
help="Path to a release notes file to check for missing contributors",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Exit with code 1 if new unmapped emails are found (for CI)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff-base",
|
||||
default=None,
|
||||
help="Git ref to diff against (only flag emails from commits after this ref)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"=== Contributor Audit: {args.since_tag}..{args.until} ===")
|
||||
print()
|
||||
|
||||
# ---- 1. Git commit authors ----
|
||||
print("[1/3] Scanning git commit authors...")
|
||||
commit_contribs, commit_unknowns = collect_commit_authors(args.since_tag, args.until)
|
||||
print(f" Found {len(commit_contribs)} contributor(s) from commits.")
|
||||
|
||||
# ---- 2. Co-authored-by trailers ----
|
||||
print("[2/3] Scanning Co-authored-by trailers...")
|
||||
coauthor_contribs, coauthor_unknowns = collect_co_authors(args.since_tag, args.until)
|
||||
print(f" Found {len(coauthor_contribs)} contributor(s) from co-author trailers.")
|
||||
|
||||
# ---- 3. Salvaged PRs ----
|
||||
print("[3/3] Scanning salvaged/cherry-picked PR descriptions...")
|
||||
salvage_contribs, salvage_pr_refs = collect_salvaged_contributors(args.since_tag, args.until)
|
||||
print(f" Found {len(salvage_contribs)} contributor(s) from salvaged PRs.")
|
||||
|
||||
# ---- Merge all contributors ----
|
||||
all_contributors = defaultdict(set)
|
||||
for handle, sources in commit_contribs.items():
|
||||
all_contributors[handle].update(sources)
|
||||
for handle, sources in coauthor_contribs.items():
|
||||
all_contributors[handle].update(sources)
|
||||
for handle, sources in salvage_contribs.items():
|
||||
all_contributors[handle].update(sources)
|
||||
|
||||
# Merge unknown emails
|
||||
all_unknowns = {}
|
||||
all_unknowns.update(commit_unknowns)
|
||||
all_unknowns.update(coauthor_unknowns)
|
||||
|
||||
# Filter out AI assistants, bots, and machine accounts
|
||||
ignored = {h for h in all_contributors if is_ignored(h)}
|
||||
for h in ignored:
|
||||
del all_contributors[h]
|
||||
# Also filter unknowns by email
|
||||
all_unknowns = {e: n for e, n in all_unknowns.items() if not is_ignored(n, e)}
|
||||
|
||||
# ---- Output ----
|
||||
print()
|
||||
print(f"=== All Contributors ({len(all_contributors)}) ===")
|
||||
print()
|
||||
|
||||
# Sort by handle, case-insensitive
|
||||
for handle in sorted(all_contributors.keys(), key=str.lower):
|
||||
sources = sorted(all_contributors[handle])
|
||||
source_str = ", ".join(sources)
|
||||
extra = ""
|
||||
if handle in salvage_pr_refs:
|
||||
pr_nums = salvage_pr_refs[handle]
|
||||
extra = f" (PRs: {', '.join(f'#{n}' for n in pr_nums)})"
|
||||
print(f" @{handle} [{source_str}]{extra}")
|
||||
|
||||
# ---- Unknown emails ----
|
||||
if all_unknowns:
|
||||
print()
|
||||
print(f"=== Unknown Emails ({len(all_unknowns)}) ===")
|
||||
print("These emails are not in AUTHOR_MAP and should be added:")
|
||||
print()
|
||||
for email, name in sorted(all_unknowns.items()):
|
||||
print(f' "{email}": "{name}",')
|
||||
|
||||
# ---- Strict mode: fail CI if new unmapped emails are introduced ----
|
||||
if args.strict and all_unknowns:
|
||||
# In strict mode, check if ANY unknown emails come from commits in this
|
||||
# PR's diff range (new unmapped emails that weren't there before).
|
||||
# This is the CI gate: existing unknowns are grandfathered, but new
|
||||
# commits must have their author email in AUTHOR_MAP.
|
||||
new_unknowns = {}
|
||||
if args.diff_base:
|
||||
# Only flag emails from commits after diff_base
|
||||
new_commits_output = git(
|
||||
"log", f"{args.diff_base}..HEAD",
|
||||
"--format=%ae", "--no-merges",
|
||||
)
|
||||
new_emails = set(new_commits_output.splitlines()) if new_commits_output else set()
|
||||
for email, name in all_unknowns.items():
|
||||
if email in new_emails:
|
||||
new_unknowns[email] = name
|
||||
else:
|
||||
new_unknowns = all_unknowns
|
||||
|
||||
if new_unknowns:
|
||||
print()
|
||||
print(f"=== STRICT MODE FAILURE: {len(new_unknowns)} new unmapped email(s) ===")
|
||||
print("Add these to AUTHOR_MAP in scripts/release.py before merging:")
|
||||
print()
|
||||
for email, name in sorted(new_unknowns.items()):
|
||||
print(f' "{email}": "<github-username>",')
|
||||
print()
|
||||
print("To find the GitHub username:")
|
||||
print(" gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'")
|
||||
strict_failed = True
|
||||
else:
|
||||
strict_failed = False
|
||||
else:
|
||||
strict_failed = False
|
||||
|
||||
# ---- Release file comparison ----
|
||||
if args.release_file:
|
||||
print()
|
||||
print(f"=== Release File Check: {args.release_file} ===")
|
||||
print()
|
||||
mentioned, missing = check_release_file(args.release_file, all_contributors.keys())
|
||||
print(f" Mentioned in release notes: {len(mentioned)}")
|
||||
print(f" Missing from release notes: {len(missing)}")
|
||||
if missing:
|
||||
print()
|
||||
print(" Contributors NOT mentioned in the release file:")
|
||||
for handle in sorted(missing, key=str.lower):
|
||||
sources = sorted(all_contributors[handle])
|
||||
print(f" @{handle} [{', '.join(sources)}]")
|
||||
else:
|
||||
print()
|
||||
print(" All contributors are mentioned in the release file!")
|
||||
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
if strict_failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+396
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discord Voice Doctor — diagnostic tool for voice channel support.
|
||||
|
||||
Checks all dependencies, configuration, and bot permissions needed
|
||||
for Discord voice mode to work correctly.
|
||||
|
||||
Usage:
|
||||
python scripts/discord-voice-doctor.py
|
||||
.venv/bin/python scripts/discord-voice-doctor.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# Resolve project root
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
ENV_FILE = HERMES_HOME / ".env"
|
||||
|
||||
OK = "\033[92m\u2713\033[0m"
|
||||
FAIL = "\033[91m\u2717\033[0m"
|
||||
WARN = "\033[93m!\033[0m"
|
||||
|
||||
# Track whether discord.py is available for later sections
|
||||
_discord_available = False
|
||||
|
||||
|
||||
def mask(value):
|
||||
"""Mask sensitive value: show only first 4 chars."""
|
||||
if not value or len(value) < 8:
|
||||
return "****"
|
||||
return f"{value[:4]}{'*' * (len(value) - 4)}"
|
||||
|
||||
|
||||
def check(label, ok, detail=""):
|
||||
symbol = OK if ok else FAIL
|
||||
msg = f" {symbol} {label}"
|
||||
if detail:
|
||||
msg += f" ({detail})"
|
||||
print(msg)
|
||||
return ok
|
||||
|
||||
|
||||
def warn(label, detail=""):
|
||||
msg = f" {WARN} {label}"
|
||||
if detail:
|
||||
msg += f" ({detail})"
|
||||
print(msg)
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f"\n\033[1m{title}\033[0m")
|
||||
|
||||
|
||||
def check_packages():
|
||||
"""Check Python package dependencies. Returns True if all critical deps OK."""
|
||||
global _discord_available
|
||||
section("Python Packages")
|
||||
ok = True
|
||||
|
||||
# discord.py
|
||||
try:
|
||||
import discord
|
||||
_discord_available = True
|
||||
check("discord.py", True, f"v{discord.__version__}")
|
||||
except ImportError:
|
||||
check("discord.py", False, "pip install discord.py[voice]")
|
||||
ok = False
|
||||
|
||||
# PyNaCl
|
||||
try:
|
||||
import nacl
|
||||
ver = getattr(nacl, "__version__", "unknown")
|
||||
try:
|
||||
import nacl.secret
|
||||
nacl.secret.Aead(bytes(32))
|
||||
check("PyNaCl", True, f"v{ver}")
|
||||
except (AttributeError, Exception):
|
||||
check("PyNaCl (Aead)", False, f"v{ver} — need >=1.5.0")
|
||||
ok = False
|
||||
except ImportError:
|
||||
check("PyNaCl", False, "pip install PyNaCl>=1.5.0")
|
||||
ok = False
|
||||
|
||||
# davey (DAVE E2EE)
|
||||
try:
|
||||
import davey
|
||||
check("davey (DAVE E2EE)", True, f"v{getattr(davey, '__version__', '?')}")
|
||||
except ImportError:
|
||||
check("davey (DAVE E2EE)", False, "pip install davey")
|
||||
ok = False
|
||||
|
||||
# Optional: local STT
|
||||
try:
|
||||
import faster_whisper
|
||||
check("faster-whisper (local STT)", True)
|
||||
except ImportError:
|
||||
warn("faster-whisper (local STT)", "not installed — local STT unavailable")
|
||||
|
||||
# Optional: TTS providers
|
||||
try:
|
||||
import edge_tts
|
||||
check("edge-tts", True)
|
||||
except ImportError:
|
||||
warn("edge-tts", "not installed — edge TTS unavailable")
|
||||
|
||||
try:
|
||||
import elevenlabs
|
||||
check("elevenlabs SDK", True)
|
||||
except ImportError:
|
||||
warn("elevenlabs SDK", "not installed — premium TTS unavailable")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def check_system_tools():
|
||||
"""Check system-level tools (opus, ffmpeg). Returns True if all OK."""
|
||||
section("System Tools")
|
||||
ok = True
|
||||
|
||||
# Opus codec
|
||||
if _discord_available:
|
||||
try:
|
||||
import discord
|
||||
opus_loaded = discord.opus.is_loaded()
|
||||
if not opus_loaded:
|
||||
import ctypes.util
|
||||
opus_path = ctypes.util.find_library("opus")
|
||||
if not opus_path:
|
||||
# Platform-specific fallback paths
|
||||
candidates = [
|
||||
"/opt/homebrew/lib/libopus.dylib", # macOS Apple Silicon
|
||||
"/usr/local/lib/libopus.dylib", # macOS Intel
|
||||
"/usr/lib/x86_64-linux-gnu/libopus.so.0", # Debian/Ubuntu x86
|
||||
"/usr/lib/aarch64-linux-gnu/libopus.so.0", # Debian/Ubuntu ARM
|
||||
"/usr/lib/libopus.so", # Arch Linux
|
||||
"/usr/lib64/libopus.so", # RHEL/Fedora
|
||||
]
|
||||
for p in candidates:
|
||||
if os.path.isfile(p):
|
||||
opus_path = p
|
||||
break
|
||||
if opus_path:
|
||||
discord.opus.load_opus(opus_path)
|
||||
opus_loaded = discord.opus.is_loaded()
|
||||
if opus_loaded:
|
||||
check("Opus codec", True)
|
||||
else:
|
||||
check("Opus codec", False, "brew install opus / apt install libopus0")
|
||||
ok = False
|
||||
except Exception as e:
|
||||
check("Opus codec", False, str(e))
|
||||
ok = False
|
||||
else:
|
||||
warn("Opus codec", "skipped — discord.py not installed")
|
||||
|
||||
# ffmpeg
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_path:
|
||||
check("ffmpeg", True, ffmpeg_path)
|
||||
else:
|
||||
check("ffmpeg", False, "brew install ffmpeg / apt install ffmpeg")
|
||||
ok = False
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def check_env_vars():
|
||||
"""Check environment variables. Returns (ok, token, groq_key, eleven_key)."""
|
||||
section("Environment Variables")
|
||||
|
||||
# Load .env
|
||||
try:
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
load_hermes_dotenv(
|
||||
hermes_home=ENV_FILE.parent,
|
||||
project_env=PROJECT_ROOT / ".env",
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
ok = True
|
||||
|
||||
token = os.getenv("DISCORD_BOT_TOKEN", "")
|
||||
if token:
|
||||
check("DISCORD_BOT_TOKEN", True, mask(token))
|
||||
else:
|
||||
check("DISCORD_BOT_TOKEN", False, "not set")
|
||||
ok = False
|
||||
|
||||
# Allowed users — resolve usernames if possible
|
||||
allowed = os.getenv("DISCORD_ALLOWED_USERS", "")
|
||||
if allowed:
|
||||
users = [u.strip() for u in allowed.split(",") if u.strip()]
|
||||
user_labels = []
|
||||
for uid in users:
|
||||
label = mask(uid)
|
||||
if token and uid.isdigit():
|
||||
try:
|
||||
import requests
|
||||
r = requests.get(
|
||||
f"https://discord.com/api/v10/users/{uid}",
|
||||
headers={"Authorization": f"Bot {token}"},
|
||||
timeout=3,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
label = f"{r.json().get('username', '?')} ({mask(uid)})"
|
||||
except Exception:
|
||||
pass
|
||||
user_labels.append(label)
|
||||
check("DISCORD_ALLOWED_USERS", True, f"{len(users)} user(s): {', '.join(user_labels)}")
|
||||
else:
|
||||
warn("DISCORD_ALLOWED_USERS", "not set — all users can use voice")
|
||||
|
||||
groq_key = os.getenv("GROQ_API_KEY", "")
|
||||
eleven_key = os.getenv("ELEVENLABS_API_KEY", "")
|
||||
|
||||
if groq_key:
|
||||
check("GROQ_API_KEY (STT)", True, mask(groq_key))
|
||||
else:
|
||||
warn("GROQ_API_KEY", "not set — Groq STT unavailable")
|
||||
|
||||
if eleven_key:
|
||||
check("ELEVENLABS_API_KEY (TTS)", True, mask(eleven_key))
|
||||
else:
|
||||
warn("ELEVENLABS_API_KEY", "not set — ElevenLabs TTS unavailable")
|
||||
|
||||
return ok, token, groq_key, eleven_key
|
||||
|
||||
|
||||
def check_config(groq_key, eleven_key):
|
||||
"""Check hermes config.yaml."""
|
||||
section("Configuration")
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
|
||||
stt_provider = cfg.get("stt", {}).get("provider", "local")
|
||||
tts_provider = cfg.get("tts", {}).get("provider", "edge")
|
||||
check("STT provider", True, stt_provider)
|
||||
check("TTS provider", True, tts_provider)
|
||||
|
||||
if stt_provider == "groq" and not groq_key:
|
||||
warn("STT config says groq but GROQ_API_KEY is missing")
|
||||
if stt_provider == "mistral" and not os.getenv("MISTRAL_API_KEY"):
|
||||
warn("STT config says mistral but MISTRAL_API_KEY is missing")
|
||||
if tts_provider == "elevenlabs" and not eleven_key:
|
||||
warn("TTS config says elevenlabs but ELEVENLABS_API_KEY is missing")
|
||||
if tts_provider == "mistral" and not os.getenv("MISTRAL_API_KEY"):
|
||||
warn("TTS config says mistral but MISTRAL_API_KEY is missing")
|
||||
except Exception as e:
|
||||
warn("config.yaml", f"parse error: {e}")
|
||||
else:
|
||||
warn("config.yaml", "not found — using defaults")
|
||||
|
||||
# Voice mode state
|
||||
voice_mode_path = HERMES_HOME / "gateway_voice_mode.json"
|
||||
if voice_mode_path.exists():
|
||||
try:
|
||||
import json
|
||||
modes = json.loads(voice_mode_path.read_text(encoding="utf-8"))
|
||||
off_count = sum(1 for v in modes.values() if v == "off")
|
||||
all_count = sum(1 for v in modes.values() if v == "all")
|
||||
check("Voice mode state", True, f"{all_count} on, {off_count} off, {len(modes)} total")
|
||||
except Exception:
|
||||
warn("Voice mode state", "parse error")
|
||||
else:
|
||||
check("Voice mode state", True, "no saved state (fresh)")
|
||||
|
||||
|
||||
def check_bot_permissions(token):
|
||||
"""Check bot permissions via Discord API. Returns True if all OK."""
|
||||
section("Bot Permissions")
|
||||
|
||||
if not token:
|
||||
warn("Bot permissions", "no token — skipping")
|
||||
return True
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
warn("Bot permissions", "requests not installed — skipping")
|
||||
return True
|
||||
|
||||
VOICE_PERMS = {
|
||||
"Priority Speaker": 8,
|
||||
"Stream": 9,
|
||||
"View Channel": 10,
|
||||
"Send Messages": 11,
|
||||
"Embed Links": 14,
|
||||
"Attach Files": 15,
|
||||
"Read Message History": 16,
|
||||
"Connect": 20,
|
||||
"Speak": 21,
|
||||
"Mute Members": 22,
|
||||
"Deafen Members": 23,
|
||||
"Move Members": 24,
|
||||
"Use VAD": 25,
|
||||
"Send Voice Messages": 46,
|
||||
}
|
||||
REQUIRED_PERMS = {"Connect", "Speak", "View Channel", "Send Messages"}
|
||||
ok = True
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bot {token}"}
|
||||
r = requests.get("https://discord.com/api/v10/users/@me", headers=headers, timeout=5)
|
||||
|
||||
if r.status_code == 401:
|
||||
check("Bot login", False, "invalid token (401)")
|
||||
return False
|
||||
if r.status_code != 200:
|
||||
check("Bot login", False, f"HTTP {r.status_code}")
|
||||
return False
|
||||
|
||||
bot = r.json()
|
||||
bot_name = bot.get("username", "?")
|
||||
check("Bot login", True, f"{bot_name[:3]}{'*' * (len(bot_name) - 3)}")
|
||||
|
||||
# Check guilds
|
||||
r2 = requests.get("https://discord.com/api/v10/users/@me/guilds", headers=headers, timeout=5)
|
||||
if r2.status_code != 200:
|
||||
warn("Guilds", f"HTTP {r2.status_code}")
|
||||
return ok
|
||||
|
||||
guilds = r2.json()
|
||||
check("Guilds", True, f"{len(guilds)} guild(s)")
|
||||
|
||||
for g in guilds[:5]:
|
||||
perms = int(g.get("permissions", 0))
|
||||
is_admin = bool(perms & (1 << 3))
|
||||
|
||||
if is_admin:
|
||||
print(f" {OK} {g['name']}: Administrator (all permissions)")
|
||||
continue
|
||||
|
||||
has = []
|
||||
missing = []
|
||||
for name, bit in sorted(VOICE_PERMS.items(), key=lambda x: x[1]):
|
||||
if perms & (1 << bit):
|
||||
has.append(name)
|
||||
elif name in REQUIRED_PERMS:
|
||||
missing.append(name)
|
||||
|
||||
if missing:
|
||||
print(f" {FAIL} {g['name']}: missing {', '.join(missing)}")
|
||||
ok = False
|
||||
else:
|
||||
print(f" {OK} {g['name']}: {', '.join(has)}")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
warn("Bot permissions", "Discord API timeout")
|
||||
except requests.exceptions.ConnectionError:
|
||||
warn("Bot permissions", "cannot reach Discord API")
|
||||
except Exception as e:
|
||||
warn("Bot permissions", f"check failed: {e}")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("\033[1m" + "=" * 50 + "\033[0m")
|
||||
print("\033[1m Discord Voice Doctor\033[0m")
|
||||
print("\033[1m" + "=" * 50 + "\033[0m")
|
||||
|
||||
all_ok = True
|
||||
|
||||
all_ok &= check_packages()
|
||||
all_ok &= check_system_tools()
|
||||
env_ok, token, groq_key, eleven_key = check_env_vars()
|
||||
all_ok &= env_ok
|
||||
check_config(groq_key, eleven_key)
|
||||
all_ok &= check_bot_permissions(token)
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print("\033[1m" + "-" * 50 + "\033[0m")
|
||||
if all_ok:
|
||||
print(f" {OK} \033[92mAll checks passed — voice mode ready!\033[0m")
|
||||
else:
|
||||
print(f" {FAIL} \033[91mSome checks failed — fix issues above.\033[0m")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Docker boot-time config migrations safely."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from hermes_cli.config import (
|
||||
check_config_version,
|
||||
get_config_path,
|
||||
get_env_path,
|
||||
migrate_config,
|
||||
)
|
||||
from utils import env_var_enabled
|
||||
|
||||
|
||||
def _backup_path(path: Path, stamp: str) -> Path:
|
||||
base = path.with_name(f"{path.name}.bak-{stamp}")
|
||||
if not base.exists():
|
||||
return base
|
||||
for index in range(1, 1000):
|
||||
candidate = path.with_name(f"{path.name}.bak-{stamp}.{index}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise RuntimeError(f"could not choose a backup path for {path}")
|
||||
|
||||
|
||||
def _backup_existing(paths: Iterable[Path]) -> list[Path]:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
backups: list[Path] = []
|
||||
for path in paths:
|
||||
if not path.is_file():
|
||||
continue
|
||||
dest = _backup_path(path, stamp)
|
||||
shutil.copy2(path, dest)
|
||||
backups.append(dest)
|
||||
return backups
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if env_var_enabled("HERMES_SKIP_CONFIG_MIGRATION"):
|
||||
print("[config-migrate] HERMES_SKIP_CONFIG_MIGRATION is set; skipping config migration")
|
||||
return 0
|
||||
|
||||
current_ver, latest_ver = check_config_version()
|
||||
if current_ver >= latest_ver:
|
||||
return 0
|
||||
|
||||
backups = _backup_existing((get_config_path(), get_env_path()))
|
||||
backup_text = ", ".join(str(path) for path in backups) if backups else "none"
|
||||
print(
|
||||
f"[config-migrate] Migrating config schema {current_ver} -> {latest_ver}; "
|
||||
f"backups: {backup_text}"
|
||||
)
|
||||
migrate_config(interactive=False, quiet=False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(f"[config-migrate] ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Executable
+416
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hermes Gateway - Standalone messaging platform integration.
|
||||
|
||||
This is the proper entry point for running the gateway as a service.
|
||||
NOT tied to the CLI - runs independently.
|
||||
|
||||
Usage:
|
||||
# Run in foreground (for testing)
|
||||
./scripts/hermes-gateway
|
||||
|
||||
# Install as systemd service
|
||||
./scripts/hermes-gateway install
|
||||
|
||||
# Manage the service
|
||||
./scripts/hermes-gateway start
|
||||
./scripts/hermes-gateway stop
|
||||
./scripts/hermes-gateway restart
|
||||
./scripts/hermes-gateway status
|
||||
|
||||
# Uninstall
|
||||
./scripts/hermes-gateway uninstall
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
PROJECT_DIR = SCRIPT_DIR.parent
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
|
||||
# Load .env file
|
||||
from dotenv import load_dotenv
|
||||
env_path = PROJECT_DIR / '.env'
|
||||
if env_path.exists():
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Service Configuration
|
||||
# =============================================================================
|
||||
|
||||
SERVICE_NAME = "hermes-gateway"
|
||||
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
|
||||
|
||||
def get_systemd_unit_path() -> Path:
|
||||
"""Get the path for the systemd user service file."""
|
||||
return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
|
||||
|
||||
def get_launchd_plist_path() -> Path:
|
||||
"""Get the path for the launchd plist file (macOS)."""
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"ai.hermes.gateway.plist"
|
||||
|
||||
def get_python_path() -> str:
|
||||
"""Get the path to the Python interpreter."""
|
||||
# Prefer the venv if it exists
|
||||
venv_python = PROJECT_DIR / "venv" / "bin" / "python"
|
||||
if venv_python.exists():
|
||||
return str(venv_python)
|
||||
return sys.executable
|
||||
|
||||
def get_gateway_script_path() -> str:
|
||||
"""Get the path to this script."""
|
||||
return str(Path(__file__).resolve())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Systemd Service (Linux)
|
||||
# =============================================================================
|
||||
|
||||
def generate_systemd_unit() -> str:
|
||||
"""Generate the systemd unit file content."""
|
||||
python_path = get_python_path()
|
||||
script_path = get_gateway_script_path()
|
||||
working_dir = str(PROJECT_DIR)
|
||||
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network.target
|
||||
StartLimitIntervalSec=600
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} {script_path} run
|
||||
WorkingDirectory={working_dir}
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Environment (optional - can also use .env file)
|
||||
# Environment="TELEGRAM_BOT_TOKEN=your_token"
|
||||
# Environment="DISCORD_BOT_TOKEN=your_token"
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
|
||||
def install_systemd():
|
||||
"""Install the systemd user service."""
|
||||
unit_path = get_systemd_unit_path()
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Installing systemd service to: {unit_path}")
|
||||
unit_path.write_text(generate_systemd_unit())
|
||||
|
||||
# Reload systemd
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||
|
||||
# Enable the service (start on boot)
|
||||
subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True)
|
||||
|
||||
print(f"✓ Service installed and enabled")
|
||||
print(f"")
|
||||
print(f"To start the service:")
|
||||
print(f" systemctl --user start {SERVICE_NAME}")
|
||||
print(f"")
|
||||
print(f"To view logs:")
|
||||
print(f" journalctl --user -u {SERVICE_NAME} -f")
|
||||
print(f"")
|
||||
print(f"To enable lingering (keeps service running after logout):")
|
||||
print(f" sudo loginctl enable-linger $USER")
|
||||
|
||||
def uninstall_systemd():
|
||||
"""Uninstall the systemd user service."""
|
||||
unit_path = get_systemd_unit_path()
|
||||
|
||||
# Stop and disable first
|
||||
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False)
|
||||
subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False)
|
||||
|
||||
# Remove the unit file
|
||||
if unit_path.exists():
|
||||
unit_path.unlink()
|
||||
print(f"✓ Removed {unit_path}")
|
||||
|
||||
# Reload systemd
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||
print(f"✓ Service uninstalled")
|
||||
|
||||
def systemd_status():
|
||||
"""Show systemd service status."""
|
||||
subprocess.run(["systemctl", "--user", "status", SERVICE_NAME])
|
||||
|
||||
def systemd_start():
|
||||
"""Start the systemd service."""
|
||||
subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True)
|
||||
print(f"✓ Service started")
|
||||
|
||||
def systemd_stop():
|
||||
"""Stop the systemd service."""
|
||||
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True)
|
||||
print(f"✓ Service stopped")
|
||||
|
||||
def systemd_restart():
|
||||
"""Restart the systemd service."""
|
||||
subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True)
|
||||
print(f"✓ Service restarted")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Launchd Service (macOS)
|
||||
# =============================================================================
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
"""Generate the launchd plist file content."""
|
||||
python_path = get_python_path()
|
||||
script_path = get_gateway_script_path()
|
||||
working_dir = str(PROJECT_DIR)
|
||||
log_dir = Path.home() / ".hermes" / "logs"
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.hermes.gateway</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{python_path}</string>
|
||||
<string>{script_path}</string>
|
||||
<string>run</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{working_dir}</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>{log_dir}/gateway.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{log_dir}/gateway.error.log</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
def install_launchd():
|
||||
"""Install the launchd service (macOS)."""
|
||||
plist_path = get_launchd_plist_path()
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Ensure log directory exists
|
||||
log_dir = Path.home() / ".hermes" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
|
||||
# Load the service
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
|
||||
print(f"✓ Service installed and loaded")
|
||||
print(f"")
|
||||
print(f"To view logs:")
|
||||
print(f" tail -f ~/.hermes/logs/gateway.log")
|
||||
print(f"")
|
||||
print(f"To manage the service:")
|
||||
print(f" launchctl start ai.hermes.gateway")
|
||||
print(f" launchctl stop ai.hermes.gateway")
|
||||
|
||||
def uninstall_launchd():
|
||||
"""Uninstall the launchd service (macOS)."""
|
||||
plist_path = get_launchd_plist_path()
|
||||
|
||||
# Unload first
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
|
||||
|
||||
# Remove the plist file
|
||||
if plist_path.exists():
|
||||
plist_path.unlink()
|
||||
print(f"✓ Removed {plist_path}")
|
||||
|
||||
print(f"✓ Service uninstalled")
|
||||
|
||||
def launchd_status():
|
||||
"""Show launchd service status."""
|
||||
subprocess.run(["launchctl", "list", "ai.hermes.gateway"])
|
||||
|
||||
def launchd_start():
|
||||
"""Start the launchd service."""
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
print(f"✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
"""Stop the launchd service."""
|
||||
subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True)
|
||||
print(f"✓ Service stopped")
|
||||
|
||||
def launchd_restart():
|
||||
"""Restart the launchd service."""
|
||||
launchd_stop()
|
||||
launchd_start()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Platform Detection
|
||||
# =============================================================================
|
||||
|
||||
def is_linux() -> bool:
|
||||
return sys.platform.startswith('linux')
|
||||
|
||||
def is_macos() -> bool:
|
||||
return sys.platform == 'darwin'
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == 'win32'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Runner
|
||||
# =============================================================================
|
||||
|
||||
def run_gateway():
|
||||
"""Run the gateway in foreground."""
|
||||
from gateway.run import start_gateway
|
||||
print("Starting Hermes Gateway...")
|
||||
print("Press Ctrl+C to stop.")
|
||||
print()
|
||||
asyncio.run(start_gateway())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main CLI
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Hermes Gateway - Messaging Platform Integration",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run in foreground (for testing)
|
||||
./scripts/hermes-gateway run
|
||||
|
||||
# Install as system service
|
||||
./scripts/hermes-gateway install
|
||||
|
||||
# Manage the service
|
||||
./scripts/hermes-gateway start
|
||||
./scripts/hermes-gateway stop
|
||||
./scripts/hermes-gateway restart
|
||||
./scripts/hermes-gateway status
|
||||
|
||||
# Uninstall
|
||||
./scripts/hermes-gateway uninstall
|
||||
|
||||
Configuration:
|
||||
Set environment variables in .env file or system environment:
|
||||
- TELEGRAM_BOT_TOKEN
|
||||
- DISCORD_BOT_TOKEN
|
||||
- WHATSAPP_ENABLED
|
||||
|
||||
Or create ~/.hermes/gateway.json for advanced configuration.
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["run", "install", "uninstall", "start", "stop", "restart", "status"],
|
||||
nargs="?",
|
||||
default="run",
|
||||
help="Command to execute (default: run)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Verbose output"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Detect platform and dispatch command
|
||||
if args.command == "run":
|
||||
run_gateway()
|
||||
|
||||
elif args.command == "install":
|
||||
if is_linux():
|
||||
install_systemd()
|
||||
elif is_macos():
|
||||
install_launchd()
|
||||
else:
|
||||
print("Service installation not supported on this platform.")
|
||||
print("Please run manually: ./scripts/hermes-gateway run")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "uninstall":
|
||||
if is_linux():
|
||||
uninstall_systemd()
|
||||
elif is_macos():
|
||||
uninstall_launchd()
|
||||
else:
|
||||
print("Service uninstallation not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "start":
|
||||
if is_linux():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "stop":
|
||||
if is_linux():
|
||||
systemd_stop()
|
||||
elif is_macos():
|
||||
launchd_stop()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "restart":
|
||||
if is_linux():
|
||||
systemd_restart()
|
||||
elif is_macos():
|
||||
launchd_restart()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "status":
|
||||
if is_linux():
|
||||
systemd_status()
|
||||
elif is_macos():
|
||||
launchd_status()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
@echo off
|
||||
REM ============================================================================
|
||||
REM Hermes Agent Installer for Windows (CMD wrapper)
|
||||
REM ============================================================================
|
||||
REM This batch file launches the PowerShell installer for users running CMD.
|
||||
REM
|
||||
REM Usage:
|
||||
REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
|
||||
REM
|
||||
REM Or if you're already in PowerShell, use the direct command instead:
|
||||
REM iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
REM ============================================================================
|
||||
|
||||
echo.
|
||||
echo Hermes Agent Installer
|
||||
echo Launching PowerShell installer...
|
||||
echo.
|
||||
|
||||
powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
|
||||
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo.
|
||||
echo Installation failed. Please try running PowerShell directly:
|
||||
echo powershell -ExecutionPolicy ByPass -c "iex (irm https://hermes-agent.nousresearch.com/install.ps1)"
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
+3109
File diff suppressed because it is too large
Load Diff
Executable
+2744
File diff suppressed because it is too large
Load Diff
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install psutil on Termux/Android by patching upstream platform detection.
|
||||
|
||||
psutil's setup currently gates Linux sources behind
|
||||
``sys.platform.startswith('linux')``. On Termux, Python reports
|
||||
``sys.platform == 'android'``, so ``pip install psutil`` aborts with
|
||||
"platform android is not supported" — even though psutil compiles fine
|
||||
when the Linux source path is reused.
|
||||
|
||||
This script downloads the official psutil sdist, applies a one-line
|
||||
patch (``LINUX = sys.platform.startswith(("linux", "android"))``), and
|
||||
installs the patched tree with ``pip install --no-build-isolation``.
|
||||
|
||||
Usage:
|
||||
python scripts/install_psutil_android.py [--pip "/path/to/pip"] [--uv]
|
||||
|
||||
When neither flag is given, the script auto-detects ``uv`` on PATH and
|
||||
falls back to ``<sys.executable> -m pip``.
|
||||
|
||||
This is a stopgap. Remove once psutil upstream merges
|
||||
https://github.com/giampaolo/psutil/pull/2762 and ships a release.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Keep sibling imports working when invoked as
|
||||
# ``python scripts/install_psutil_android.py`` from the repo checkout.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from hermes_cli.psutil_android import (
|
||||
PSUTIL_URL,
|
||||
PsutilAndroidInstallError,
|
||||
prepare_patched_psutil_sdist,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]:
|
||||
if pip_arg:
|
||||
return pip_arg.split()
|
||||
if prefer_uv:
|
||||
uv = shutil.which("uv")
|
||||
if not uv:
|
||||
sys.exit("--uv requested but no uv on PATH")
|
||||
return [uv, "pip"]
|
||||
auto_uv = shutil.which("uv")
|
||||
if auto_uv:
|
||||
return [auto_uv, "pip"]
|
||||
return [sys.executable, "-m", "pip"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--pip",
|
||||
help="Explicit installer command (e.g. '/usr/bin/uv pip' or 'python -m pip')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--uv",
|
||||
action="store_true",
|
||||
help="Force using uv (errors out if uv is not on PATH)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
install_cmd_prefix = _resolve_install_cmd(args.pip, args.uv)
|
||||
|
||||
print(
|
||||
"→ Termux/Android: prebuilding psutil with Linux source path "
|
||||
"compatibility shim (see psutil#2762)..."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
archive = tmp_path / "psutil.tar.gz"
|
||||
urllib.request.urlretrieve(PSUTIL_URL, archive)
|
||||
try:
|
||||
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
|
||||
except PsutilAndroidInstallError as exc:
|
||||
sys.exit(str(exc))
|
||||
|
||||
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
|
||||
print(f" $ {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
return result.returncode
|
||||
|
||||
print("✓ psutil installed via Android compatibility shim")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnose how prompt_toolkit identifies keystrokes in the current terminal.
|
||||
|
||||
Useful when adding a keybinding to Hermes (or any prompt_toolkit app) and you
|
||||
need to know what the terminal actually delivers — particularly on Windows,
|
||||
where terminals can collapse, intercept, or silently remap key combinations.
|
||||
|
||||
Usage:
|
||||
# POSIX
|
||||
python scripts/keystroke_diagnostic.py
|
||||
|
||||
# Windows (PowerShell / git-bash / cmd)
|
||||
python scripts\\keystroke_diagnostic.py
|
||||
|
||||
Press the key combinations you care about. Each keystroke prints the
|
||||
prompt_toolkit `Keys.*` identifier and the raw escape bytes the terminal
|
||||
sent. The last 20 keystrokes stay on screen. Ctrl+Q or Ctrl+C to quit.
|
||||
|
||||
Common questions this answers:
|
||||
- Does my terminal distinguish Ctrl+Enter from plain Enter?
|
||||
(On Windows Terminal: yes, Ctrl+Enter → c-j, Enter → c-m.)
|
||||
- Does Alt+Enter reach the app, or does the terminal eat it?
|
||||
(Windows Terminal eats it for fullscreen; mintty may too.)
|
||||
- Does Shift+Enter register as a separate key?
|
||||
(Almost never — most terminals collapse it to Enter.)
|
||||
- What byte sequence does Home/End/PageUp/etc. produce?
|
||||
|
||||
Example output for Ctrl+Enter on Windows Terminal + PowerShell:
|
||||
key=<Keys.ControlJ: 'c-j'> data='\\n'
|
||||
|
||||
Then in Hermes, bind the newline behaviour to that key:
|
||||
@kb.add('c-j')
|
||||
def handle_ctrl_enter(event):
|
||||
event.current_buffer.insert_text('\\n')
|
||||
"""
|
||||
from prompt_toolkit import Application
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.layout import Layout
|
||||
from prompt_toolkit.layout.containers import Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
|
||||
|
||||
_HISTORY: list[str] = []
|
||||
|
||||
|
||||
def _header() -> list[str]:
|
||||
return [
|
||||
"Keystroke diagnostic — press keys to see how prompt_toolkit sees them.",
|
||||
"Try: Enter, Ctrl+Enter, Shift+Enter, Alt+Enter, Ctrl+J, Ctrl+M, arrows, Home/End.",
|
||||
"Ctrl+Q or Ctrl+C to quit. Last 20 keystrokes shown.",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def _render_text() -> str:
|
||||
return "\n".join(_header() + _HISTORY[-20:])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("<any>")
|
||||
def _on_any(event): # noqa: ANN001 — prompt_toolkit event type
|
||||
parts = []
|
||||
for kp in event.key_sequence:
|
||||
parts.append(f"key={kp.key!r} data={kp.data!r}")
|
||||
_HISTORY.append(" | ".join(parts))
|
||||
event.app.invalidate()
|
||||
|
||||
@kb.add("c-q")
|
||||
@kb.add("c-c")
|
||||
def _quit(event): # noqa: ANN001
|
||||
event.app.exit()
|
||||
|
||||
control = FormattedTextControl(text=_render_text)
|
||||
layout = Layout(Window(content=control))
|
||||
Application(layout=layout, key_bindings=kb, full_screen=False).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Kill all running Modal apps (sandboxes, deployments, etc.)
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/kill_modal.sh # Stop hermes-agent sandboxes
|
||||
# bash scripts/kill_modal.sh --all # Stop ALL Modal apps
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
echo "Fetching Modal app list..."
|
||||
APP_LIST=$(modal app list 2>/dev/null)
|
||||
|
||||
if [[ "${1:-}" == "--all" ]]; then
|
||||
echo "Stopping ALL Modal apps..."
|
||||
echo "$APP_LIST" | grep -oE 'ap-[A-Za-z0-9]+' | sort -u | while read app_id; do
|
||||
echo " Stopping $app_id"
|
||||
modal app stop "$app_id" 2>/dev/null || true
|
||||
done
|
||||
else
|
||||
echo "Stopping hermes-agent sandboxes..."
|
||||
APPS=$(echo "$APP_LIST" | grep 'hermes-agent' | grep -oE 'ap-[A-Za-z0-9]+' || true)
|
||||
if [[ -z "$APPS" ]]; then
|
||||
echo " No hermes-agent apps found."
|
||||
else
|
||||
echo "$APPS" | while read app_id; do
|
||||
echo " Stopping $app_id"
|
||||
modal app stop "$app_id" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Current hermes-agent status:"
|
||||
modal app list 2>/dev/null | grep -E 'State|hermes-agent' || echo " (none)"
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# scripts/lib/node-bootstrap.sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI
|
||||
# (React + Ink), browser tools, and the WhatsApp bridge.
|
||||
#
|
||||
# Strategy (first hit wins — respects the user's existing tooling):
|
||||
# 1. modern `node` already on PATH
|
||||
# 2. ~/.hermes/node/ from a prior Hermes-managed install
|
||||
# 3. fnm, proto, nvm (in that order) if the user already uses a version manager
|
||||
# 4. Termux `pkg`, macOS Homebrew
|
||||
# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits)
|
||||
#
|
||||
# Usage:
|
||||
# source scripts/lib/node-bootstrap.sh
|
||||
# ensure_node # returns 0 on success, non-zero on failure
|
||||
# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi
|
||||
#
|
||||
# Env inputs (set before sourcing to override defaults):
|
||||
# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH
|
||||
# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install
|
||||
# HERMES_HOME (default: $HOME/.hermes)
|
||||
# ============================================================================
|
||||
|
||||
HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}"
|
||||
HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}"
|
||||
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||
HERMES_NODE_AVAILABLE=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging — prefer the host script's log_* helpers when present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; }
|
||||
_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; }
|
||||
_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform + version helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
# Where to symlink node/npm/npx so they land on PATH.
|
||||
# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
|
||||
# Termux → $PREFIX/bin, otherwise ~/.local/bin.
|
||||
_nb_get_link_dir() {
|
||||
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
elif [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
|
||||
echo "/usr/local/bin"
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
fi
|
||||
}
|
||||
|
||||
_nb_node_major() {
|
||||
local v
|
||||
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0
|
||||
}
|
||||
|
||||
_nb_have_modern_node() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
[ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version-manager paths — respect what the user already uses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_try_fnm() {
|
||||
command -v fnm >/dev/null 2>&1 || return 1
|
||||
_nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
eval "$(fnm env 2>/dev/null)" || true
|
||||
fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via fnm"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_proto() {
|
||||
command -v proto >/dev/null 2>&1 || return 1
|
||||
_nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via proto"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_nvm() {
|
||||
local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh"
|
||||
[ -s "$nvm_sh" ] || return 1
|
||||
# shellcheck source=/dev/null
|
||||
\. "$nvm_sh" >/dev/null 2>&1 || return 1
|
||||
_nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..."
|
||||
nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) activated via nvm"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform package managers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_try_termux_pkg() {
|
||||
_nb_is_termux || return 1
|
||||
_nb_log "Installing Node.js via pkg..."
|
||||
pkg install -y nodejs >/dev/null 2>&1 || return 1
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed via pkg"
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_try_brew() {
|
||||
[ "$(uname -s)" = "Darwin" ] || return 1
|
||||
command -v brew >/dev/null 2>&1 || return 1
|
||||
_nb_log "Installing Node via Homebrew..."
|
||||
brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \
|
||||
|| brew install node >/dev/null 2>&1 \
|
||||
|| return 1
|
||||
brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed via Homebrew"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundled binary fallback — always works, no shell rc edits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_nb_install_bundled_node() {
|
||||
local arch node_arch os_name node_os
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) node_arch="x64" ;;
|
||||
aarch64|arm64) node_arch="arm64" ;;
|
||||
armv7l) node_arch="armv7l" ;;
|
||||
*)
|
||||
_nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
os_name=$(uname -s)
|
||||
case "$os_name" in
|
||||
Linux*) node_os="linux" ;;
|
||||
Darwin*) node_os="darwin" ;;
|
||||
*)
|
||||
_nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/"
|
||||
local tarball
|
||||
tarball=$(curl -fsSL "$index_url" \
|
||||
| grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
|
||||
| head -1)
|
||||
if [ -z "$tarball" ]; then
|
||||
tarball=$(curl -fsSL "$index_url" \
|
||||
| grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
|
||||
| head -1)
|
||||
fi
|
||||
if [ -z "$tarball" ]; then
|
||||
_nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp -d)
|
||||
_nb_log "Downloading $tarball..."
|
||||
curl -fsSL "${index_url}${tarball}" -o "$tmp/$tarball" || {
|
||||
_nb_warn "Download failed"; rm -rf "$tmp"; return 1
|
||||
}
|
||||
|
||||
_nb_log "Extracting to $HERMES_HOME/node/..."
|
||||
if [[ "$tarball" == *.tar.xz ]]; then
|
||||
tar xf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||
else
|
||||
tar xzf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; }
|
||||
fi
|
||||
|
||||
local extracted
|
||||
extracted=$(find "$tmp" -maxdepth 1 -type d -name 'node-v*' 2>/dev/null | head -1)
|
||||
if [ ! -d "$extracted" ]; then
|
||||
_nb_warn "Extraction produced no node-v* directory"
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
mkdir -p "$HERMES_HOME"
|
||||
rm -rf "$HERMES_HOME/node"
|
||||
mv "$extracted" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp"
|
||||
|
||||
local _link_dir
|
||||
_link_dir="$(_nb_get_link_dir)"
|
||||
mkdir -p "$_link_dir"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx"
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
_nb_have_modern_node || return 1
|
||||
_nb_ok "Node $(node --version) installed to $HERMES_HOME/node/"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ensure_node() {
|
||||
HERMES_NODE_AVAILABLE=false
|
||||
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) found"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ]; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
if _nb_have_modern_node; then
|
||||
_nb_ok "Node $(node --version) found (Hermes-managed)"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Version managers first — respect the user's existing setup.
|
||||
_nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
# Platform package managers.
|
||||
_nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
_nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
# Last resort: pinned nodejs.org tarball.
|
||||
_nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; }
|
||||
|
||||
_nb_warn "Node.js install failed — TUI and browser tools will be unavailable."
|
||||
_nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)"
|
||||
return 1
|
||||
}
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diff ruff + ty diagnostic reports between two git refs.
|
||||
|
||||
Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR
|
||||
comments. Compares issues by a stable key (file, rule, line) so line-only
|
||||
shifts from unrelated edits are treated as the same issue.
|
||||
|
||||
Usage:
|
||||
lint_diff.py \\
|
||||
--base-ruff base/ruff.json --head-ruff head/ruff.json \\
|
||||
--base-ty base/ty.json --head-ty head/ty.json \\
|
||||
[--base-ref origin/main] [--head-ref HEAD]
|
||||
|
||||
Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that
|
||||
case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the
|
||||
config yet, or a tool crashed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_json(path: Path | None) -> list[dict]:
|
||||
if path is None or not path.exists() or path.stat().st_size == 0:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return data
|
||||
|
||||
|
||||
def _normalize_ruff(entries: list[dict]) -> list[dict]:
|
||||
"""Ruff JSON: {code, filename, location.row, message}."""
|
||||
out: list[dict] = []
|
||||
for e in entries:
|
||||
code = e.get("code") or "unknown"
|
||||
# ruff emits absolute paths; relativize to repo root if possible
|
||||
filename = e.get("filename", "")
|
||||
try:
|
||||
filename = os.path.relpath(filename)
|
||||
except ValueError:
|
||||
pass
|
||||
line = (e.get("location") or {}).get("row", 0)
|
||||
out.append(
|
||||
{
|
||||
"tool": "ruff",
|
||||
"rule": code,
|
||||
"path": filename,
|
||||
"line": line,
|
||||
"message": e.get("message", ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_ty(entries: list[dict]) -> list[dict]:
|
||||
"""ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}."""
|
||||
out: list[dict] = []
|
||||
for e in entries:
|
||||
loc = e.get("location") or {}
|
||||
begin = (loc.get("positions") or {}).get("begin") or {}
|
||||
out.append(
|
||||
{
|
||||
"tool": "ty",
|
||||
"rule": e.get("check_name", "unknown"),
|
||||
"path": loc.get("path", ""),
|
||||
"line": begin.get("line", 0),
|
||||
"message": e.get("description", ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _key(d: dict) -> tuple[str, str, str]:
|
||||
"""Stable diagnostic identity across commits: (path, rule, message)."""
|
||||
# Intentionally omit line so unrelated edits above an issue don't flag it
|
||||
# as "new". Same file + same rule + same message = same issue.
|
||||
return (d["path"], d["rule"], d["message"])
|
||||
|
||||
|
||||
def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
base_map = {_key(d): d for d in base}
|
||||
head_map = {_key(d): d for d in head}
|
||||
base_keys = set(base_map)
|
||||
head_keys = set(head_map)
|
||||
new_keys = head_keys - base_keys
|
||||
fixed_keys = base_keys - head_keys
|
||||
unchanged_keys = base_keys & head_keys
|
||||
# Return head entries for new (current line numbers), base entries for fixed
|
||||
return (
|
||||
[head_map[k] for k in new_keys],
|
||||
[base_map[k] for k in fixed_keys],
|
||||
[head_map[k] for k in unchanged_keys],
|
||||
)
|
||||
|
||||
|
||||
def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]:
|
||||
return Counter(e["rule"] for e in entries).most_common()
|
||||
|
||||
|
||||
def _section(title: str, entries: list[dict], limit: int = 25) -> str:
|
||||
if not entries:
|
||||
return f"**{title}:** none\n"
|
||||
lines = [f"**{title} ({len(entries)}):**\n"]
|
||||
# Group by rule for readability
|
||||
counts = _rule_counts(entries)
|
||||
lines.append("| Rule | Count |")
|
||||
lines.append("| --- | ---: |")
|
||||
for rule, count in counts[:15]:
|
||||
lines.append(f"| `{rule}` | {count} |")
|
||||
if len(counts) > 15:
|
||||
lines.append(f"| _+{len(counts) - 15} more rules_ | |")
|
||||
lines.append("")
|
||||
lines.append("<details><summary>First entries</summary>\n")
|
||||
lines.append("```")
|
||||
for e in entries[:limit]:
|
||||
lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}")
|
||||
if len(entries) > limit:
|
||||
lines.append(f"... and {len(entries) - limit} more")
|
||||
lines.append("```")
|
||||
lines.append("</details>\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _tool_report(
|
||||
tool_name: str,
|
||||
base: list[dict],
|
||||
head: list[dict],
|
||||
base_available: bool,
|
||||
) -> str:
|
||||
new, fixed, unchanged = _diff(base, head)
|
||||
delta = len(head) - len(base)
|
||||
delta_str = f"+{delta}" if delta > 0 else str(delta)
|
||||
emoji = "🆕" if delta > 0 else ("✅" if delta < 0 else "➖")
|
||||
|
||||
lines = [f"## {tool_name}\n"]
|
||||
if not base_available:
|
||||
lines.append(
|
||||
"_Base report unavailable (likely main has no config for this tool yet); "
|
||||
"treating all head diagnostics as new._\n"
|
||||
)
|
||||
lines.append(
|
||||
f"**Total:** {len(head)} on HEAD, {len(base)} on base "
|
||||
f"({emoji} {delta_str})\n"
|
||||
)
|
||||
lines.append(_section("🆕 New issues", new))
|
||||
lines.append(_section("✅ Fixed issues", fixed))
|
||||
lines.append(
|
||||
f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base-ruff", type=Path, required=True)
|
||||
ap.add_argument("--head-ruff", type=Path, required=True)
|
||||
ap.add_argument("--base-ty", type=Path, required=True)
|
||||
ap.add_argument("--head-ty", type=Path, required=True)
|
||||
ap.add_argument("--base-ref", default="base")
|
||||
ap.add_argument("--head-ref", default="HEAD")
|
||||
ap.add_argument(
|
||||
"--output", type=Path, help="Write summary to this file instead of stdout"
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
base_ruff_raw = _load_json(args.base_ruff)
|
||||
head_ruff_raw = _load_json(args.head_ruff)
|
||||
base_ty_raw = _load_json(args.base_ty)
|
||||
head_ty_raw = _load_json(args.head_ty)
|
||||
|
||||
base_ruff = _normalize_ruff(base_ruff_raw)
|
||||
head_ruff = _normalize_ruff(head_ruff_raw)
|
||||
base_ty = _normalize_ty(base_ty_raw)
|
||||
head_ty = _normalize_ty(head_ty_raw)
|
||||
|
||||
base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0
|
||||
base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0
|
||||
|
||||
buf: list[str] = []
|
||||
buf.append(f"# 🔎 Lint report: `{args.head_ref}` vs `{args.base_ref}`\n")
|
||||
buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail))
|
||||
buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail))
|
||||
buf.append(
|
||||
"_Diagnostics are surfaced as warnings — this check never fails the build._\n"
|
||||
)
|
||||
|
||||
summary = "\n".join(buf)
|
||||
if args.output:
|
||||
args.output.write_text(summary)
|
||||
else:
|
||||
print(summary)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+625
@@ -0,0 +1,625 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive the Hermes TUI under HERMES_DEV_PERF and summarize the pipeline.
|
||||
|
||||
Usage:
|
||||
scripts/profile-tui.py [--session SID] [--hold KEY] [--seconds N] [--rate HZ]
|
||||
|
||||
Defaults: picks the session with the most messages, holds PageUp for 8s at
|
||||
~30 Hz (matching xterm key-repeat), summarizes ~/.hermes/perf.log on exit.
|
||||
|
||||
The --tui build must exist (run `npm run build` in ui-tui first). This script
|
||||
launches `node dist/entry.js` directly with HERMES_TUI_RESUME set so it
|
||||
bypasses the hermes_cli wrapper — we want repeatable timing, not the CLI's
|
||||
session-picker flow.
|
||||
|
||||
Environment overrides:
|
||||
HERMES_PERF_LOG (default ~/.hermes/perf.log)
|
||||
HERMES_PERF_NODE (default node from $PATH)
|
||||
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
|
||||
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
except ImportError:
|
||||
def get_hermes_home() -> Path: # type: ignore[misc]
|
||||
val = (os.environ.get("HERMES_HOME") or "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
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(get_hermes_home() / "perf.log")))
|
||||
DEFAULT_STATE_DB = get_hermes_home() / "state.db"
|
||||
|
||||
# Keystroke escape sequences. Matches what xterm/VT220 send when the
|
||||
# terminal has bracketed-paste disabled and the key-repeat handler fires.
|
||||
KEYS = {
|
||||
"page_up": b"\x1b[5~",
|
||||
"page_down": b"\x1b[6~",
|
||||
"wheel_up": b"\x1b[M`!!", # mouse wheel up (SGR-less) — best-effort
|
||||
"shift_up": b"\x1b[1;2A",
|
||||
"shift_down": b"\x1b[1;2B",
|
||||
}
|
||||
|
||||
|
||||
def pick_longest_session(db: Path) -> str:
|
||||
conn = sqlite3.connect(db)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM sessions s ORDER BY "
|
||||
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
sys.exit(f"no sessions in {db}")
|
||||
return row[0]
|
||||
|
||||
|
||||
def drain(fd: int, timeout: float) -> bytes:
|
||||
"""Read whatever's available from fd within `timeout`, then return."""
|
||||
chunks = []
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic()))
|
||||
if not r:
|
||||
break
|
||||
try:
|
||||
data = os.read(fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def hold_key(fd: int, seq: bytes, seconds: float, rate_hz: int) -> int:
|
||||
"""Write `seq` to fd at ~rate_hz for `seconds`. Returns keystrokes sent."""
|
||||
interval = 1.0 / max(1, rate_hz)
|
||||
end = time.monotonic() + seconds
|
||||
sent = 0
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
os.write(fd, seq)
|
||||
sent += 1
|
||||
except OSError:
|
||||
break
|
||||
# Drain stdout to keep the PTY buffer flowing; ignore content.
|
||||
drain(fd, 0)
|
||||
time.sleep(interval)
|
||||
return sent
|
||||
|
||||
|
||||
def summarize(log: Path, since_ts_ms: int) -> dict[str, Any]:
|
||||
"""Parse perf.log, keep only events newer than since_ts_ms, return stats."""
|
||||
react_events: list[dict[str, Any]] = []
|
||||
frame_events: list[dict[str, Any]] = []
|
||||
if not log.exists():
|
||||
return {"error": f"no log at {log}", "react": [], "frame": []}
|
||||
for line in log.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if int(row.get("ts", 0)) < since_ts_ms:
|
||||
continue
|
||||
src = row.get("src")
|
||||
if src == "react":
|
||||
react_events.append(row)
|
||||
elif src == "frame":
|
||||
frame_events.append(row)
|
||||
|
||||
return {
|
||||
"react": react_events,
|
||||
"frame": frame_events,
|
||||
}
|
||||
|
||||
|
||||
def pct(values: list[float], p: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
s = sorted(values)
|
||||
idx = min(len(s) - 1, int(len(s) * p))
|
||||
return s[idx]
|
||||
|
||||
|
||||
def format_report(data: dict[str, Any]) -> str:
|
||||
react = data.get("react") or []
|
||||
frames = data.get("frame") or []
|
||||
out = []
|
||||
|
||||
out.append("═══ React Profiler ═══")
|
||||
if not react:
|
||||
out.append(" (no react events — HERMES_DEV_PERF wired? threshold too high?)")
|
||||
else:
|
||||
by_id: dict[str, list[float]] = {}
|
||||
for r in react:
|
||||
by_id.setdefault(r["id"], []).append(r["actualMs"])
|
||||
out.append(f" {'pane':<14} {'count':>6} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8}")
|
||||
for pid, ms in sorted(by_id.items(), key=lambda kv: -pct(kv[1], 0.99)):
|
||||
out.append(
|
||||
f" {pid:<14} {len(ms):>6} {pct(ms,0.50):>8.2f} {pct(ms,0.95):>8.2f} "
|
||||
f"{pct(ms,0.99):>8.2f} {max(ms):>8.2f}"
|
||||
)
|
||||
|
||||
out.append("")
|
||||
out.append("═══ Ink pipeline ═══")
|
||||
if not frames:
|
||||
out.append(" (no frame events — onFrame wiring broken?)")
|
||||
else:
|
||||
dur = [f["durationMs"] for f in frames]
|
||||
phases_present = any(f.get("phases") for f in frames)
|
||||
out.append(f" frames captured: {len(frames)}")
|
||||
out.append(
|
||||
f" durationMs p50={pct(dur,0.50):.2f} p95={pct(dur,0.95):.2f} "
|
||||
f"p99={pct(dur,0.99):.2f} max={max(dur):.2f}"
|
||||
)
|
||||
# Effective FPS during the run: frames / elapsed seconds.
|
||||
ts = sorted(f["ts"] for f in frames)
|
||||
if len(ts) >= 2:
|
||||
elapsed_s = (ts[-1] - ts[0]) / 1000.0
|
||||
fps = len(frames) / elapsed_s if elapsed_s > 0 else float("inf")
|
||||
out.append(f" throughput: {len(frames)} frames / {elapsed_s:.2f}s = {fps:.1f} fps")
|
||||
|
||||
if phases_present:
|
||||
fields = ["yoga", "renderer", "diff", "optimize", "write", "commit"]
|
||||
out.append("")
|
||||
out.append(f" {'phase':<10} {'p50':>8} {'p95':>8} {'p99':>8} {'max':>8} (ms)")
|
||||
for field in fields:
|
||||
vals = [f["phases"][field] for f in frames if f.get("phases")]
|
||||
if vals:
|
||||
out.append(
|
||||
f" {field:<10} {pct(vals,0.50):>8.2f} {pct(vals,0.95):>8.2f} "
|
||||
f"{pct(vals,0.99):>8.2f} {max(vals):>8.2f}"
|
||||
)
|
||||
# Derived: sum of phases vs durationMs (reveals hidden time).
|
||||
sum_ps = [
|
||||
sum(f["phases"][k] for k in fields)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if sum_ps:
|
||||
dur_match = [f["durationMs"] for f in frames if f.get("phases")]
|
||||
deltas = [d - s for d, s in zip(dur_match, sum_ps)]
|
||||
out.append(
|
||||
f" {'dur-Σphases':<10} {pct(deltas,0.50):>8.2f} {pct(deltas,0.95):>8.2f} "
|
||||
f"{pct(deltas,0.99):>8.2f} {max(deltas):>8.2f} (unaccounted-for time)"
|
||||
)
|
||||
|
||||
# Yoga counters
|
||||
visited = [f["phases"]["yogaVisited"] for f in frames if f.get("phases")]
|
||||
measured = [f["phases"]["yogaMeasured"] for f in frames if f.get("phases")]
|
||||
cache_hits = [f["phases"]["yogaCacheHits"] for f in frames if f.get("phases")]
|
||||
live = [f["phases"]["yogaLive"] for f in frames if f.get("phases")]
|
||||
out.append("")
|
||||
out.append(" Yoga counters (per frame):")
|
||||
for name, vals in (
|
||||
("visited", visited),
|
||||
("measured", measured),
|
||||
("cacheHits", cache_hits),
|
||||
("live", live),
|
||||
):
|
||||
if vals:
|
||||
out.append(f" {name:<11} p50={pct(vals,0.5):.0f} p99={pct(vals,0.99):.0f} max={max(vals)}")
|
||||
|
||||
# Patch counts — proxy for "how much changed each frame"
|
||||
patches = [f["phases"]["patches"] for f in frames if f.get("phases")]
|
||||
if patches:
|
||||
out.append(
|
||||
f" patches p50={pct(patches,0.5):.0f} p99={pct(patches,0.99):.0f} "
|
||||
f"max={max(patches)} total={sum(patches)}"
|
||||
)
|
||||
optimized = [
|
||||
f["phases"].get("optimizedPatches", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if any(optimized):
|
||||
out.append(
|
||||
f" optimized p50={pct(optimized,0.5):.0f} p99={pct(optimized,0.99):.0f} "
|
||||
f"max={max(optimized)} total={sum(optimized)}"
|
||||
f" (ratio: {sum(optimized)/max(1,sum(patches)):.2f})"
|
||||
)
|
||||
|
||||
# Write bytes + drain telemetry — the outer-terminal bottleneck gauge.
|
||||
bytes_written = [
|
||||
f["phases"].get("writeBytes", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if any(bytes_written):
|
||||
total_b = sum(bytes_written)
|
||||
kb = total_b / 1024
|
||||
out.append(
|
||||
f" writeBytes p50={pct(bytes_written,0.5):.0f}B p99={pct(bytes_written,0.99):.0f}B "
|
||||
f"max={max(bytes_written)}B total={kb:.1f}KB"
|
||||
)
|
||||
drains = [
|
||||
f["phases"].get("prevFrameDrainMs", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
if any(d > 0 for d in drains):
|
||||
nonzero = [d for d in drains if d > 0]
|
||||
out.append(
|
||||
f" drainMs p50={pct(nonzero,0.5):.2f} p95={pct(nonzero,0.95):.2f} "
|
||||
f"p99={pct(nonzero,0.99):.2f} max={max(nonzero):.2f} (terminal flush latency)"
|
||||
)
|
||||
backpressure = sum(1 for f in frames if f.get("phases", {}).get("backpressure"))
|
||||
if backpressure:
|
||||
out.append(
|
||||
f" backpressure: {backpressure}/{len(frames)} frames "
|
||||
f"({100*backpressure/len(frames):.0f}%) (Node stdout buffer full — terminal slow)"
|
||||
)
|
||||
|
||||
# Flickers
|
||||
flicker_frames = [f for f in frames if f.get("flickers")]
|
||||
if flicker_frames:
|
||||
out.append("")
|
||||
out.append(f" ⚠ flickers detected in {len(flicker_frames)} frames")
|
||||
reasons: dict[str, int] = {}
|
||||
for f in flicker_frames:
|
||||
for fl in f["flickers"]:
|
||||
reasons[fl["reason"]] = reasons.get(fl["reason"], 0) + 1
|
||||
for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1]):
|
||||
out.append(f" {reason}: {n}")
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def key_metrics(data: dict[str, Any]) -> dict[str, float]:
|
||||
"""Flatten the report into a dict of scalar metrics for A/B diffing."""
|
||||
metrics: dict[str, float] = {}
|
||||
frames = data.get("frame") or []
|
||||
react = data.get("react") or []
|
||||
|
||||
if frames:
|
||||
durs = [f["durationMs"] for f in frames]
|
||||
metrics["frames"] = len(frames)
|
||||
metrics["dur_p50"] = pct(durs, 0.50)
|
||||
metrics["dur_p95"] = pct(durs, 0.95)
|
||||
metrics["dur_p99"] = pct(durs, 0.99)
|
||||
metrics["dur_max"] = max(durs)
|
||||
|
||||
ts = sorted(f["ts"] for f in frames)
|
||||
if len(ts) >= 2:
|
||||
elapsed = (ts[-1] - ts[0]) / 1000.0
|
||||
metrics["fps_throughput"] = len(frames) / elapsed if elapsed > 0 else 0.0
|
||||
# Interframe gaps distribution — complementary view to throughput:
|
||||
gaps = [ts[i] - ts[i - 1] for i in range(1, len(ts))]
|
||||
if gaps:
|
||||
metrics["gap_p50_ms"] = pct(gaps, 0.50)
|
||||
metrics["gap_p99_ms"] = pct(gaps, 0.99)
|
||||
metrics["gaps_under_16ms"] = sum(1 for g in gaps if g < 16)
|
||||
metrics["gaps_over_200ms"] = sum(1 for g in gaps if g >= 200)
|
||||
|
||||
for phase in ("renderer", "yoga", "diff", "write"):
|
||||
vals = [f["phases"][phase] for f in frames if f.get("phases")]
|
||||
if vals:
|
||||
metrics[f"{phase}_p99"] = pct(vals, 0.99)
|
||||
metrics[f"{phase}_max"] = max(vals)
|
||||
|
||||
patches = [f["phases"]["patches"] for f in frames if f.get("phases")]
|
||||
if patches:
|
||||
metrics["patches_total"] = sum(patches)
|
||||
metrics["patches_p99"] = pct(patches, 0.99)
|
||||
|
||||
optimized = [
|
||||
f["phases"].get("optimizedPatches", 0) for f in frames if f.get("phases")
|
||||
]
|
||||
if any(optimized):
|
||||
metrics["optimized_total"] = sum(optimized)
|
||||
|
||||
bytes_list = [
|
||||
f["phases"].get("writeBytes", 0) for f in frames if f.get("phases")
|
||||
]
|
||||
if any(bytes_list):
|
||||
metrics["writeBytes_total"] = sum(bytes_list)
|
||||
|
||||
drains = [
|
||||
f["phases"].get("prevFrameDrainMs", 0)
|
||||
for f in frames if f.get("phases")
|
||||
]
|
||||
drain_nonzero = [d for d in drains if d > 0]
|
||||
if drain_nonzero:
|
||||
metrics["drain_p99"] = pct(drain_nonzero, 0.99)
|
||||
metrics["drain_max"] = max(drain_nonzero)
|
||||
|
||||
bp = sum(1 for f in frames if f.get("phases", {}).get("backpressure"))
|
||||
metrics["backpressure_frames"] = bp
|
||||
|
||||
if react:
|
||||
for pid in {e["id"] for e in react}:
|
||||
ms = [e["actualMs"] for e in react if e["id"] == pid]
|
||||
metrics[f"react_{pid}_p99"] = pct(ms, 0.99)
|
||||
metrics[f"react_{pid}_max"] = max(ms)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def format_diff(before: dict[str, float], after: dict[str, float]) -> str:
|
||||
"""Render a side-by-side A/B comparison table."""
|
||||
keys = sorted(set(before) | set(after))
|
||||
lines = [f"{'metric':<28} {'before':>12} {'after':>12} {'delta':>12} {'%':>6}"]
|
||||
lines.append("─" * 76)
|
||||
for k in keys:
|
||||
b = before.get(k, 0.0)
|
||||
a = after.get(k, 0.0)
|
||||
d = a - b
|
||||
pct_change = ((a / b) - 1) * 100 if b not in {0, 0.0} else float("inf") if a else 0
|
||||
|
||||
# Flag improvements vs regressions. For _p99 / _max / _total / gaps_over /
|
||||
# patches / writeBytes / backpressure, LOWER is better. For fps / gaps_under,
|
||||
# HIGHER is better.
|
||||
lower_is_better = any(
|
||||
token in k
|
||||
for token in (
|
||||
"p50",
|
||||
"p95",
|
||||
"p99",
|
||||
"_max",
|
||||
"_total",
|
||||
"gaps_over",
|
||||
"backpressure",
|
||||
"drain",
|
||||
)
|
||||
)
|
||||
higher_is_better = "fps_" in k or "gaps_under" in k
|
||||
mark = ""
|
||||
if d and not (lower_is_better or higher_is_better):
|
||||
mark = ""
|
||||
elif d < 0 and lower_is_better:
|
||||
mark = "↓"
|
||||
elif d > 0 and higher_is_better:
|
||||
mark = "↑"
|
||||
elif d > 0 and lower_is_better:
|
||||
mark = "↑" # regression
|
||||
elif d < 0 and higher_is_better:
|
||||
mark = "↓" # regression
|
||||
|
||||
pct_str = "—" if pct_change == float("inf") else f"{pct_change:+6.1f}%"
|
||||
lines.append(
|
||||
f"{k:<28} {b:>12.2f} {a:>12.2f} {d:>+12.2f} {pct_str} {mark}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_once(args: argparse.Namespace) -> dict[str, Any]:
|
||||
tui_dir = Path(args.tui_dir).resolve()
|
||||
entry = tui_dir / "dist" / "entry.js"
|
||||
if not entry.exists():
|
||||
sys.exit(f"{entry} missing — run `npm run build` in {tui_dir} first")
|
||||
|
||||
sid = args.session or pick_longest_session(DEFAULT_STATE_DB)
|
||||
print(f"• session: {sid}")
|
||||
print(f"• hold: {args.hold} x {args.rate}Hz for {args.seconds}s after {args.warmup}s warmup")
|
||||
print(f"• terminal: {args.cols}x{args.rows}")
|
||||
|
||||
log = Path(args.log)
|
||||
if not args.keep_log and log.exists():
|
||||
log.unlink()
|
||||
|
||||
since_ms = int(time.time() * 1000)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HERMES_DEV_PERF"] = "1"
|
||||
env["HERMES_DEV_PERF_MS"] = str(args.threshold_ms)
|
||||
env["HERMES_DEV_PERF_LOG"] = str(log)
|
||||
env["HERMES_TUI_RESUME"] = sid
|
||||
env["COLUMNS"] = str(args.cols)
|
||||
env["LINES"] = str(args.rows)
|
||||
env["TERM"] = env.get("TERM", "xterm-256color")
|
||||
|
||||
# Pass through extra flags the TUI wrapper recognizes (e.g. --no-fullscreen).
|
||||
# Stored on args as `extra_flags` list.
|
||||
node = os.environ.get("HERMES_PERF_NODE", "node")
|
||||
node_args = [node, str(entry), *getattr(args, "extra_flags", [])]
|
||||
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvpe(node, node_args, env)
|
||||
|
||||
try:
|
||||
import fcntl, struct, termios
|
||||
winsize = struct.pack("HHHH", args.rows, args.cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
print(f"• pid: {pid} fd: {fd}")
|
||||
print(f"• warmup {args.warmup}s (drain startup output)…")
|
||||
drain(fd, args.warmup)
|
||||
|
||||
print(f"• holding {args.hold}…")
|
||||
sent = hold_key(fd, KEYS[args.hold], args.seconds, args.rate)
|
||||
print(f" sent {sent} keystrokes")
|
||||
|
||||
drain(fd, 0.5)
|
||||
finally:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _ in range(10):
|
||||
pid_done, _ = os.waitpid(pid, os.WNOHANG)
|
||||
if pid_done == pid:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only script (imports pty at top)
|
||||
os.waitpid(pid, 0)
|
||||
except (ProcessLookupError, ChildProcessError):
|
||||
pass
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
time.sleep(0.2)
|
||||
return summarize(log, since_ms)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--session", help="session id to resume (default: longest in db)")
|
||||
p.add_argument("--hold", default="page_up", choices=sorted(KEYS.keys()), help="key to hold")
|
||||
p.add_argument("--seconds", type=float, default=8.0, help="how long to hold the key")
|
||||
p.add_argument("--rate", type=int, default=30, help="keystrokes per second")
|
||||
p.add_argument("--warmup", type=float, default=3.0, help="seconds to wait after launch before input")
|
||||
p.add_argument("--threshold-ms", type=float, default=0.0, help="HERMES_DEV_PERF_MS (0 = capture all)")
|
||||
p.add_argument("--cols", type=int, default=120)
|
||||
p.add_argument("--rows", type=int, default=40)
|
||||
p.add_argument("--keep-log", action="store_true", help="don't wipe perf.log before run")
|
||||
p.add_argument("--tui-dir", default=str(DEFAULT_TUI_DIR))
|
||||
p.add_argument("--log", default=str(DEFAULT_LOG))
|
||||
p.add_argument("--save", metavar="LABEL",
|
||||
help="save the final metrics as /tmp/perf-<LABEL>.json for later --compare")
|
||||
p.add_argument("--compare", metavar="LABEL",
|
||||
help="diff against /tmp/perf-<LABEL>.json after running")
|
||||
p.add_argument("--loop", action="store_true",
|
||||
help="watch for source changes, rebuild, rerun, and diff vs previous run")
|
||||
p.add_argument("--extra-flag", dest="extra_flags", action="append", default=[],
|
||||
help="pass through to node dist/entry.js (repeatable)")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.loop:
|
||||
return loop_mode(args)
|
||||
|
||||
# Single-shot path.
|
||||
data = run_once(args)
|
||||
print()
|
||||
print(format_report(data))
|
||||
|
||||
metrics = key_metrics(data)
|
||||
|
||||
if args.save:
|
||||
path = Path(f"/tmp/perf-{args.save}.json")
|
||||
path.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
|
||||
print(f"\n• saved: {path}")
|
||||
|
||||
if args.compare:
|
||||
path = Path(f"/tmp/perf-{args.compare}.json")
|
||||
if not path.exists():
|
||||
print(f"\n⚠ no baseline at {path} — run with --save {args.compare} first")
|
||||
else:
|
||||
before = json.loads(path.read_text())
|
||||
print(f"\n═══ A/B diff vs /tmp/perf-{args.compare}.json ═══")
|
||||
print(format_diff(before, metrics))
|
||||
|
||||
if not data["react"] and not data["frame"]:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def loop_mode(args: argparse.Namespace) -> int:
|
||||
"""Watch source files, rebuild, rerun, print A/B diff against previous run.
|
||||
|
||||
Keeps a rolling 'previous run' baseline in memory so each iteration
|
||||
reports delta vs the last one — visibility into whether the last
|
||||
edit moved the needle. Press Ctrl+C to stop.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
tui_dir = Path(args.tui_dir).resolve()
|
||||
src_root = tui_dir / "src"
|
||||
pkg_root = tui_dir / "packages" / "hermes-ink" / "src"
|
||||
|
||||
def collect_mtimes() -> dict[str, float]:
|
||||
mtimes: dict[str, float] = {}
|
||||
for root in (src_root, pkg_root):
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if path.suffix in {".ts", ".tsx"} and "__tests__" not in str(path):
|
||||
try:
|
||||
mtimes[str(path)] = path.stat().st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
return mtimes
|
||||
|
||||
previous_metrics: dict[str, float] | None = None
|
||||
previous_mtimes = collect_mtimes()
|
||||
iteration = 0
|
||||
|
||||
print(f"• loop mode — watching {src_root} + {pkg_root} for *.ts(x) changes")
|
||||
print("• edit any TS file, the harness rebuilds + reruns automatically")
|
||||
print("• Ctrl+C to stop\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
iteration += 1
|
||||
print(f"\n{'═' * 76}")
|
||||
print(f"Iteration {iteration} @ {time.strftime('%H:%M:%S')}")
|
||||
print("═" * 76)
|
||||
|
||||
if iteration > 1:
|
||||
print("• rebuilding…")
|
||||
result = subprocess.run(
|
||||
["npm", "run", "build"],
|
||||
cwd=tui_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("✗ build failed:")
|
||||
print(result.stdout[-2000:])
|
||||
print(result.stderr[-2000:])
|
||||
print("\n• waiting for source changes to retry…")
|
||||
previous_mtimes = wait_for_change(previous_mtimes, collect_mtimes)
|
||||
continue
|
||||
print("✓ build ok")
|
||||
|
||||
data = run_once(args)
|
||||
metrics = key_metrics(data)
|
||||
|
||||
print()
|
||||
print(format_report(data))
|
||||
|
||||
if previous_metrics is not None:
|
||||
print(f"\n═══ A/B diff vs iteration {iteration - 1} ═══")
|
||||
print(format_diff(previous_metrics, metrics))
|
||||
|
||||
previous_metrics = metrics
|
||||
|
||||
print("\n• waiting for source changes…")
|
||||
previous_mtimes = wait_for_change(previous_mtimes, collect_mtimes)
|
||||
except KeyboardInterrupt:
|
||||
print("\n• loop stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def wait_for_change(prev: dict[str, float], collect) -> dict[str, float]:
|
||||
"""Poll every 1s until a watched file's mtime changes. Debounced 500ms."""
|
||||
while True:
|
||||
time.sleep(1)
|
||||
current = collect()
|
||||
|
||||
changed = [
|
||||
path for path, mtime in current.items() if prev.get(path) != mtime
|
||||
]
|
||||
|
||||
if changed:
|
||||
print(f" ↻ {len(changed)} file(s) changed:")
|
||||
for path in changed[:5]:
|
||||
print(f" {path}")
|
||||
# Debounce — editor save bursts can take ~500ms to settle
|
||||
time.sleep(0.5)
|
||||
return collect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+2146
File diff suppressed because it is too large
Load Diff
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical test runner for hermes-agent. Run this instead of calling
|
||||
# `pytest` directly to guarantee your local run matches CI behavior.
|
||||
#
|
||||
# What this script enforces:
|
||||
# * Per-file isolation via scripts/run_tests_parallel.py — each test
|
||||
# file runs in its own freshly-spawned `python -m pytest <file>`
|
||||
# subprocess. No xdist, no shared workers, no module-level leakage
|
||||
# between files.
|
||||
# * TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0 (deterministic)
|
||||
# * Env vars blanked (conftest.py also does this, but this
|
||||
# is belt-and-suspenders for anyone running pytest outside our
|
||||
# conftest path — e.g. on a single file)
|
||||
# * Proper venv activation (probes .venv, venv, then ~/.hermes/...)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/run_tests.sh # full suite
|
||||
# scripts/run_tests.sh -j 4 # cap parallelism
|
||||
# scripts/run_tests.sh tests/agent/ # discover only here
|
||||
# scripts/run_tests.sh tests/agent/ tests/acp/ # multiple roots
|
||||
# scripts/run_tests.sh tests/foo.py # single file
|
||||
# scripts/run_tests.sh tests/foo.py -- --tb=long # path + pytest args
|
||||
# scripts/run_tests.sh -- -v --tb=long # pytest args only
|
||||
#
|
||||
# Everything after a literal '--' is passed through to each per-file
|
||||
# pytest invocation. Positional path arguments before '--' override
|
||||
# the default discovery root (tests/).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Locate repo root ────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# ── Activate venv ───────────────────────────────────────────────────────────
|
||||
VENV=""
|
||||
for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agent/venv"; do
|
||||
if [ -f "$candidate/bin/activate" ]; then
|
||||
VENV="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$VENV" ]; then
|
||||
echo "error: no virtualenv found in $REPO_ROOT/.venv or $REPO_ROOT/venv" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON="$VENV/bin/python"
|
||||
|
||||
|
||||
# ── Live-gateway plugin (computed before we drop env) ───────────────────────
|
||||
EXTRA_PYTHONPATH=""
|
||||
EXTRA_PYTEST_PLUGINS=""
|
||||
if [ -f "$HOME/.hermes/pytest_live_guard.py" ]; then
|
||||
EXTRA_PYTHONPATH="$HOME/.hermes"
|
||||
EXTRA_PYTEST_PLUGINS="pytest_live_guard"
|
||||
fi
|
||||
|
||||
|
||||
# ── Run in hermetic env ──────────────────────────────────────────────────────
|
||||
# env -i: start with empty environment, opt-in only what we need.
|
||||
# No credential var can leak — you'd have to explicitly add it here.
|
||||
echo "▶ running per-file parallel test suite via run_tests_parallel.py"
|
||||
echo " (TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0; clean env)"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
exec env -i \
|
||||
PATH="$PATH" \
|
||||
HOME="$HOME" \
|
||||
TZ=UTC \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PYTHONHASHSEED=0 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
${EXTRA_PYTHONPATH:+PYTHONPATH="$EXTRA_PYTHONPATH"} \
|
||||
${EXTRA_PYTEST_PLUGINS:+PYTEST_PLUGINS="$EXTRA_PYTEST_PLUGINS"} \
|
||||
"$PYTHON" "$SCRIPT_DIR/run_tests_parallel.py" "$@"
|
||||
Executable
+862
@@ -0,0 +1,862 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-file parallel test runner.
|
||||
|
||||
The minimum-viable replacement for pytest-xdist + a subprocess-isolation
|
||||
plugin. Discovers test files under ``tests/`` (excluding integration/e2e
|
||||
unless explicitly requested), then runs one ``python -m pytest <file>``
|
||||
subprocess per file, with bounded parallelism (default: ``os.cpu_count()``).
|
||||
|
||||
Why per-file rather than per-test?
|
||||
Per-test spawn overhead (~250ms × 17k tests = 70min CPU minimum)
|
||||
swamped the actual work. Per-file spawn (~250ms × ~850 files = ~3.5min)
|
||||
fits in the budget while still giving every file a fresh Python
|
||||
interpreter — the only isolation boundary that actually matters
|
||||
(cross-file module-level state leakage was the original flake source;
|
||||
intra-file state is the test author's responsibility).
|
||||
|
||||
Why drop xdist entirely?
|
||||
xdist's persistent workers accumulate state across files, which is
|
||||
exactly the leakage we wanted to fix. xdist also adds complexity
|
||||
(loadfile vs loadscope, --max-worker-restart, internal control plane)
|
||||
that we don't need when the unit of work is "run pytest on one file".
|
||||
A subprocess.Popen pool gated by a semaphore is ~60 lines and does
|
||||
the job.
|
||||
|
||||
Usage:
|
||||
python scripts/run_tests_parallel.py [pytest_args...]
|
||||
|
||||
Common pytest args pass through (e.g. ``-v``, ``-x``, ``--tb=long``,
|
||||
``-k 'pattern'``, ``--lf``).
|
||||
|
||||
Environment:
|
||||
HERMES_TEST_WORKERS Override worker count (default: os.cpu_count())
|
||||
HERMES_TEST_PATHS Override discovery roots (colon-sep, default: 'tests')
|
||||
|
||||
Exit code: 0 if every file's pytest exited 0; 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
# Default test discovery roots.
|
||||
_DEFAULT_ROOTS = ["tests"]
|
||||
|
||||
# Directories to skip during discovery — these suites require real
|
||||
# external services (a model gateway, a docker daemon with a prebuilt
|
||||
# image, etc.) and are run in their own dedicated CI jobs:
|
||||
#
|
||||
# tests/e2e/ — .github/workflows/tests.yml :: e2e job
|
||||
# tests/integration/ — historical; legacy --ignore flags
|
||||
# tests/docker/ — .github/workflows/docker-publish.yml ::
|
||||
# build-amd64 job (runs against the freshly-loaded
|
||||
# nousresearch/hermes-agent:test image, via
|
||||
# ``HERMES_TEST_IMAGE`` so the fixture skips
|
||||
# rebuild). The full pytest-shard runner can't
|
||||
# host these because the session-scoped
|
||||
# ``built_image`` fixture would do a 3-7min
|
||||
# ``docker build``,
|
||||
# so the build is guaranteed to die in fixture
|
||||
# setup. The dedicated job sidesteps both costs.
|
||||
_SKIP_PARTS = {"integration", "e2e", "docker"}
|
||||
|
||||
# Per-file wall-clock cap. Override
|
||||
# via --file-timeout or HERMES_TEST_FILE_TIMEOUT.
|
||||
_DEFAULT_FILE_TIMEOUT_SECONDS = 140.0 # set by observing the slowest file at commit time was ~100s in CI and adding some leeway
|
||||
|
||||
# Duration cache: maps relative file paths to last-observed subprocess
|
||||
# wall-clock seconds. Used by ``--slice`` to distribute files across
|
||||
# CI jobs by estimated total time, so no one job gets all the slow files.
|
||||
_DURATIONS_FILE = "test_durations.json"
|
||||
|
||||
|
||||
def _count_tests(
|
||||
files: List[Path], repo_root: Path, pytest_passthrough: List[str]
|
||||
) -> dict[Path, int]:
|
||||
"""Run ``pytest --co -q`` once to count individual tests per file.
|
||||
|
||||
Returns a mapping ``{file_path: test_count}``. Files with zero
|
||||
collected tests are omitted from the dict (not an error — e.g. the
|
||||
file only defines fixtures / conftest helpers).
|
||||
|
||||
This is a single subprocess call (~2-5s for ~1k files) that gives
|
||||
us the total test count for the discovery announcement and
|
||||
per-file counts for the progress lines.
|
||||
|
||||
``--ignore`` flags for directories in ``_SKIP_PARTS`` are added
|
||||
automatically so that pytest's own collection machinery (conftest
|
||||
walking, directory traversal) doesn't pull in tests we intend to
|
||||
skip — matching what the per-file runs will actually execute.
|
||||
"""
|
||||
# Build --ignore flags for skipped dirs so the --co collection
|
||||
# mirrors what we'll actually run (not what pytest might find via
|
||||
# conftest walking or directory traversal).
|
||||
ignore_args: List[str] = []
|
||||
for root in [repo_root / p for p in _DEFAULT_ROOTS]:
|
||||
for part in _SKIP_PARTS:
|
||||
d = root / part
|
||||
if d.is_dir():
|
||||
ignore_args.extend(["--ignore", str(d)])
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "pytest",
|
||||
"--co", "-q",
|
||||
*ignore_args,
|
||||
*[str(f) for f in files],
|
||||
*pytest_passthrough,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return {}
|
||||
|
||||
counts: dict[Path, int] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
# Lines look like: tests/acp/test_auth.py::TestClass::test_name
|
||||
if "::" not in line:
|
||||
continue
|
||||
file_part = line.split("::", 1)[0]
|
||||
key = repo_root / file_part
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def _discover_files(roots: List[Path]) -> List[Path]:
|
||||
"""Return every ``test_*.py`` under the given roots (sorted).
|
||||
|
||||
Roots may be directories (recursed for ``test_*.py``) or explicit
|
||||
``.py`` files (included as-is, even if they don't match the
|
||||
``test_*`` prefix — caller knows what they want).
|
||||
|
||||
Exclude any file whose path contains a component in ``_SKIP_PARTS``,
|
||||
UNLESS the user explicitly named it as a root (in which case the
|
||||
user's intent overrides the skip filter). This makes
|
||||
``scripts/run_tests.sh tests/docker/`` work locally the same way
|
||||
``pytest tests/docker/`` does — the CI-level skip exists to keep
|
||||
the sharded matrix from blowing up, not to block targeted runs.
|
||||
"""
|
||||
seen: set[Path] = set()
|
||||
out: List[Path] = []
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
if root.is_file():
|
||||
# Explicit file: include it as-is, skip the _SKIP_PARTS filter
|
||||
# since the user named it directly.
|
||||
real = root.resolve()
|
||||
if real not in seen:
|
||||
seen.add(real)
|
||||
out.append(root)
|
||||
continue
|
||||
# If the explicit root itself sits inside a skipped dir (e.g.
|
||||
# the user said ``tests/docker``), the user has overridden the
|
||||
# skip for that subtree. Compute the set of skip-parts the user
|
||||
# opted into, and only filter files whose path crosses a
|
||||
# skip-part *outside* that opt-in.
|
||||
root_skip_overrides = {
|
||||
part for part in root.parts if part in _SKIP_PARTS
|
||||
}
|
||||
effective_skips = _SKIP_PARTS - root_skip_overrides
|
||||
for path in root.rglob("test_*.py"):
|
||||
if any(part in effective_skips for part in path.parts):
|
||||
continue
|
||||
real = path.resolve()
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
out.append(path)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _kill_tree(proc: "subprocess.Popen", pgid: int | None = None) -> None:
|
||||
"""Kill the pytest subprocess and every descendant it spawned.
|
||||
|
||||
A test run can spin up uvicorn servers, async runtimes, or other
|
||||
long-running grandchildren that survive the pytest subprocess exit
|
||||
if we don't kill the whole tree. ``subprocess.Popen.kill()`` only
|
||||
targets the immediate child; grandchildren reparent to PID 1
|
||||
(Linux) / get adopted by services.exe (Windows) and leak.
|
||||
|
||||
POSIX: the caller must pass ``pgid`` — the process group id captured
|
||||
immediately after Popen (via ``os.getpgid(proc.pid)``). We can't
|
||||
look it up here in the happy path because by the time we get
|
||||
called the leader process has already been reaped and its pid is
|
||||
gone from the kernel's process table, even though descendants in
|
||||
the group are still alive. SIGKILL'ing the captured pgid takes out
|
||||
everything in that group atomically.
|
||||
|
||||
Windows: ``taskkill /F /T /PID`` walks the recorded ppid chain and
|
||||
terminates the whole tree, even when the root has already exited.
|
||||
|
||||
Why not psutil: psutil walks the parent-child tree, but in the
|
||||
happy path the root has already been reaped so ``psutil.Process(pid)``
|
||||
can't find it; grandchildren reparented to PID 1 are also
|
||||
unreachable by tree walk at that point. The platform-native
|
||||
primitives (process groups / taskkill) handle both cases correctly
|
||||
without an extra abstraction layer.
|
||||
"""
|
||||
if proc.pid is None:
|
||||
return
|
||||
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
) # windows-footgun: ok
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
else:
|
||||
# POSIX: kill the captured pgid. Local-import signal so the
|
||||
# SIGKILL attribute is never referenced on Windows.
|
||||
if pgid is not None:
|
||||
try:
|
||||
import signal as _signal
|
||||
os.killpg(pgid, _signal.SIGKILL) # windows-footgun: ok
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
# Belt-and-suspenders: ensure subprocess.communicate() sees the exit.
|
||||
try:
|
||||
proc.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _run_one_file(
|
||||
file: Path,
|
||||
pytest_args: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
) -> Tuple[Path, int, str, dict[str, int], float]:
|
||||
"""Run ``python -m pytest <file> <pytest_args>`` in a fresh subprocess.
|
||||
|
||||
Returns (file, returncode, captured_combined_output, summary_counts, subprocess_wall_seconds).
|
||||
|
||||
``summary_counts`` is the result of ``_parse_pytest_summary(output)`` —
|
||||
|
||||
pytest exit codes (https://docs.pytest.org/en/stable/reference/exit-codes.html):
|
||||
0 = all tests passed
|
||||
1 = some tests failed
|
||||
2 = test execution interrupted
|
||||
3 = internal error
|
||||
4 = pytest CLI usage error
|
||||
5 = no tests collected
|
||||
|
||||
We treat exit 5 as a pass: it just means every test in the file was
|
||||
skipped or filtered by a marker (e.g. ``-m 'not integration'`` skips
|
||||
files where every test is marked integration). That's intentional and
|
||||
not a failure mode.
|
||||
|
||||
On per-file timeout (``file_timeout`` seconds) or any other exception
|
||||
during ``communicate()``, we kill the whole process group / process
|
||||
tree so grandchildren (uvicorn servers, async runtimes, etc.) do not
|
||||
orphan onto PID 1. This outer timeout exists only to
|
||||
bound a pathologically slow or hung file as a whole.
|
||||
"""
|
||||
cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args]
|
||||
|
||||
subproc_start = time.monotonic()
|
||||
# launch the pytest process
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
# skipping writing bytecode because we're running a bunch of parallel python processes on the same code
|
||||
env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'},
|
||||
# POSIX: place the child at the head of its own process group so
|
||||
# _kill_tree can SIGKILL the group atomically.
|
||||
# Windows: this maps to CREATE_NEW_PROCESS_GROUP in CPython 3.12+;
|
||||
# _kill_tree handles the Windows path via taskkill /F /T.
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# Capture the pgid NOW, before the leader can exit and be reaped. Once
|
||||
# the leader is reaped, os.getpgid(proc.pid) raises ProcessLookupError
|
||||
# even though grandchildren in that group are still alive — defeating
|
||||
# the whole cleanup. None on Windows where the pgid concept doesn't apply.
|
||||
pgid: int | None = None
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pgid = None
|
||||
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=file_timeout)
|
||||
rc = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
try:
|
||||
output, _ = proc.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
output = "(file timeout exceeded; output unavailable)"
|
||||
rc = 124 # de facto convention for "killed by timeout".
|
||||
output = (
|
||||
f"({file_timeout:.0f}s exceeded; "
|
||||
f"process tree SIGKILL'd)\n{output}"
|
||||
)
|
||||
except BaseException:
|
||||
# KeyboardInterrupt / runner crash — make sure no zombie
|
||||
# grandchildren outlive us.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
raise
|
||||
else:
|
||||
# Happy path: pytest exited on its own. Kill the group anyway in
|
||||
# case it left grandchildren behind; already-dead is a no-op.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
|
||||
output += "\n"
|
||||
|
||||
if rc == 5:
|
||||
# No tests collected — every test in the file was filtered out.
|
||||
# Treat as a pass; surface info in a slightly distinct status
|
||||
# so the operator can spot it.
|
||||
rc = 0
|
||||
summary = _parse_pytest_summary(output)
|
||||
subproc_wall = time.monotonic() - subproc_start
|
||||
return file, rc, output, summary, subproc_wall
|
||||
|
||||
|
||||
def _parse_pytest_summary(output: str) -> dict[str, int]:
|
||||
"""Extract per-file test pass/fail/skip counts from pytest output.
|
||||
|
||||
pytest prints a summary line like ``12 passed, 3 skipped, 1 failed in 2.1s``
|
||||
as the last non-empty line before the short test summary. We scrape that
|
||||
line for the individual counts so the progress display can show test-level
|
||||
granularity instead of just file-level pass/fail.
|
||||
|
||||
Returns a dict with keys ``passed``, ``failed``, ``skipped``, ``errors``,
|
||||
``xfailed``, ``xpassed`` (only keys found in the output are present).
|
||||
"""
|
||||
import re
|
||||
|
||||
result: dict[str, int] = {}
|
||||
# Walk backwards from the end — the summary line is always near the tail.
|
||||
for line in reversed(output.splitlines()):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Match "N passed", "N failed", "N skipped", "N errors", "N xfailed", "N xpassed"
|
||||
for m in re.finditer(r"(\d+)\s+(passed|failed|skipped|errors|xfailed|xpassed)", line):
|
||||
result[m.group(2)] = int(m.group(1))
|
||||
# Also match "N error" (singular — pytest uses this sometimes).
|
||||
for m in re.finditer(r"(\d+)\s+error\b", line):
|
||||
result.setdefault("errors", result.get("errors", 0) + int(m.group(1)))
|
||||
if result:
|
||||
# Found the counts line — done.
|
||||
break
|
||||
# Stop at the short test summary header (if any) — everything above
|
||||
# that is individual failure details, not the counts line.
|
||||
if line.startswith("FAILED") or line.startswith("SHORT TEST SUMMARY"):
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def _format_file(file: Path, repo_root: Path) -> str:
|
||||
"""Render a test-file path for display: strip the repo-root prefix
|
||||
when possible so output reads ``tests/acp/test_auth.py`` instead of
|
||||
``/home/runner/work/hermes-agent/hermes-agent/tests/acp/test_auth.py``.
|
||||
|
||||
Falls back to the absolute path for anything outside the repo root.
|
||||
"""
|
||||
try:
|
||||
return str(file.resolve().relative_to(repo_root.resolve()))
|
||||
except ValueError:
|
||||
return str(file)
|
||||
|
||||
|
||||
def _print_progress(
|
||||
tests_done: int,
|
||||
total_tests: int,
|
||||
file: Path,
|
||||
rc: int,
|
||||
dur: float,
|
||||
repo_root: Path,
|
||||
tests_passed: int,
|
||||
tests_failed: int,
|
||||
test_counts: dict[Path, int],
|
||||
file_summary: dict[str, int] | None = None,
|
||||
subproc_wall: float | None = None,
|
||||
) -> None:
|
||||
"""Single-line live progress.
|
||||
|
||||
When ``file_summary`` is provided (parsed from pytest output), the
|
||||
per-file parenthetical shows individual test pass/fail counts instead
|
||||
of just the total test count.
|
||||
|
||||
``subproc_wall`` is the actual subprocess wall-clock time (excluding
|
||||
queue-wait). When available, the display shows both the subprocess
|
||||
time and the queue-inclusive elapsed time.
|
||||
"""
|
||||
status = "✓" if rc == 0 else "✗"
|
||||
pct = (tests_done / total_tests * 100) if total_tests else 0
|
||||
# Digit width for left-side counter padding (derived from total file count).
|
||||
fw = len(str(tests_passed + tests_failed))
|
||||
# Build per-file test count string.
|
||||
if file_summary:
|
||||
parts = []
|
||||
p = file_summary.get("passed", 0)
|
||||
f = file_summary.get("failed", 0)
|
||||
s = file_summary.get("skipped", 0)
|
||||
e = file_summary.get("errors", 0)
|
||||
if p:
|
||||
parts.append(f"{p}✓")
|
||||
if f:
|
||||
parts.append(f"{f}✗")
|
||||
if s:
|
||||
parts.append(f"{s}s")
|
||||
if e:
|
||||
parts.append(f"{e}e")
|
||||
# xfailed/xpassed are rare; include if present.
|
||||
xf = file_summary.get("xfailed", 0)
|
||||
xp = file_summary.get("xpassed", 0)
|
||||
if xf:
|
||||
parts.append(f"{xf}xf")
|
||||
if xp:
|
||||
parts.append(f"{xp}xp")
|
||||
test_str = " ".join(parts) + ", " if parts else ""
|
||||
else:
|
||||
n_tests = test_counts.get(file, 0)
|
||||
test_str = f"{n_tests} tests, " if n_tests else ""
|
||||
# Show subprocess time when available; fall back to queue-inclusive dur.
|
||||
if subproc_wall is not None:
|
||||
time_str = f"{subproc_wall:.1f}s"
|
||||
else:
|
||||
time_str = f"{dur:.1f}s"
|
||||
msg = (
|
||||
f"[{pct:5.1f}% | {tests_done:>5}/{total_tests}"
|
||||
f" | ✓{tests_passed:>{fw}} | ✗{tests_failed:>{fw}}] "
|
||||
f"{status} {_format_file(file, repo_root)} ({test_str}{time_str})"
|
||||
)
|
||||
# Truncate to terminal width if available (no clobbering ANSI lines).
|
||||
try:
|
||||
cols = os.get_terminal_size().columns
|
||||
if len(msg) > cols:
|
||||
msg = msg[: cols - 1] + "…"
|
||||
except OSError:
|
||||
pass
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def _print_inline_failure(
|
||||
file: Path, output: str, repo_root: Path, pytest_passthrough: List[str]
|
||||
) -> None:
|
||||
"""Print a compact failure summary immediately when a file fails.
|
||||
|
||||
Shows the tail of the pytest output (the failure section with stack
|
||||
traces) and a ready-to-run repro command, so the developer doesn't
|
||||
have to wait for the full run to finish before seeing what broke.
|
||||
"""
|
||||
rel = _format_file(file, repo_root)
|
||||
# Build a repro command the developer can copy-paste.
|
||||
passthrough_str = " ".join(pytest_passthrough) if pytest_passthrough else ""
|
||||
repro = f"python -m pytest {rel}"
|
||||
if passthrough_str:
|
||||
repro += f" {passthrough_str}"
|
||||
|
||||
# Grab just the failure lines (last ~30 lines of pytest output —
|
||||
# typically the FAILED summary + short test info).
|
||||
lines = output.rstrip().splitlines()
|
||||
tail = "\n".join(lines[-30:])
|
||||
|
||||
print(flush=True)
|
||||
print(f" ╔╍ Failed: {rel} ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍", flush=True)
|
||||
for line in tail.splitlines():
|
||||
print(f" ║ {line}", flush=True)
|
||||
print(f" ║", flush=True)
|
||||
print(f" ║ Repro: {repro}", flush=True)
|
||||
print(f" ╚╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍", flush=True)
|
||||
print(flush=True)
|
||||
|
||||
|
||||
def _load_durations(repo_root: Path) -> dict[str, float]:
|
||||
"""Read the duration cache from the repo root.
|
||||
|
||||
Returns a dict mapping relative file paths (e.g.
|
||||
``tests/tools/test_code_execution.py``) to wall-clock seconds from
|
||||
the last run. Missing or corrupt file → empty dict (safe fallback).
|
||||
"""
|
||||
path = repo_root / _DURATIONS_FILE
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_durations(
|
||||
file_times: List[Tuple[Path, float]],
|
||||
repo_root: Path,
|
||||
) -> None:
|
||||
"""Write the duration cache so future ``--slice`` runs can use it.
|
||||
|
||||
Merges with any existing cache so entries from files not in the
|
||||
current run (e.g. from a different slice) are preserved. Keys are
|
||||
repo-relative paths so the cache is portable across checkouts
|
||||
and CI runners.
|
||||
"""
|
||||
data: dict[str, float] = _load_durations(repo_root)
|
||||
for f, t in file_times:
|
||||
key = _format_file(f, repo_root)
|
||||
data[key] = round(t, 3)
|
||||
path = repo_root / _DURATIONS_FILE
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _slice_files(
|
||||
files: List[Path],
|
||||
slice_index: int,
|
||||
slice_count: int,
|
||||
durations: dict[str, float],
|
||||
repo_root: Path,
|
||||
) -> List[Path]:
|
||||
"""Return the subset of *files* belonging to slice *slice_index*.
|
||||
|
||||
Uses **Longest Processing Time first** (LPT) distribution: sort files
|
||||
by estimated duration descending, then greedily assign each file to
|
||||
the slice with the smallest accumulated time so far. This minimizes
|
||||
the makespan (max slice duration) and keeps CI jobs balanced.
|
||||
|
||||
Files with no cached duration get a default estimate of 2.0s (roughly
|
||||
the P50 from profiling). This means first-time ``--slice`` runs
|
||||
(no cache) still get reasonable distribution, and new files don't
|
||||
all land in one slice.
|
||||
|
||||
``slice_index`` is 1-indexed (1..slice_count) for ergonomics —
|
||||
``--slice 1/4`` reads more naturally than ``--slice 0/4``.
|
||||
"""
|
||||
if slice_count < 2:
|
||||
return files
|
||||
if not (1 <= slice_index <= slice_count):
|
||||
print(
|
||||
f"error: --slice index must be 1..{slice_count}, got {slice_index}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Build (file, estimated_duration) pairs.
|
||||
default_dur = 2.0
|
||||
file_durs: List[Tuple[Path, float]] = []
|
||||
for f in files:
|
||||
rel = _format_file(f, repo_root)
|
||||
dur = durations.get(rel, default_dur)
|
||||
file_durs.append((f, dur))
|
||||
|
||||
# Sort longest first (LPT).
|
||||
file_durs.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Greedy assignment: for each file, add it to the slice with the
|
||||
# smallest current total.
|
||||
bucket_files: List[List[Path]] = [[] for _ in range(slice_count)]
|
||||
bucket_totals: List[float] = [0.0] * slice_count
|
||||
|
||||
for f, dur in file_durs:
|
||||
# Find the least-loaded bucket.
|
||||
min_idx = min(range(slice_count), key=lambda i: bucket_totals[i])
|
||||
bucket_files[min_idx].append(f)
|
||||
bucket_totals[min_idx] += dur
|
||||
|
||||
# Print slice summary for visibility.
|
||||
target = bucket_files[slice_index - 1]
|
||||
target_dur = bucket_totals[slice_index - 1]
|
||||
total_dur = sum(bucket_totals)
|
||||
print(
|
||||
f"Slice {slice_index}/{slice_count}: {len(target)} files "
|
||||
f"(~{target_dur:.0f}s estimated of {total_dur:.0f}s total)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=int(os.environ.get("HERMES_TEST_WORKERS") or (os.cpu_count() or 4) * 2),
|
||||
help="Parallel worker count (default: $HERMES_TEST_WORKERS or cpu_count*2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--paths",
|
||||
default=os.environ.get("HERMES_TEST_PATHS", ":".join(_DEFAULT_ROOTS)),
|
||||
help="Colon-separated discovery roots (default: 'tests')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-integration",
|
||||
action="store_true",
|
||||
help="Don't skip integration/ e2e/ during discovery",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file-timeout",
|
||||
type=float,
|
||||
default=float(
|
||||
os.environ.get("HERMES_TEST_FILE_TIMEOUT", _DEFAULT_FILE_TIMEOUT_SECONDS)
|
||||
),
|
||||
help=(
|
||||
"Per-file wall-clock cap in seconds. On timeout, the pytest "
|
||||
"subprocess and its full process tree are SIGKILL'd. "
|
||||
f"Default: {_DEFAULT_FILE_TIMEOUT_SECONDS}s ({round(_DEFAULT_FILE_TIMEOUT_SECONDS/60)} min), env: HERMES_TEST_FILE_TIMEOUT."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slice",
|
||||
metavar="I/N",
|
||||
help=(
|
||||
"Run only slice I of N (e.g. --slice 1/4). "
|
||||
"Files are distributed across slices using cached durations "
|
||||
"so each slice takes roughly equal wall time. "
|
||||
"Without a duration cache, files are distributed by count. "
|
||||
"Env: HERMES_TEST_SLICE (format: I/N)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths_positional",
|
||||
nargs="*",
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Restrict discovery to these paths (directories or .py files). "
|
||||
"Mutually exclusive with --paths. Anything after a literal '--' "
|
||||
"separator is passed through to each per-file pytest invocation."
|
||||
),
|
||||
)
|
||||
# Manually split argv on '--' so positional paths and pytest passthrough
|
||||
# args don't fight over each other. argparse's nargs="*" positional is
|
||||
# greedy and will swallow everything after '--' including the pytest
|
||||
# flags, defeating the convention.
|
||||
argv = sys.argv[1:]
|
||||
if "--" in argv:
|
||||
sep = argv.index("--")
|
||||
our_args, pytest_passthrough = argv[:sep], argv[sep + 1 :]
|
||||
else:
|
||||
our_args, pytest_passthrough = argv, []
|
||||
args = parser.parse_args(our_args)
|
||||
|
||||
# Parse --slice (or HERMES_TEST_SLICE) early so we can exit on bad input
|
||||
# before doing any expensive discovery.
|
||||
slice_raw = args.slice or os.environ.get("HERMES_TEST_SLICE")
|
||||
slice_index: int | None = None
|
||||
slice_count: int = 1
|
||||
if slice_raw:
|
||||
try:
|
||||
idx_s, count_s = slice_raw.split("/", 1)
|
||||
slice_index = int(idx_s)
|
||||
slice_count = int(count_s)
|
||||
except (ValueError, AttributeError):
|
||||
print(f"error: --slice must be I/N (e.g. 1/4), got: {slice_raw!r}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Resolve discovery roots: positional path args override --paths if any
|
||||
# were supplied, otherwise --paths (which itself defaults to 'tests').
|
||||
if args.paths_positional:
|
||||
# Positionals can be directories OR explicit .py files. Either is
|
||||
# fine — _discover_files handles both via rglob('test_*.py') for
|
||||
# dirs and direct inclusion for files.
|
||||
roots = [repo_root / p for p in args.paths_positional]
|
||||
else:
|
||||
roots = [repo_root / p for p in args.paths.split(":") if p]
|
||||
|
||||
if args.include_integration:
|
||||
# Caller takes responsibility — typically used via explicit -k filter.
|
||||
global _SKIP_PARTS # noqa: PLW0603 — config knob
|
||||
_SKIP_PARTS = set()
|
||||
|
||||
files = _discover_files(roots)
|
||||
if not files:
|
||||
print(f"No test files discovered under {[str(r) for r in roots]}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Count individual tests per file via a single pytest --co pass.
|
||||
test_counts = _count_tests(files, repo_root, pytest_passthrough)
|
||||
total_tests = sum(test_counts.values())
|
||||
|
||||
# Apply slicing if requested — distribute files across CI jobs by
|
||||
# estimated duration so no one job gets all the slow files.
|
||||
if slice_index is not None:
|
||||
durations = _load_durations(repo_root)
|
||||
files = _slice_files(files, slice_index, slice_count, durations, repo_root)
|
||||
# Recount after slicing.
|
||||
test_counts = {f: test_counts[f] for f in files if f in test_counts}
|
||||
total_tests = sum(test_counts.values())
|
||||
|
||||
print(
|
||||
f"Discovered {len(files)} test files ({total_tests} tests) under "
|
||||
f"{[str(r.relative_to(repo_root)) if r.is_relative_to(repo_root) else str(r) for r in roots]}; "
|
||||
f"running with -j {args.jobs}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Capture and print on completion (out-of-order is fine — keeps the
|
||||
# terminal clean rather than interleaving N parallel pytest outputs).
|
||||
failures: List[Tuple[Path, str, Dict[str, int]]] = []
|
||||
file_times: List[Tuple[Path, float]] = [] # (file, subprocess_wall) for distribution
|
||||
started = time.monotonic()
|
||||
files_done = 0
|
||||
tests_done = 0
|
||||
pass_count = 0
|
||||
fail_count = 0
|
||||
tests_passed = 0
|
||||
tests_failed = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, dict[str, int], float]]") -> None:
|
||||
nonlocal files_done, tests_done, pass_count, fail_count, tests_passed, tests_failed
|
||||
n_tests = test_counts.get(file, 0)
|
||||
try:
|
||||
fpath, rc, output, summary, subproc_wall = fut.result()
|
||||
except Exception as exc: # noqa: BLE001 — must always advance counter
|
||||
with lock:
|
||||
files_done += 1
|
||||
tests_done += n_tests
|
||||
fail_count += 1
|
||||
failures.append((file, f"runner crashed: {exc!r}", {}))
|
||||
_print_progress(
|
||||
tests_done, total_tests, file, 1,
|
||||
time.monotonic() - started_at,
|
||||
repo_root, tests_passed, tests_failed,
|
||||
test_counts,
|
||||
subproc_wall=0.0,
|
||||
)
|
||||
return
|
||||
with lock:
|
||||
files_done += 1
|
||||
tests_done += n_tests
|
||||
# Accumulate test-level counts from parsed summary.
|
||||
tests_passed += summary.get("passed", 0)
|
||||
tests_failed += summary.get("failed", 0)
|
||||
file_times.append((fpath, subproc_wall))
|
||||
if rc == 0:
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
failures.append((fpath, output, summary))
|
||||
_print_progress(
|
||||
tests_done, total_tests, fpath, rc,
|
||||
time.monotonic() - started_at,
|
||||
repo_root, tests_passed, tests_failed,
|
||||
test_counts,
|
||||
file_summary=summary,
|
||||
subproc_wall=subproc_wall,
|
||||
)
|
||||
if rc != 0:
|
||||
_print_inline_failure(fpath, output, repo_root, pytest_passthrough)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
|
||||
futures: List[Future] = []
|
||||
for file in files:
|
||||
t0 = time.monotonic()
|
||||
fut = pool.submit(
|
||||
_run_one_file, file, pytest_passthrough, repo_root, args.file_timeout
|
||||
)
|
||||
fut.add_done_callback(lambda f, file=file, t0=t0: _on_done(file, t0, f))
|
||||
futures.append(fut)
|
||||
# Block until everything's done. ThreadPoolExecutor.__exit__ waits
|
||||
# for all submitted work, but doing it explicitly here makes the
|
||||
# control flow obvious.
|
||||
for fut in futures:
|
||||
fut.result() if fut.exception() is None else None
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
print()
|
||||
pct = (tests_done / total_tests * 100) if total_tests else 0
|
||||
print(f"=== Summary: {len(files)} files, {tests_passed} tests passed, {tests_failed} failed ({pct:.0f}% complete) in {elapsed:.1f}s ({args.jobs} workers) ===")
|
||||
|
||||
# Save durations for future --slice runs. Each slice writes its own
|
||||
# partial test_durations.json; a CI merge step joins them later.
|
||||
# Locally, _save_durations merges with any existing cache so entries
|
||||
# from previous runs aren't lost.
|
||||
if file_times:
|
||||
_save_durations(file_times, repo_root)
|
||||
print(f" Durations cached to {_DURATIONS_FILE} ({len(file_times)} files)")
|
||||
|
||||
# Per-file time distribution (throwaway diagnostic — shows how
|
||||
# subprocess time is distributed so we can see if startup dominates).
|
||||
if file_times:
|
||||
times = sorted([t for _, t in file_times])
|
||||
total_subproc = sum(times)
|
||||
median_t = times[len(times) // 2]
|
||||
p50 = median_t
|
||||
p90 = times[int(len(times) * 0.90)]
|
||||
p95 = times[int(len(times) * 0.95)]
|
||||
p99 = times[min(int(len(times) * 0.99), len(times) - 1)]
|
||||
max_t = times[-1]
|
||||
# How many files finish in <1s? That's roughly "just startup".
|
||||
fast = sum(1 for t in times if t < 1.0)
|
||||
fast_2s = sum(1 for t in times if t < 2.0)
|
||||
print()
|
||||
print(f"=== Per-file subprocess time distribution ===")
|
||||
print(f" Files: {len(times)}")
|
||||
print(f" Total subprocess CPU-wall: {total_subproc:.1f}s (runner wall: {elapsed:.1f}s, parallelism: {args.jobs}x)")
|
||||
print(f" P50: {p50:.2f}s P90: {p90:.2f}s P95: {p95:.2f}s P99: {p99:.2f}s Max: {max_t:.2f}s")
|
||||
print(f" <1s: {fast} files ({fast/len(times)*100:.0f}%) <2s: {fast_2s} files ({fast_2s/len(times)*100:.0f}%)")
|
||||
# Top 10 slowest files — likely the ones dragging the run.
|
||||
slowest = sorted(file_times, key=lambda x: x[1], reverse=True)[:10]
|
||||
print(f" Top 10 slowest:")
|
||||
for f, t in slowest:
|
||||
print(f" {t:>6.2f}s {_format_file(f, repo_root)}")
|
||||
|
||||
if failures:
|
||||
print()
|
||||
print("=== Failure output ===")
|
||||
for file, output, _summary in failures:
|
||||
print()
|
||||
print(f"--- {_format_file(file, repo_root)} ---")
|
||||
print(output.rstrip())
|
||||
print()
|
||||
# Split: files with actual test failures vs non-zero exit for other reasons
|
||||
test_fail_files = [(f, s) for f, _o, s in failures if s.get("failed", 0) > 0]
|
||||
all_passed_but_nonzero = [(f, s) for f, _o, s in failures
|
||||
if s.get("failed", 0) == 0 and s.get("passed", 0) > 0]
|
||||
no_tests_ran = [(f, s) for f, _o, s in failures
|
||||
if s.get("failed", 0) == 0 and s.get("passed", 0) == 0]
|
||||
if test_fail_files:
|
||||
total_tf = sum(s.get("failed", 0) for _, s in test_fail_files)
|
||||
print(f"=== {len(test_fail_files)} file{'s' if len(test_fail_files) != 1 else ''} with test failures ({total_tf} test{'s' if total_tf != 1 else ''} failed) ===")
|
||||
for file, s in test_fail_files:
|
||||
nf = s.get("failed", 0)
|
||||
print(f" {_format_file(file, repo_root)} ({nf} test{'s' if nf != 1 else ''} failed)")
|
||||
if all_passed_but_nonzero:
|
||||
print(f"=== {len(all_passed_but_nonzero)} file{'s' if len(all_passed_but_nonzero) != 1 else ''} where all tests passed but pytest exited non-zero (warnings-as-errors, hook failures, etc.) ===")
|
||||
for file, s in all_passed_but_nonzero:
|
||||
print(f" {_format_file(file, repo_root)} ({s.get('passed', 0)} passed)")
|
||||
if no_tests_ran:
|
||||
print(f"=== {len(no_tests_ran)} file{'s' if len(no_tests_ran) != 1 else ''} where no tests ran (collection/import error, timeout before collection, etc.) ===")
|
||||
for file, s in no_tests_ran:
|
||||
print(f" {_format_file(file, repo_root)}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sample and Compress HuggingFace Datasets
|
||||
|
||||
Downloads trajectories from multiple HuggingFace datasets, randomly samples them,
|
||||
and runs trajectory compression to fit within a target token budget.
|
||||
|
||||
Usage:
|
||||
python scripts/sample_and_compress.py
|
||||
|
||||
# Custom sample size
|
||||
python scripts/sample_and_compress.py --total_samples=5000
|
||||
|
||||
# Custom output name
|
||||
python scripts/sample_and_compress.py --output_name=compressed_16k
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Tuple
|
||||
import fire
|
||||
|
||||
# Load environment variables
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# Default datasets to sample from
|
||||
DEFAULT_DATASETS = [
|
||||
"NousResearch/swe-terminus-agent-glm-kimi-minimax",
|
||||
"NousResearch/hermes-agent-megascience-sft1",
|
||||
"NousResearch/Hermes-Agent-Thinking-GLM-4.7-SFT2",
|
||||
"NousResearch/Hermes-Agent-Thinking-GLM-4.7-SFT1",
|
||||
"NousResearch/terminal-tasks-glm-hermes-agent"
|
||||
]
|
||||
|
||||
|
||||
def load_dataset_from_hf(dataset_name: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load a dataset from HuggingFace.
|
||||
|
||||
Args:
|
||||
dataset_name: HuggingFace dataset name (e.g., "NousResearch/dataset-name")
|
||||
|
||||
Returns:
|
||||
List of trajectory entries
|
||||
"""
|
||||
from datasets import load_dataset
|
||||
|
||||
print(f" Loading {dataset_name}...")
|
||||
|
||||
try:
|
||||
# Try loading with default config
|
||||
ds = load_dataset(dataset_name, split="train")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Error loading {dataset_name}: {e}")
|
||||
return []
|
||||
|
||||
# Convert to list of dicts
|
||||
entries = []
|
||||
for item in ds:
|
||||
# Handle different possible formats
|
||||
if "conversations" in item:
|
||||
entries.append({"conversations": item["conversations"]})
|
||||
elif "messages" in item:
|
||||
# Convert messages format to conversations format if needed
|
||||
entries.append({"conversations": item["messages"]})
|
||||
else:
|
||||
# Assume the whole item is the entry
|
||||
entries.append(dict(item))
|
||||
|
||||
print(f" ✅ Loaded {len(entries):,} entries from {dataset_name}")
|
||||
return entries
|
||||
|
||||
|
||||
# Global tokenizer for multiprocessing (set in worker init)
|
||||
_TOKENIZER = None
|
||||
|
||||
|
||||
def _init_tokenizer_worker(tokenizer_name: str):
|
||||
"""Initialize tokenizer in worker process."""
|
||||
global _TOKENIZER
|
||||
from transformers import AutoTokenizer
|
||||
_TOKENIZER = AutoTokenizer.from_pretrained(tokenizer_name, trust_remote_code=True)
|
||||
|
||||
|
||||
def _count_tokens_for_entry(entry: Dict) -> Tuple[Dict, int]:
|
||||
"""
|
||||
Count tokens for a single entry (used in parallel processing).
|
||||
|
||||
Args:
|
||||
entry: Trajectory entry with 'conversations' field
|
||||
|
||||
Returns:
|
||||
Tuple of (entry, token_count)
|
||||
"""
|
||||
global _TOKENIZER
|
||||
|
||||
conversations = entry.get("conversations", [])
|
||||
if not conversations:
|
||||
return entry, 0
|
||||
|
||||
total = 0
|
||||
for turn in conversations:
|
||||
value = turn.get("value", "")
|
||||
if value:
|
||||
try:
|
||||
total += len(_TOKENIZER.encode(value))
|
||||
except Exception:
|
||||
# Fallback to character estimate
|
||||
total += len(value) // 4
|
||||
|
||||
return entry, total
|
||||
|
||||
|
||||
def sample_from_datasets(
|
||||
datasets: List[str],
|
||||
total_samples: int,
|
||||
min_tokens: int = 16000,
|
||||
tokenizer_name: str = "moonshotai/Kimi-K2-Thinking",
|
||||
seed: int = 42,
|
||||
num_proc: int = 8
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load all datasets, filter by token count, then randomly sample from combined pool.
|
||||
|
||||
Args:
|
||||
datasets: List of HuggingFace dataset names
|
||||
total_samples: Total number of samples to collect
|
||||
min_tokens: Minimum token count to include (only sample trajectories >= this)
|
||||
tokenizer_name: HuggingFace tokenizer for counting tokens
|
||||
seed: Random seed for reproducibility
|
||||
num_proc: Number of parallel processes for tokenization
|
||||
|
||||
Returns:
|
||||
List of sampled trajectory entries
|
||||
"""
|
||||
from multiprocessing import Pool
|
||||
|
||||
random.seed(seed)
|
||||
|
||||
print(f"\n📥 Loading {len(datasets)} datasets...")
|
||||
print(f" Minimum tokens: {min_tokens:,} (filtering smaller trajectories)")
|
||||
print(f" Parallel workers: {num_proc}")
|
||||
print()
|
||||
|
||||
# Load ALL entries from all datasets into one pool
|
||||
all_entries = []
|
||||
|
||||
for dataset_name in datasets:
|
||||
entries = load_dataset_from_hf(dataset_name)
|
||||
|
||||
if not entries:
|
||||
print(f" ⚠️ Skipping {dataset_name} (no entries loaded)")
|
||||
continue
|
||||
|
||||
# Add source metadata to each entry
|
||||
for entry in entries:
|
||||
entry["_source_dataset"] = dataset_name
|
||||
|
||||
all_entries.extend(entries)
|
||||
|
||||
print(f"\n📊 Total entries loaded: {len(all_entries):,}")
|
||||
|
||||
# Filter by token count using parallel processing
|
||||
print(f"\n🔍 Filtering trajectories with >= {min_tokens:,} tokens (using {num_proc} workers)...")
|
||||
|
||||
filtered_entries = []
|
||||
token_counts = []
|
||||
|
||||
# Use multiprocessing for token counting
|
||||
with Pool(
|
||||
processes=num_proc,
|
||||
initializer=_init_tokenizer_worker,
|
||||
initargs=(tokenizer_name,)
|
||||
) as pool:
|
||||
# Process in chunks and show progress
|
||||
chunk_size = 1000
|
||||
processed = 0
|
||||
|
||||
for result in pool.imap_unordered(_count_tokens_for_entry, all_entries, chunksize=100):
|
||||
entry, token_count = result
|
||||
processed += 1
|
||||
|
||||
if processed % chunk_size == 0:
|
||||
print(f" Processed {processed:,}/{len(all_entries):,}...", end="\r")
|
||||
|
||||
if token_count >= min_tokens:
|
||||
entry["_original_tokens"] = token_count
|
||||
filtered_entries.append(entry)
|
||||
token_counts.append(token_count)
|
||||
|
||||
print(f"\n ✅ Found {len(filtered_entries):,} trajectories >= {min_tokens:,} tokens")
|
||||
|
||||
if token_counts:
|
||||
avg_tokens = sum(token_counts) / len(token_counts)
|
||||
print(f" 📈 Token stats: min={min(token_counts):,}, max={max(token_counts):,}, avg={avg_tokens:,.0f}")
|
||||
|
||||
# Random sample from the filtered pool
|
||||
if len(filtered_entries) <= total_samples:
|
||||
print(f"\n⚠️ Only {len(filtered_entries):,} trajectories available, using all of them")
|
||||
sampled = filtered_entries
|
||||
else:
|
||||
sampled = random.sample(filtered_entries, total_samples)
|
||||
print(f"\n✅ Randomly sampled {len(sampled):,} trajectories from pool of {len(filtered_entries):,}")
|
||||
|
||||
# Show source distribution
|
||||
source_counts = {}
|
||||
for entry in sampled:
|
||||
source = entry.get("_source_dataset", "unknown").split("/")[-1]
|
||||
source_counts[source] = source_counts.get(source, 0) + 1
|
||||
|
||||
print(f"\n📌 Sample distribution by source:")
|
||||
for source, count in sorted(source_counts.items()):
|
||||
print(f" {source}: {count:,}")
|
||||
|
||||
# Shuffle
|
||||
random.shuffle(sampled)
|
||||
|
||||
return sampled
|
||||
|
||||
|
||||
def save_samples_for_compression(
|
||||
samples: List[Dict[str, Any]],
|
||||
output_dir: Path,
|
||||
batch_size: int = 100
|
||||
):
|
||||
"""
|
||||
Save samples to JSONL files for trajectory compression.
|
||||
|
||||
Args:
|
||||
samples: List of trajectory entries
|
||||
output_dir: Directory to save JSONL files
|
||||
batch_size: Number of entries per file
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Split into batches
|
||||
num_batches = (len(samples) + batch_size - 1) // batch_size
|
||||
|
||||
print(f"\n💾 Saving {len(samples)} samples to {output_dir}")
|
||||
print(f" Batch size: {batch_size}, Total batches: {num_batches}")
|
||||
|
||||
for i in range(num_batches):
|
||||
start_idx = i * batch_size
|
||||
end_idx = min((i + 1) * batch_size, len(samples))
|
||||
batch = samples[start_idx:end_idx]
|
||||
|
||||
output_file = output_dir / f"batch_{i}.jsonl"
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
for entry in batch:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||
|
||||
print(f" ✅ Saved {num_batches} batch files")
|
||||
|
||||
|
||||
def run_compression(input_dir: Path, output_dir: Path, config_path: str):
|
||||
"""
|
||||
Run trajectory compression on the sampled data.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing JSONL files to compress
|
||||
output_dir: Directory for compressed output
|
||||
config_path: Path to compression config YAML
|
||||
"""
|
||||
# Import the compressor
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from trajectory_compressor import TrajectoryCompressor, CompressionConfig
|
||||
|
||||
print(f"\n🗜️ Running trajectory compression...")
|
||||
print(f" Input: {input_dir}")
|
||||
print(f" Output: {output_dir}")
|
||||
print(f" Config: {config_path}")
|
||||
|
||||
# Load config
|
||||
config = CompressionConfig.from_yaml(config_path)
|
||||
|
||||
# Initialize compressor
|
||||
compressor = TrajectoryCompressor(config)
|
||||
|
||||
# Run compression
|
||||
compressor.process_directory(input_dir, output_dir)
|
||||
|
||||
|
||||
def merge_output_to_single_jsonl(input_dir: Path, output_file: Path):
|
||||
"""
|
||||
Merge all JSONL files in a directory into a single JSONL file.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing JSONL files
|
||||
output_file: Output JSONL file path
|
||||
"""
|
||||
print(f"\n📦 Merging output files into {output_file.name}...")
|
||||
|
||||
all_entries = []
|
||||
for jsonl_file in sorted(input_dir.glob("*.jsonl")):
|
||||
if jsonl_file.name == output_file.name:
|
||||
continue
|
||||
with open(jsonl_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
all_entries.append(json.loads(line))
|
||||
|
||||
# Write merged file
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
for entry in all_entries:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||
|
||||
print(f" ✅ Merged {len(all_entries):,} entries into {output_file.name}")
|
||||
return output_file
|
||||
|
||||
|
||||
def main(
|
||||
total_samples: int = 2500,
|
||||
output_name: str = "compressed_agentic",
|
||||
datasets: str = None,
|
||||
config: str = "configs/trajectory_compression.yaml",
|
||||
seed: int = 42,
|
||||
batch_size: int = 100,
|
||||
min_tokens: int = 16000,
|
||||
num_proc: int = 8,
|
||||
skip_download: bool = False,
|
||||
):
|
||||
"""
|
||||
Sample trajectories from HuggingFace datasets and run compression.
|
||||
|
||||
Args:
|
||||
total_samples: Total number of samples to collect (default: 2500)
|
||||
output_name: Name for output directory/file (default: "compressed_agentic")
|
||||
datasets: Comma-separated list of dataset names (uses defaults if not provided)
|
||||
config: Path to compression config YAML
|
||||
seed: Random seed for reproducibility
|
||||
batch_size: Number of entries per JSONL file during processing
|
||||
min_tokens: Minimum token count to filter trajectories (default: 16000)
|
||||
num_proc: Number of parallel workers for tokenization (default: 8)
|
||||
skip_download: Skip download and use existing sampled data
|
||||
"""
|
||||
print("=" * 70)
|
||||
print("📊 TRAJECTORY SAMPLING AND COMPRESSION")
|
||||
print("=" * 70)
|
||||
|
||||
# Parse datasets
|
||||
if datasets:
|
||||
dataset_list = [d.strip() for d in datasets.split(",")]
|
||||
else:
|
||||
dataset_list = DEFAULT_DATASETS
|
||||
|
||||
print(f"\n📋 Configuration:")
|
||||
print(f" Total samples: {total_samples:,}")
|
||||
print(f" Min tokens filter: {min_tokens:,}")
|
||||
print(f" Parallel workers: {num_proc}")
|
||||
print(f" Datasets: {len(dataset_list)}")
|
||||
for ds in dataset_list:
|
||||
print(f" - {ds}")
|
||||
print(f" Output name: {output_name}")
|
||||
print(f" Config: {config}")
|
||||
print(f" Seed: {seed}")
|
||||
|
||||
# Setup paths
|
||||
base_dir = Path(__file__).parent.parent
|
||||
sampled_dir = base_dir / "data" / f"{output_name}_raw"
|
||||
compressed_dir = base_dir / "data" / f"{output_name}_batches"
|
||||
final_output = base_dir / "data" / f"{output_name}.jsonl"
|
||||
|
||||
if not skip_download:
|
||||
# Step 1: Download, filter by token count, and sample from combined pool
|
||||
samples = sample_from_datasets(
|
||||
dataset_list,
|
||||
total_samples,
|
||||
min_tokens=min_tokens,
|
||||
seed=seed,
|
||||
num_proc=num_proc
|
||||
)
|
||||
|
||||
if not samples:
|
||||
print("❌ No samples collected. Exiting.")
|
||||
return
|
||||
|
||||
# Step 2: Save to JSONL files
|
||||
save_samples_for_compression(samples, sampled_dir, batch_size)
|
||||
else:
|
||||
print(f"\n⏭️ Skipping download, using existing data in {sampled_dir}")
|
||||
|
||||
# Step 3: Run compression
|
||||
config_path = base_dir / config
|
||||
if not config_path.exists():
|
||||
print(f"❌ Config not found: {config_path}")
|
||||
return
|
||||
|
||||
run_compression(sampled_dir, compressed_dir, str(config_path))
|
||||
|
||||
# Step 4: Merge into single JSONL file
|
||||
merge_output_to_single_jsonl(compressed_dir, final_output)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ COMPLETE!")
|
||||
print("=" * 70)
|
||||
print(f"\n📁 Raw samples: {sampled_dir}")
|
||||
print(f"📁 Compressed batches: {compressed_dir}")
|
||||
print(f"📁 Final output: {final_output}")
|
||||
print(f"\nTo upload to HuggingFace:")
|
||||
print(f" huggingface-cli upload NousResearch/{output_name} {final_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fire.Fire(main)
|
||||
Executable
+349
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bootstrap Open WebUI against Hermes Agent's OpenAI-compatible API server.
|
||||
#
|
||||
# Idempotent by design:
|
||||
# - ensures ~/.hermes/.env has API server settings
|
||||
# - installs Open WebUI into ~/.local/open-webui-venv
|
||||
# - writes a reusable launcher at ~/.local/bin/start-open-webui-hermes.sh
|
||||
# - optionally installs a user service (launchd on macOS, systemd --user on Linux)
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/setup_open_webui.sh
|
||||
#
|
||||
# Optional environment overrides:
|
||||
# OPEN_WEBUI_PORT=8080
|
||||
# OPEN_WEBUI_HOST=127.0.0.1
|
||||
# OPEN_WEBUI_NAME='Johnny Hermes'
|
||||
# OPEN_WEBUI_ENABLE_SIGNUP=true
|
||||
# OPEN_WEBUI_ENABLE_SERVICE=auto # auto|true|false
|
||||
# OPEN_WEBUI_VENV=~/.local/open-webui-venv
|
||||
# OPEN_WEBUI_DATA_DIR=~/.local/share/open-webui/data
|
||||
# HERMES_API_PORT=8642
|
||||
# HERMES_API_HOST=127.0.0.1
|
||||
# HERMES_API_MODEL_NAME='Hermes Agent'
|
||||
|
||||
OPEN_WEBUI_PORT="${OPEN_WEBUI_PORT:-8080}"
|
||||
OPEN_WEBUI_HOST="${OPEN_WEBUI_HOST:-127.0.0.1}"
|
||||
OPEN_WEBUI_NAME="${OPEN_WEBUI_NAME:-Hermes Agent WebUI}"
|
||||
OPEN_WEBUI_ENABLE_SIGNUP="${OPEN_WEBUI_ENABLE_SIGNUP:-true}"
|
||||
OPEN_WEBUI_ENABLE_SERVICE="${OPEN_WEBUI_ENABLE_SERVICE:-auto}"
|
||||
OPEN_WEBUI_VENV="${OPEN_WEBUI_VENV:-$HOME/.local/open-webui-venv}"
|
||||
OPEN_WEBUI_DATA_DIR="${OPEN_WEBUI_DATA_DIR:-$HOME/.local/share/open-webui/data}"
|
||||
HERMES_ENV_FILE="${HERMES_ENV_FILE:-$HOME/.hermes/.env}"
|
||||
HERMES_API_PORT="${HERMES_API_PORT:-8642}"
|
||||
HERMES_API_HOST="${HERMES_API_HOST:-127.0.0.1}"
|
||||
HERMES_API_CONNECT_HOST="${HERMES_API_CONNECT_HOST:-127.0.0.1}"
|
||||
HERMES_API_MODEL_NAME="${HERMES_API_MODEL_NAME:-Hermes Agent}"
|
||||
HERMES_API_BASE_URL="http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/v1"
|
||||
LAUNCHER_PATH="$HOME/.local/bin/start-open-webui-hermes.sh"
|
||||
LOG_DIR="$HOME/.hermes/logs"
|
||||
|
||||
log() {
|
||||
printf '[open-webui-bootstrap] %s\n' "$*"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
choose_python() {
|
||||
if command -v python3.11 >/dev/null 2>&1; then
|
||||
echo python3.11
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
echo python3
|
||||
else
|
||||
echo "Python 3 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
upsert_env() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local file="$3"
|
||||
|
||||
mkdir -p "$(dirname "$file")"
|
||||
touch "$file"
|
||||
|
||||
python3 - "$file" "$key" "$value" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
path = Path(sys.argv[1])
|
||||
key = sys.argv[2]
|
||||
value = sys.argv[3]
|
||||
lines = path.read_text().splitlines() if path.exists() else []
|
||||
out = []
|
||||
seen = False
|
||||
for raw in lines:
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith(f"{key}="):
|
||||
if not seen:
|
||||
out.append(f"{key}={value}")
|
||||
seen = True
|
||||
continue
|
||||
out.append(raw)
|
||||
if not seen:
|
||||
if out and out[-1] != "":
|
||||
out.append("")
|
||||
out.append(f"{key}={value}")
|
||||
path.write_text("\n".join(out).rstrip() + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
get_env_value() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
python3 - "$file" "$key" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
path = Path(sys.argv[1])
|
||||
key = sys.argv[2]
|
||||
if not path.exists():
|
||||
raise SystemExit(0)
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith(f"{key}="):
|
||||
print(line.split("=", 1)[1])
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
}
|
||||
|
||||
generate_secret() {
|
||||
python3 - <<'PY'
|
||||
import secrets
|
||||
print(secrets.token_urlsafe(32))
|
||||
PY
|
||||
}
|
||||
|
||||
shell_quote() {
|
||||
python3 - "$1" <<'PY'
|
||||
import shlex
|
||||
import sys
|
||||
print(shlex.quote(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
can_use_systemd_user() {
|
||||
[[ "$(uname -s)" == "Linux" ]] || return 1
|
||||
command -v systemctl >/dev/null 2>&1 || return 1
|
||||
|
||||
local uid runtime_dir bus_path
|
||||
uid="$(id -u)"
|
||||
runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$uid}"
|
||||
bus_path="$runtime_dir/bus"
|
||||
|
||||
if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "$runtime_dir" ]]; then
|
||||
export XDG_RUNTIME_DIR="$runtime_dir"
|
||||
fi
|
||||
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "$bus_path" ]]; then
|
||||
export DBUS_SESSION_BUS_ADDRESS="unix:path=$bus_path"
|
||||
fi
|
||||
|
||||
systemctl --user show-environment >/dev/null 2>&1
|
||||
}
|
||||
|
||||
install_macos_dependencies() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then
|
||||
if ! command -v pandoc >/dev/null 2>&1; then
|
||||
log 'Installing pandoc with Homebrew (recommended by Open WebUI docs)...'
|
||||
brew install pandoc
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
install_open_webui() {
|
||||
local py
|
||||
py="$(choose_python)"
|
||||
log "Using Python interpreter: $py"
|
||||
"$py" -m venv "$OPEN_WEBUI_VENV"
|
||||
# shellcheck disable=SC1090
|
||||
source "$OPEN_WEBUI_VENV/bin/activate"
|
||||
"$py" -m pip install --upgrade pip setuptools wheel
|
||||
"$py" -m pip install open-webui
|
||||
}
|
||||
|
||||
write_launcher() {
|
||||
mkdir -p "$(dirname "$LAUNCHER_PATH")" "$OPEN_WEBUI_DATA_DIR" "$LOG_DIR"
|
||||
|
||||
local quoted_data_dir quoted_name quoted_base_url quoted_host quoted_port quoted_venv
|
||||
quoted_data_dir="$(shell_quote "$OPEN_WEBUI_DATA_DIR")"
|
||||
quoted_name="$(shell_quote "$OPEN_WEBUI_NAME")"
|
||||
quoted_base_url="$(shell_quote "$HERMES_API_BASE_URL")"
|
||||
quoted_host="$(shell_quote "$OPEN_WEBUI_HOST")"
|
||||
quoted_port="$(shell_quote "$OPEN_WEBUI_PORT")"
|
||||
quoted_venv="$(shell_quote "$OPEN_WEBUI_VENV")"
|
||||
|
||||
cat > "$LAUNCHER_PATH" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
API_KEY=\$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
p = Path.home()/'.hermes'/'.env'
|
||||
for raw in p.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith('API_SERVER_KEY='):
|
||||
print(line.split('=', 1)[1])
|
||||
break
|
||||
PY
|
||||
)
|
||||
export DATA_DIR=${quoted_data_dir}
|
||||
export WEBUI_NAME=${quoted_name}
|
||||
export ENABLE_SIGNUP=${OPEN_WEBUI_ENABLE_SIGNUP}
|
||||
export ENABLE_PUBLIC_ACTIVE_USERS_COUNT=False
|
||||
export ENABLE_VERSION_UPDATE_CHECK=False
|
||||
export OPENAI_API_BASE_URL=${quoted_base_url}
|
||||
export OPENAI_API_KEY="\$API_KEY"
|
||||
export ENABLE_OPENAI_API=True
|
||||
export ENABLE_OLLAMA_API=False
|
||||
export OFFLINE_MODE=True
|
||||
export BYPASS_EMBEDDING_AND_RETRIEVAL=True
|
||||
export RAG_EMBEDDING_MODEL_AUTO_UPDATE=False
|
||||
export RAG_RERANKING_MODEL_AUTO_UPDATE=False
|
||||
export SCARF_NO_ANALYTICS=true
|
||||
export DO_NOT_TRACK=true
|
||||
export ANONYMIZED_TELEMETRY=false
|
||||
export HOST=${quoted_host}
|
||||
export PORT=${quoted_port}
|
||||
source ${quoted_venv}/bin/activate
|
||||
exec open-webui serve
|
||||
EOF
|
||||
|
||||
chmod +x "$LAUNCHER_PATH"
|
||||
}
|
||||
|
||||
ensure_env_permissions() {
|
||||
chmod 600 "$HERMES_ENV_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
install_launchd_service() {
|
||||
local plist="$HOME/Library/LaunchAgents/ai.openwebui.hermes.plist"
|
||||
mkdir -p "$(dirname "$plist")"
|
||||
cat > "$plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.openwebui.hermes</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>${LAUNCHER_PATH}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${HOME}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG_DIR}/openwebui.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG_DIR}/openwebui.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootout "gui/$(id -u)" "$plist" >/dev/null 2>&1 || true
|
||||
launchctl bootstrap "gui/$(id -u)" "$plist"
|
||||
launchctl enable "gui/$(id -u)/ai.openwebui.hermes"
|
||||
launchctl kickstart -k "gui/$(id -u)/ai.openwebui.hermes"
|
||||
}
|
||||
|
||||
install_systemd_user_service() {
|
||||
require_cmd systemctl
|
||||
local unit_dir="$HOME/.config/systemd/user"
|
||||
local unit="$unit_dir/openwebui-hermes.service"
|
||||
mkdir -p "$unit_dir"
|
||||
cat > "$unit" <<EOF
|
||||
[Unit]
|
||||
Description=Open WebUI connected to Hermes Agent
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/bin/bash %h/.local/bin/start-open-webui-hermes.sh
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
WorkingDirectory=%h
|
||||
StandardOutput=append:%h/.hermes/logs/openwebui.log
|
||||
StandardError=append:%h/.hermes/logs/openwebui.error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now openwebui-hermes.service
|
||||
}
|
||||
|
||||
start_foreground_hint() {
|
||||
log "Launcher created at: ${LAUNCHER_PATH}"
|
||||
log "Start Open WebUI manually with: ${LAUNCHER_PATH}"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd hermes
|
||||
require_cmd curl
|
||||
require_cmd python3
|
||||
|
||||
install_macos_dependencies
|
||||
|
||||
local api_key
|
||||
api_key="$(get_env_value API_SERVER_KEY "$HERMES_ENV_FILE")"
|
||||
if [[ -z "$api_key" ]]; then
|
||||
api_key="$(generate_secret)"
|
||||
fi
|
||||
|
||||
log 'Ensuring Hermes API server is configured...'
|
||||
upsert_env API_SERVER_ENABLED true "$HERMES_ENV_FILE"
|
||||
upsert_env API_SERVER_HOST "$HERMES_API_HOST" "$HERMES_ENV_FILE"
|
||||
upsert_env API_SERVER_PORT "$HERMES_API_PORT" "$HERMES_ENV_FILE"
|
||||
upsert_env API_SERVER_MODEL_NAME "$HERMES_API_MODEL_NAME" "$HERMES_ENV_FILE"
|
||||
upsert_env API_SERVER_KEY "$api_key" "$HERMES_ENV_FILE"
|
||||
ensure_env_permissions
|
||||
|
||||
log 'Restarting Hermes gateway so API server settings take effect...'
|
||||
hermes gateway restart >/dev/null 2>&1 || true
|
||||
sleep 4
|
||||
if ! curl -fsS "http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/health" >/dev/null; then
|
||||
log 'Hermes API server did not answer on the first check. Trying to start gateway in the background...'
|
||||
nohup hermes gateway run >/dev/null 2>&1 &
|
||||
sleep 6
|
||||
fi
|
||||
curl -fsS "http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/health" >/dev/null
|
||||
|
||||
log 'Installing Open WebUI into a dedicated virtualenv...'
|
||||
install_open_webui
|
||||
write_launcher
|
||||
|
||||
case "$OPEN_WEBUI_ENABLE_SERVICE" in
|
||||
true|auto)
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
install_launchd_service
|
||||
elif can_use_systemd_user; then
|
||||
install_systemd_user_service
|
||||
else
|
||||
log 'No usable user service manager detected; falling back to the launcher script.'
|
||||
start_foreground_hint
|
||||
fi
|
||||
;;
|
||||
false)
|
||||
start_foreground_hint
|
||||
;;
|
||||
*)
|
||||
echo "OPEN_WEBUI_ENABLE_SERVICE must be one of: auto, true, false" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Done. Open WebUI should be available at: http://${OPEN_WEBUI_HOST}:${OPEN_WEBUI_PORT}"
|
||||
log "Hermes API endpoint: ${HERMES_API_BASE_URL}"
|
||||
log 'Important: Open WebUI persists connection settings after first launch. If you later save a wrong API key in the Admin UI, update/delete that connection there or reset its database.'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,134 @@
|
||||
# Smoke tests for the install.ps1 stage protocol.
|
||||
#
|
||||
# Run from a PowerShell prompt:
|
||||
#
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1
|
||||
#
|
||||
# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest,
|
||||
# unknown -Stage handling). They DO NOT actually run any install stages --
|
||||
# those have heavy side effects (winget, git clone, pip install, PATH writes)
|
||||
# and are out of scope for a unit smoke test. All three metadata commands
|
||||
# below return without invoking Main / Invoke-AllStages.
|
||||
#
|
||||
# To exercise real install stages, drive the script from a clean VM.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot "scripts\install.ps1"
|
||||
|
||||
if (-not (Test-Path $installScript)) {
|
||||
throw "Could not locate install.ps1 at $installScript"
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
function Assert-Equal {
|
||||
param([Parameter(Mandatory=$true)] $Expected,
|
||||
[Parameter(Mandatory=$true)] $Actual,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if ($Expected -ne $Actual) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
Write-Host " expected: $Expected"
|
||||
Write-Host " actual: $Actual"
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
function Assert-True {
|
||||
param([Parameter(Mandatory=$true)] $Condition,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if (-not $Condition) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -ProtocolVersion emits a single integer
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -ProtocolVersion --"
|
||||
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0"
|
||||
Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -Manifest emits valid JSON with expected shape
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Manifest --"
|
||||
$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0"
|
||||
|
||||
$manifest = $null
|
||||
try {
|
||||
$manifest = $manifestJson | ConvertFrom-Json
|
||||
Assert-True $true -Label "-Manifest output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($manifest) {
|
||||
Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) `
|
||||
-Label "manifest.protocol_version is an integer"
|
||||
Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty"
|
||||
|
||||
# Every stage has the four required fields
|
||||
$allValid = $true
|
||||
foreach ($stage in $manifest.stages) {
|
||||
foreach ($field in @("name", "title", "category", "needs_user_input")) {
|
||||
if (-not ($stage.PSObject.Properties.Name -contains $field)) {
|
||||
Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red
|
||||
$allValid = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-True $allValid -Label "every stage has name/title/category/needs_user_input"
|
||||
|
||||
# Specific stage names that the GUI driver will rely on
|
||||
$names = $manifest.stages | ForEach-Object { $_.name }
|
||||
foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) {
|
||||
Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'"
|
||||
}
|
||||
|
||||
# The two known-interactive stages must declare needs_user_input
|
||||
$interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name }
|
||||
Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input"
|
||||
Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: unknown stage name -> exit 2, structured JSON error
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Stage with unknown name --"
|
||||
$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist"
|
||||
Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2"
|
||||
|
||||
$errFrame = $null
|
||||
try {
|
||||
$errFrame = $errOutput | ConvertFrom-Json
|
||||
Assert-True $true -Label "unknown-stage output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($errFrame) {
|
||||
Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false"
|
||||
Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name"
|
||||
Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "All smoke tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live test harness for Hermes Agent's Tool Search feature.
|
||||
|
||||
Spins up a real AIAgent against a real model, registers ~20 fake "MCP" tools
|
||||
with realistic shapes (github-like, slack-like, calendar-like, search-like),
|
||||
runs a small set of scenarios, and records exactly what the model did.
|
||||
|
||||
For each scenario we record:
|
||||
- the full message transcript
|
||||
- the sequence of tool calls (name + args) the model emitted
|
||||
- which underlying tools actually got invoked (after bridge unwrap)
|
||||
- the final assistant response
|
||||
- timing and round-trip count
|
||||
|
||||
Each scenario runs twice:
|
||||
- tool_search ENABLED (deferred behind bridges)
|
||||
- tool_search DISABLED (all tools loaded directly)
|
||||
|
||||
Output: ./out/<scenario_id>__<enabled|disabled>.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
# Force-isolate the test environment BEFORE any hermes imports.
|
||||
ORIGINAL_HOME = os.environ.get("HERMES_HOME")
|
||||
ORIGINAL_AUTH = Path.home() / ".hermes" / "auth.json"
|
||||
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_WORKTREE_ROOT = _THIS_DIR.parent
|
||||
sys.path.insert(0, str(_WORKTREE_ROOT))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake MCP tools — realistic shape, varied difficulty for retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAKE_MCP_TOOLS: List[Dict[str, Any]] = [
|
||||
# GitHub cluster
|
||||
{
|
||||
"name": "github_create_issue",
|
||||
"description": "Open a new issue in a GitHub repository. Use when the user wants to report a bug or request a feature in a repo.",
|
||||
"params": {"repo": ("string", "Repository in owner/name form"),
|
||||
"title": ("string", "Issue title"),
|
||||
"body": ("string", "Issue body in Markdown")},
|
||||
"returns": lambda args: {"ok": True, "issue_url": f"https://github.com/{args.get('repo','x/y')}/issues/42"},
|
||||
},
|
||||
{
|
||||
"name": "github_search_repos",
|
||||
"description": "Search GitHub repositories by free-text query. Returns a ranked list of repo names with star counts.",
|
||||
"params": {"query": ("string", "Search terms"),
|
||||
"limit": ("integer", "Max results")},
|
||||
"returns": lambda args: {"results": [{"name": "fake/repo-1", "stars": 1200},
|
||||
{"name": "fake/repo-2", "stars": 540}]},
|
||||
},
|
||||
{
|
||||
"name": "github_close_pr",
|
||||
"description": "Close a pull request without merging it. Use when the PR should be abandoned.",
|
||||
"params": {"repo": ("string", ""), "pr_number": ("integer", "")},
|
||||
"returns": lambda args: {"ok": True, "state": "closed"},
|
||||
},
|
||||
{
|
||||
"name": "github_list_pulls",
|
||||
"description": "List open pull requests for a repository.",
|
||||
"params": {"repo": ("string", "")},
|
||||
"returns": lambda args: {"pulls": [{"number": 31163, "title": "feat(tools): tool search"}]},
|
||||
},
|
||||
|
||||
# Slack cluster
|
||||
{
|
||||
"name": "slack_send_message",
|
||||
"description": "Post a message into a Slack channel as the connected workspace's app.",
|
||||
"params": {"channel": ("string", "Channel name with leading #"),
|
||||
"text": ("string", "Message body")},
|
||||
"returns": lambda args: {"ok": True, "ts": "1716528000.000100"},
|
||||
},
|
||||
{
|
||||
"name": "slack_list_channels",
|
||||
"description": "Return all channels visible to the connected Slack workspace bot.",
|
||||
"params": {},
|
||||
"returns": lambda args: {"channels": ["#general", "#engineering", "#random"]},
|
||||
},
|
||||
{
|
||||
"name": "slack_set_status",
|
||||
"description": "Set the current user's Slack status (emoji + text).",
|
||||
"params": {"emoji": ("string", ""), "text": ("string", "")},
|
||||
"returns": lambda args: {"ok": True},
|
||||
},
|
||||
|
||||
# Calendar cluster (intentionally vague names to stress retrieval)
|
||||
{
|
||||
"name": "evt_create",
|
||||
"description": "Add an event to the connected calendar. Used for scheduling meetings.",
|
||||
"params": {"title": ("string", ""),
|
||||
"start": ("string", "ISO 8601 datetime"),
|
||||
"duration_min": ("integer", "")},
|
||||
"returns": lambda args: {"ok": True, "event_id": "evt_abc"},
|
||||
},
|
||||
{
|
||||
"name": "evt_list",
|
||||
"description": "List upcoming calendar events.",
|
||||
"params": {"max_results": ("integer", "")},
|
||||
"returns": lambda args: {"events": [{"id": "evt_1", "title": "Standup", "start": "2026-05-25T09:00:00Z"}]},
|
||||
},
|
||||
|
||||
# Knowledge / docs (paraphrased name to stress retrieval)
|
||||
{
|
||||
"name": "docsearch_query",
|
||||
"description": "Search the user's internal documentation index for matching pages.",
|
||||
"params": {"q": ("string", "Search query"), "limit": ("integer", "")},
|
||||
"returns": lambda args: {"hits": [{"title": "Onboarding", "url": "https://docs/x"}]},
|
||||
},
|
||||
{
|
||||
"name": "docsearch_fetch",
|
||||
"description": "Fetch the full markdown content of one document by ID.",
|
||||
"params": {"id": ("string", "")},
|
||||
"returns": lambda args: {"content": "# Onboarding\n..."},
|
||||
},
|
||||
|
||||
# Database
|
||||
{
|
||||
"name": "db_query",
|
||||
"description": "Run a read-only SQL query against the analytics database.",
|
||||
"params": {"sql": ("string", "SELECT ... statement")},
|
||||
"returns": lambda args: {"rows": [{"id": 1, "name": "alice"}]},
|
||||
},
|
||||
{
|
||||
"name": "db_describe_table",
|
||||
"description": "Show the schema of a database table.",
|
||||
"params": {"table": ("string", "")},
|
||||
"returns": lambda args: {"columns": [{"name": "id", "type": "int"}, {"name": "name", "type": "text"}]},
|
||||
},
|
||||
|
||||
# Linear
|
||||
{
|
||||
"name": "linear_create_ticket",
|
||||
"description": "Create a new Linear issue (ticket) in the connected workspace.",
|
||||
"params": {"title": ("string", ""), "body": ("string", ""), "priority": ("integer", "1-4")},
|
||||
"returns": lambda args: {"ok": True, "id": "ENG-101"},
|
||||
},
|
||||
{
|
||||
"name": "linear_assign",
|
||||
"description": "Reassign a Linear ticket to a different user.",
|
||||
"params": {"ticket_id": ("string", ""), "user": ("string", "")},
|
||||
"returns": lambda args: {"ok": True},
|
||||
},
|
||||
|
||||
# Notion
|
||||
{
|
||||
"name": "notion_create_page",
|
||||
"description": "Create a new page in the connected Notion workspace.",
|
||||
"params": {"title": ("string", ""), "body": ("string", ""), "parent": ("string", "")},
|
||||
"returns": lambda args: {"ok": True, "page_id": "abc123"},
|
||||
},
|
||||
|
||||
# Random others (filler / distractors)
|
||||
{
|
||||
"name": "weather_get",
|
||||
"description": "Look up the current weather for a city.",
|
||||
"params": {"city": ("string", "")},
|
||||
"returns": lambda args: {"city": args.get("city", ""), "temp_c": 19, "summary": "Cloudy"},
|
||||
},
|
||||
{
|
||||
"name": "translate_text",
|
||||
"description": "Translate a short text from one language to another.",
|
||||
"params": {"text": ("string", ""), "to": ("string", "Target language code")},
|
||||
"returns": lambda args: {"translated": args.get("text", "") + " [translated to " + args.get("to", "??") + "]"},
|
||||
},
|
||||
{
|
||||
"name": "pdf_extract",
|
||||
"description": "Extract text from a PDF file given its path.",
|
||||
"params": {"path": ("string", "")},
|
||||
"returns": lambda args: {"text": "[fake PDF text]"},
|
||||
},
|
||||
{
|
||||
"name": "yt_transcript",
|
||||
"description": "Fetch the transcript for a YouTube video by URL.",
|
||||
"params": {"url": ("string", "")},
|
||||
"returns": lambda args: {"transcript": "[fake transcript]"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCENARIOS: List[Dict[str, Any]] = [
|
||||
{
|
||||
"id": "A_obvious_single",
|
||||
"description": "Single tool, obvious name in the user request",
|
||||
"prompt": (
|
||||
"Open a GitHub issue in repo 'acme/widget' titled 'Crash on startup' "
|
||||
"with body 'App crashes immediately after launch when offline.' "
|
||||
"Then tell me you're done. Don't do anything else."
|
||||
),
|
||||
"expected_underlying_tools": ["github_create_issue"],
|
||||
},
|
||||
{
|
||||
"id": "B_vague_paraphrased",
|
||||
"description": "Single tool, paraphrased intent (tests retrieval quality)",
|
||||
"prompt": (
|
||||
"Add a meeting to my schedule for tomorrow morning at 10am called "
|
||||
"'Design review', 30 minutes long. Then tell me you're done. Don't do anything else."
|
||||
),
|
||||
"expected_underlying_tools": ["evt_create"],
|
||||
},
|
||||
{
|
||||
"id": "C_multi_tool_chain",
|
||||
"description": "Multi-step task requiring 2-3 deferred tools",
|
||||
"prompt": (
|
||||
"Find the open pull requests on repo 'acme/widget', then post a "
|
||||
"summary of how many there are to the #engineering Slack channel. "
|
||||
"Then tell me you're done."
|
||||
),
|
||||
"expected_underlying_tools": ["github_list_pulls", "slack_send_message"],
|
||||
},
|
||||
{
|
||||
"id": "D_core_plus_deferred",
|
||||
"description": "Task uses BOTH a core tool (read_file) and a deferred tool",
|
||||
"prompt": (
|
||||
"Read the file at /tmp/livetest/notes.txt (it exists, just read it) "
|
||||
"and then post its contents to the #random Slack channel. Tell me you're done."
|
||||
),
|
||||
"expected_underlying_tools": ["read_file", "slack_send_message"],
|
||||
"expected_core_tool_direct": True, # must NOT use tool_call for read_file
|
||||
},
|
||||
{
|
||||
"id": "E_no_tool_needed",
|
||||
"description": "Question doesn't need any tool — model should just answer",
|
||||
"prompt": "What's 7 times 8? Answer with just the number.",
|
||||
"expected_underlying_tools": [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def setup_isolated_home(enabled: bool) -> Path:
|
||||
"""Create a fresh ~/.hermes/ for one test, copying minimal credentials.
|
||||
|
||||
Also reads OPENROUTER_API_KEY from the user's real ``~/.hermes/.env`` so
|
||||
the agent can authenticate against OpenRouter inside the isolated home.
|
||||
"""
|
||||
home_dir = Path(tempfile.mkdtemp(prefix="hermes_ts_live_"))
|
||||
hermes_home = home_dir / ".hermes"
|
||||
hermes_home.mkdir(parents=True)
|
||||
|
||||
if ORIGINAL_AUTH.exists():
|
||||
shutil.copy(ORIGINAL_AUTH, hermes_home / "auth.json")
|
||||
|
||||
# Copy .env so OPENROUTER_API_KEY (or others) are visible to the agent
|
||||
# running inside the isolated home.
|
||||
real_env_file = Path.home() / ".hermes" / ".env"
|
||||
if real_env_file.exists():
|
||||
shutil.copy(real_env_file, hermes_home / ".env")
|
||||
# Also load the real user env into this process so the provider
|
||||
# resolver can authenticate. We go through the canonical loader
|
||||
# (python-dotenv under the hood) rather than parsing the file by
|
||||
# hand — it never materializes the secret in a local variable in
|
||||
# this module, which both avoids a hand-rolled parser bug and keeps
|
||||
# static analysis from tainting the transcript records with the key.
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
load_hermes_dotenv(hermes_home=str(Path.home() / ".hermes"))
|
||||
|
||||
cfg = {
|
||||
"model": {
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-haiku-4.5",
|
||||
},
|
||||
"tools": {
|
||||
"tool_search": {
|
||||
"enabled": "on" if enabled else "off",
|
||||
"threshold_pct": 10,
|
||||
"search_default_limit": 5,
|
||||
"max_search_limit": 20,
|
||||
},
|
||||
},
|
||||
"logging": {"level": "WARNING"},
|
||||
}
|
||||
(hermes_home / "config.yaml").write_text(_yaml_dump(cfg), encoding="utf-8")
|
||||
return hermes_home
|
||||
|
||||
|
||||
def _yaml_dump(obj: Any) -> str:
|
||||
try:
|
||||
import yaml
|
||||
return yaml.safe_dump(obj, sort_keys=False)
|
||||
except ImportError:
|
||||
return json.dumps(obj, indent=2)
|
||||
|
||||
|
||||
def register_fake_tools() -> int:
|
||||
"""Register the FAKE_MCP_TOOLS into the live tool registry."""
|
||||
from tools.registry import registry
|
||||
|
||||
def make_handler(tool_def):
|
||||
def _handler(*args, **kwargs):
|
||||
try:
|
||||
return json.dumps(tool_def["returns"](kwargs), ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"fake tool handler error: {e}"})
|
||||
return _handler
|
||||
|
||||
count = 0
|
||||
for tdef in FAKE_MCP_TOOLS:
|
||||
properties = {}
|
||||
required = []
|
||||
for p_name, (p_type, p_desc) in tdef["params"].items():
|
||||
properties[p_name] = {"type": p_type, "description": p_desc}
|
||||
required.append(p_name)
|
||||
|
||||
registry.register(
|
||||
name=tdef["name"],
|
||||
toolset="mcp-fake",
|
||||
schema={
|
||||
"name": tdef["name"],
|
||||
"description": tdef["description"],
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
},
|
||||
handler=make_handler(tdef),
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def reset_module_state():
|
||||
"""Drop cached modules so the new HERMES_HOME takes effect."""
|
||||
keys = [k for k in sys.modules.keys()
|
||||
if k.startswith(("tools.", "model_tools", "toolsets",
|
||||
"hermes_cli", "agent.", "run_agent"))]
|
||||
for k in keys:
|
||||
del sys.modules[k]
|
||||
|
||||
|
||||
def run_one_scenario(scenario: Dict[str, Any], enabled: bool, out_dir: Path) -> Dict[str, Any]:
|
||||
"""Run one (scenario, enabled) combination. Returns the recorded transcript."""
|
||||
reset_module_state()
|
||||
home = setup_isolated_home(enabled=enabled)
|
||||
os.environ["HERMES_HOME"] = str(home)
|
||||
|
||||
# Pre-create the test file used by scenario D.
|
||||
Path("/tmp/livetest").mkdir(exist_ok=True)
|
||||
Path("/tmp/livetest/notes.txt").write_text("Hello from the test fixture.\n", encoding="utf-8")
|
||||
|
||||
n_registered = register_fake_tools()
|
||||
|
||||
# Capture tool calls via a hook on the registry dispatch path. We use the
|
||||
# registry hook (rather than the run_agent.handle_function_call binding,
|
||||
# which is already cached by tool_executor) because the dispatch call is
|
||||
# the one place every underlying tool call lands. Bridge calls are
|
||||
# extracted from the message transcript after the run.
|
||||
tool_call_log: List[Dict[str, Any]] = []
|
||||
|
||||
from tools.registry import registry
|
||||
original_dispatch = registry.dispatch
|
||||
|
||||
def logging_dispatch(name, args, **kw):
|
||||
tool_call_log.append({"name": name, "args": _trim_args(args)})
|
||||
return original_dispatch(name, args, **kw)
|
||||
registry.dispatch = logging_dispatch
|
||||
|
||||
# Build agent and run
|
||||
started = time.time()
|
||||
error = None
|
||||
final_response = ""
|
||||
messages_out = []
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
provider="openrouter",
|
||||
model="anthropic/claude-haiku-4.5",
|
||||
enabled_toolsets=None, # Default = all available toolsets, including the registered mcp-fake tools
|
||||
quiet_mode=True,
|
||||
save_trajectories=False,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
platform="cli",
|
||||
max_iterations=15,
|
||||
)
|
||||
result = agent.run_conversation(
|
||||
user_message=scenario["prompt"],
|
||||
system_message=(
|
||||
"You are a test agent. Complete the user's task using available "
|
||||
"tools. Be concise; don't add commentary beyond what's needed."
|
||||
),
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
final_response = result.get("final_response") or ""
|
||||
messages_out = result.get("messages") or []
|
||||
else:
|
||||
final_response = str(result)
|
||||
except Exception as e:
|
||||
error = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
|
||||
finally:
|
||||
registry.dispatch = original_dispatch
|
||||
|
||||
elapsed = time.time() - started
|
||||
|
||||
# Extract bridge calls from the message transcript. Easier and more
|
||||
# accurate than monkey-patching: this is the actual wire shape the
|
||||
# model emitted.
|
||||
bridge_call_log = _extract_bridge_calls(messages_out)
|
||||
|
||||
# Compose the trace.
|
||||
record = {
|
||||
"scenario_id": scenario["id"],
|
||||
"scenario_description": scenario["description"],
|
||||
"tool_search_enabled": enabled,
|
||||
"model": "anthropic/claude-haiku-4.5 (via openrouter)",
|
||||
"prompt": scenario["prompt"],
|
||||
"expected_underlying_tools": scenario.get("expected_underlying_tools", []),
|
||||
"n_fake_tools_registered": n_registered,
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"bridge_calls": bridge_call_log,
|
||||
"underlying_tool_calls": tool_call_log,
|
||||
"final_response": _redact_secrets(final_response),
|
||||
"n_iterations": _count_assistant_turns(messages_out),
|
||||
"error": _redact_secrets(error) if error else error,
|
||||
}
|
||||
|
||||
suffix = "enabled" if enabled else "disabled"
|
||||
out_path = out_dir / f"{scenario['id']}__{suffix}.json"
|
||||
out_path.write_text(json.dumps(record, indent=2, default=str), encoding="utf-8")
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(home.parent, ignore_errors=True)
|
||||
return record
|
||||
|
||||
|
||||
def _redact_secrets(text: str) -> str:
|
||||
"""Strip anything secret-shaped from text before it is stored or printed.
|
||||
|
||||
The harness runs against a real OpenRouter key, and ``error`` can carry a
|
||||
full traceback that — for an auth failure — may echo a request header or
|
||||
URL containing the key. We never want a credential landing in a checked-in
|
||||
transcript or the console, so we mask:
|
||||
* the live OPENROUTER_API_KEY value, if present in the environment, and
|
||||
* any ``sk-``/``sk-or-`` style bearer token by pattern.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
live_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if live_key and len(live_key) >= 8:
|
||||
out = out.replace(live_key, "[REDACTED]")
|
||||
out = re.sub(r"sk-[A-Za-z0-9_\-]{12,}", "[REDACTED]", out)
|
||||
out = re.sub(r"(?i)(authorization|bearer)\s*[:=]\s*\S+", r"\1: [REDACTED]", out)
|
||||
return out
|
||||
|
||||
|
||||
def _trim_args(args: Any, max_chars: int = 300) -> Any:
|
||||
"""Trim long string args so the log stays readable."""
|
||||
if not isinstance(args, dict):
|
||||
return args
|
||||
out = {}
|
||||
for k, v in args.items():
|
||||
if isinstance(v, str) and len(v) > max_chars:
|
||||
out[k] = v[:max_chars] + f"...[{len(v)-max_chars} chars trimmed]"
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _count_assistant_turns(messages: List[Dict[str, Any]]) -> int:
|
||||
return sum(1 for m in messages if isinstance(m, dict) and m.get("role") == "assistant")
|
||||
|
||||
|
||||
def _extract_bridge_calls(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Pull out every tool_search / tool_describe / tool_call from a transcript."""
|
||||
bridges = ("tool_search", "tool_describe", "tool_call")
|
||||
out: List[Dict[str, Any]] = []
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict) or m.get("role") != "assistant":
|
||||
continue
|
||||
tcs = m.get("tool_calls") or []
|
||||
for c in tcs:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
fn = c.get("function") or {}
|
||||
name = fn.get("name")
|
||||
if name in bridges:
|
||||
raw_args = fn.get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": raw_args}
|
||||
out.append({"name": name, "args": _trim_args(args)})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
out_dir = _THIS_DIR / "out"
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
print(f"Writing transcripts to: {out_dir}")
|
||||
|
||||
summary = []
|
||||
for scenario in SCENARIOS:
|
||||
for enabled in (True, False):
|
||||
label = "enabled" if enabled else "disabled"
|
||||
print(f"\n{'='*72}\nScenario {scenario['id']} (tool_search={label})\n{'='*72}")
|
||||
record = run_one_scenario(scenario, enabled, out_dir)
|
||||
n_bridge = len(record["bridge_calls"])
|
||||
n_under = len(record["underlying_tool_calls"])
|
||||
err = record["error"]
|
||||
print(f" bridge calls: {n_bridge}, underlying tool calls: {n_under}, "
|
||||
f"elapsed: {record['elapsed_seconds']}s, error: {bool(err)}")
|
||||
if err:
|
||||
print(f" ERROR: {err[:300]}")
|
||||
summary.append({
|
||||
"scenario": scenario["id"],
|
||||
"enabled": enabled,
|
||||
"n_bridge": n_bridge,
|
||||
"n_underlying": n_under,
|
||||
"elapsed": record["elapsed_seconds"],
|
||||
"error": bool(err),
|
||||
"underlying_tools_called": [c["name"] for c in record["underlying_tool_calls"]],
|
||||
"expected": scenario.get("expected_underlying_tools", []),
|
||||
})
|
||||
|
||||
summary_path = out_dir / "_summary.json"
|
||||
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
print(f"\nSummary saved to: {summary_path}")
|
||||
|
||||
# Restore original HERMES_HOME
|
||||
if ORIGINAL_HOME is not None:
|
||||
os.environ["HERMES_HOME"] = ORIGINAL_HOME
|
||||
else:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
export function normalizeWhatsAppIdentifier(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/:.*@/, '@')
|
||||
.replace(/@.*/, '')
|
||||
.replace(/^\+/, '');
|
||||
}
|
||||
|
||||
export function parseAllowedUsers(rawValue) {
|
||||
return new Set(
|
||||
String(rawValue || '')
|
||||
.split(',')
|
||||
.map((value) => normalizeWhatsAppIdentifier(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function readMappingFile(sessionDir, identifier, suffix = '') {
|
||||
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const normalized = normalizeWhatsAppIdentifier(parsed);
|
||||
return normalized || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
|
||||
const normalized = normalizeWhatsAppIdentifier(identifier);
|
||||
if (!normalized) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Walk both phone->LID and LID->phone mapping files so allowlists can use
|
||||
// either form transparently in bot mode.
|
||||
const resolved = new Set();
|
||||
const queue = [normalized];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || resolved.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.add(current);
|
||||
|
||||
for (const suffix of ['', '_reverse']) {
|
||||
const mapped = readMappingFile(sessionDir, current, suffix);
|
||||
if (mapped && !resolved.has(mapped)) {
|
||||
queue.push(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
|
||||
// Empty allowlist = NO ONE allowed (secure default, #8389). Operators
|
||||
// who want an open bot must set ``WHATSAPP_ALLOWED_USERS=*`` explicitly.
|
||||
// Previous behaviour (empty → return true) let any stranger DM the
|
||||
// bridge and trigger a Python-side pairing-code reply.
|
||||
if (!allowedUsers || allowedUsers.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)
|
||||
if (allowedUsers.has('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
|
||||
for (const alias of aliases) {
|
||||
if (allowedUsers.has(alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
expandWhatsAppIdentifiers,
|
||||
matchesAllowedUser,
|
||||
normalizeWhatsAppIdentifier,
|
||||
parseAllowedUsers,
|
||||
} from './allowlist.js';
|
||||
|
||||
test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
|
||||
assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
|
||||
assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
|
||||
assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
|
||||
});
|
||||
|
||||
test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
|
||||
assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const allowedUsers = parseAllowedUsers('+19175395595');
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
|
||||
assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser treats * as allow-all wildcard', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
const allowedUsers = parseAllowedUsers('*');
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', allowedUsers, sessionDir), true);
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser rejects everyone when allowlist is empty (#8389)', () => {
|
||||
// Regression guard: empty allowlist used to return true (allow-everyone),
|
||||
// which let any stranger DM the bridge and trigger a Python-side
|
||||
// pairing-code reply. Secure default is now "reject unless explicitly
|
||||
// configured"; operators who want an open bot must set `*`.
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
const empty = parseAllowedUsers('');
|
||||
assert.equal(empty.size, 0);
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', empty, sessionDir), false);
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', empty, sessionDir), false);
|
||||
|
||||
// Null/undefined allowlist (defensive) also rejects.
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', null, sessionDir), false);
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', undefined, sessionDir), false);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,750 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Hermes Agent WhatsApp Bridge
|
||||
*
|
||||
* Standalone Node.js process that connects to WhatsApp via Baileys
|
||||
* and exposes HTTP endpoints for the Python gateway adapter.
|
||||
*
|
||||
* Endpoints (matches gateway/platforms/whatsapp.py expectations):
|
||||
* GET /messages - Long-poll for new incoming messages
|
||||
* POST /send - Send a message { chatId, message, replyTo? }
|
||||
* POST /edit - Edit a sent message { chatId, messageId, message }
|
||||
* POST /send-media - Send media natively { chatId, filePath, mediaType?, caption?, fileName? }
|
||||
* POST /typing - Send typing indicator { chatId }
|
||||
* GET /chat/:id - Get chat info
|
||||
* GET /health - Health check
|
||||
*
|
||||
* Usage:
|
||||
* node bridge.js --port 3000 --session ~/.hermes/whatsapp/session
|
||||
*/
|
||||
|
||||
import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys';
|
||||
import express from 'express';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
|
||||
|
||||
// Parse CLI args
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(name, defaultVal) {
|
||||
const idx = args.indexOf(`--${name}`);
|
||||
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal;
|
||||
}
|
||||
|
||||
const WHATSAPP_DEBUG =
|
||||
typeof process !== 'undefined' &&
|
||||
process.env &&
|
||||
typeof process.env.WHATSAPP_DEBUG === 'string' &&
|
||||
['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase());
|
||||
|
||||
const PORT = parseInt(getArg('port', '3000'), 10);
|
||||
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
|
||||
// Cache directories: the Python gateway passes the profile-aware paths via
|
||||
// env (HERMES_HOME-aware, new cache/ layout). Fall back to the legacy
|
||||
// hardcoded locations for bridges launched outside the gateway.
|
||||
const IMAGE_CACHE_DIR = process.env.HERMES_IMAGE_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'image_cache');
|
||||
const DOCUMENT_CACHE_DIR = process.env.HERMES_DOCUMENT_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'document_cache');
|
||||
const AUDIO_CACHE_DIR = process.env.HERMES_AUDIO_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
|
||||
// Self-hash of this script file. Reported in /health so the Python gateway
|
||||
// can detect a running bridge that predates the current bridge.js and
|
||||
// restart it instead of silently reusing stale code (stale-bridge trap:
|
||||
// `hermes update` updates bridge.js on disk but a long-lived bridge process
|
||||
// keeps serving the old behavior forever).
|
||||
let SCRIPT_HASH = '';
|
||||
try {
|
||||
SCRIPT_HASH = createHash('sha256')
|
||||
.update(readFileSync(fileURLToPath(import.meta.url)))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
} catch {}
|
||||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
|
||||
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
||||
? DEFAULT_REPLY_PREFIX
|
||||
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
|
||||
const MAX_MESSAGE_LENGTH = parseInt(process.env.WHATSAPP_MAX_MESSAGE_LENGTH || '4096', 10);
|
||||
const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10);
|
||||
// Per-call timeout for sock.sendMessage(). Baileys occasionally hangs forever
|
||||
// when uploading media to WhatsApp servers (and, less often, on text sends),
|
||||
// which pins the bridge's HTTP handler until the upstream aiohttp timeout
|
||||
// fires. Fail fast instead so the gateway can surface a real error and retry.
|
||||
const SEND_TIMEOUT_MS = parseInt(process.env.WHATSAPP_SEND_TIMEOUT_MS || '60000', 10);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function sendWithTimeout(chatId, payload, timeoutMs = SEND_TIMEOUT_MS) {
|
||||
let timer;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`sendMessage timed out after ${timeoutMs / 1000}s`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
return Promise.race([sock.sendMessage(chatId, payload), timeoutPromise])
|
||||
.finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function formatOutgoingMessage(message) {
|
||||
// In bot mode, messages come from a different number so the prefix is
|
||||
// redundant — the sender identity is already clear. Only prepend in
|
||||
// self-chat mode where bot and user share the same number.
|
||||
if (WHATSAPP_MODE !== 'self-chat') return message;
|
||||
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
|
||||
}
|
||||
|
||||
function splitLongMessage(message, maxLength = MAX_MESSAGE_LENGTH) {
|
||||
const text = String(message || '');
|
||||
if (!text) return [];
|
||||
if (!Number.isFinite(maxLength) || maxLength < 1 || text.length <= maxLength) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > maxLength) {
|
||||
let splitAt = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitAt < Math.floor(maxLength / 2)) {
|
||||
splitAt = remaining.lastIndexOf(' ', maxLength);
|
||||
}
|
||||
if (splitAt < 1) splitAt = maxLength;
|
||||
|
||||
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
||||
remaining = remaining.slice(splitAt).trimStart();
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function trackSentMessageId(sent) {
|
||||
if (sent?.key?.id) {
|
||||
recentlySentIds.add(sent.key.id);
|
||||
if (recentlySentIds.size > MAX_RECENT_IDS) {
|
||||
recentlySentIds.delete(recentlySentIds.values().next().value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWhatsAppId(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace(':', '@');
|
||||
}
|
||||
|
||||
function getMessageContent(msg) {
|
||||
const content = msg?.message || {};
|
||||
if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
|
||||
if (content.viewOnceMessage?.message) return content.viewOnceMessage.message;
|
||||
if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message;
|
||||
if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message;
|
||||
if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate;
|
||||
if (content.buttonsMessage) return content.buttonsMessage;
|
||||
if (content.listMessage) return content.listMessage;
|
||||
return content;
|
||||
}
|
||||
|
||||
function getContextInfo(messageContent) {
|
||||
if (!messageContent || typeof messageContent !== 'object') return {};
|
||||
for (const value of Object.values(messageContent)) {
|
||||
if (value && typeof value === 'object' && value.contextInfo) {
|
||||
return value.contextInfo;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
|
||||
// Build LID → phone reverse map from session files (lid-mapping-{phone}.json)
|
||||
function buildLidMap() {
|
||||
const map = {};
|
||||
try {
|
||||
for (const f of readdirSync(SESSION_DIR)) {
|
||||
const m = f.match(/^lid-mapping-(\d+)\.json$/);
|
||||
if (!m) continue;
|
||||
const phone = m[1];
|
||||
const lid = JSON.parse(readFileSync(path.join(SESSION_DIR, f), 'utf8'));
|
||||
if (lid) map[String(lid)] = phone;
|
||||
}
|
||||
} catch {}
|
||||
return map;
|
||||
}
|
||||
let lidToPhone = buildLidMap();
|
||||
|
||||
const logger = pino({ level: 'warn' });
|
||||
|
||||
// Message queue for polling
|
||||
const messageQueue = [];
|
||||
const MAX_QUEUE_SIZE = 100;
|
||||
|
||||
// Track recently sent message IDs to prevent echo-back loops with media
|
||||
const recentlySentIds = new Set();
|
||||
const MAX_RECENT_IDS = 50;
|
||||
|
||||
let sock = null;
|
||||
let connectionState = 'disconnected';
|
||||
|
||||
async function startSocket() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ['Hermes Agent', 'Chrome', '120.0'],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
// Required for Baileys 7.x: without this, incoming messages that need
|
||||
// E2EE session re-establishment are silently dropped (msg.message === null)
|
||||
getMessage: async (key) => {
|
||||
// We don't maintain a message store, so return a placeholder.
|
||||
// This is enough for Baileys to complete the retry handshake.
|
||||
return { conversation: '' };
|
||||
},
|
||||
});
|
||||
|
||||
sock.ev.on('creds.update', () => { saveCreds(); lidToPhone = buildLidMap(); });
|
||||
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
console.log('\nWaiting for scan...\n');
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
|
||||
connectionState = 'disconnected';
|
||||
|
||||
if (reason === DisconnectReason.loggedOut) {
|
||||
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
// 515 = restart requested (common after pairing). Always reconnect.
|
||||
if (reason === 515) {
|
||||
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
|
||||
} else {
|
||||
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
|
||||
}
|
||||
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
connectionState = 'connected';
|
||||
console.log('✅ WhatsApp connected!');
|
||||
if (PAIR_ONLY) {
|
||||
console.log('✅ Pairing complete. Credentials saved.');
|
||||
// Give Baileys a moment to flush creds, then exit cleanly
|
||||
setTimeout(() => process.exit(0), 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
// In self-chat mode, your own messages commonly arrive as 'append' rather
|
||||
// than 'notify'. Accept both and filter agent echo-backs below.
|
||||
if (type !== 'notify' && type !== 'append') return;
|
||||
|
||||
const botIds = Array.from(new Set([
|
||||
normalizeWhatsAppId(sock.user?.id),
|
||||
normalizeWhatsAppId(sock.user?.lid),
|
||||
].filter(Boolean)));
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.message) continue;
|
||||
|
||||
const chatId = msg.key.remoteJid;
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'upsert', type,
|
||||
fromMe: !!msg.key.fromMe, chatId,
|
||||
senderId: msg.key.participant || chatId,
|
||||
messageKeys: Object.keys(msg.message || {}),
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
const senderId = msg.key.participant || chatId;
|
||||
const isGroup = chatId.endsWith('@g.us');
|
||||
const senderNumber = senderId.replace(/@.*/, '');
|
||||
|
||||
// Handle fromMe messages based on mode
|
||||
if (msg.key.fromMe) {
|
||||
if (isGroup || chatId.includes('status')) continue;
|
||||
|
||||
if (WHATSAPP_MODE === 'bot') {
|
||||
// Bot mode: separate number. ALL fromMe are echo-backs of our own replies — skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Self-chat mode: only allow messages in the user's own self-chat
|
||||
// WhatsApp now uses LID (Linked Identity Device) format: 67427329167522@lid
|
||||
// AND classic format: 34652029134@s.whatsapp.net
|
||||
// sock.user has both: { id: "number:10@s.whatsapp.net", lid: "lid_number:10@lid" }
|
||||
const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, '');
|
||||
const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
|
||||
const chatNumber = chatId.replace(/@.*/, '');
|
||||
const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
|
||||
if (!isSelfChat) continue;
|
||||
}
|
||||
|
||||
// Handle !fromMe messages (from other people) based on mode.
|
||||
// Self-chat mode only responds to the user's own messages to
|
||||
// themselves — stranger DMs / group pings must never reach the
|
||||
// Python gateway, otherwise a pairing-code reply fires in response
|
||||
// to arbitrary incoming messages (#8389).
|
||||
if (!msg.key.fromMe) {
|
||||
if (WHATSAPP_MODE === 'self-chat') {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'ignored',
|
||||
reason: 'self_chat_mode_rejects_non_self',
|
||||
chatId,
|
||||
senderId,
|
||||
}));
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'ignored',
|
||||
reason: 'allowlist_mismatch',
|
||||
chatId,
|
||||
senderId,
|
||||
}));
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const messageContent = getMessageContent(msg);
|
||||
const contextInfo = getContextInfo(messageContent);
|
||||
const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean)));
|
||||
const quotedMessageId = contextInfo?.stanzaId || null;
|
||||
const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || '') || null;
|
||||
const quotedRemoteJid = normalizeWhatsAppId(contextInfo?.remoteJid || '') || null;
|
||||
const hasQuotedMessage = !!contextInfo?.quotedMessage;
|
||||
|
||||
// Extract message body
|
||||
let body = '';
|
||||
let hasMedia = false;
|
||||
let mediaType = '';
|
||||
const mediaUrls = [];
|
||||
|
||||
if (messageContent.conversation) {
|
||||
body = messageContent.conversation;
|
||||
} else if (messageContent.extendedTextMessage?.text) {
|
||||
body = messageContent.extendedTextMessage.text;
|
||||
} else if (messageContent.imageMessage) {
|
||||
body = messageContent.imageMessage.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'image';
|
||||
try {
|
||||
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
|
||||
const mime = messageContent.imageMessage.mimetype || 'image/jpeg';
|
||||
const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
|
||||
const ext = extMap[mime] || '.jpg';
|
||||
mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
|
||||
const filePath = path.join(IMAGE_CACHE_DIR, `img_${randomBytes(6).toString('hex')}${ext}`);
|
||||
writeFileSync(filePath, buf);
|
||||
mediaUrls.push(filePath);
|
||||
} catch (err) {
|
||||
console.error('[bridge] Failed to download image:', err.message);
|
||||
}
|
||||
} else if (messageContent.videoMessage) {
|
||||
body = messageContent.videoMessage.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'video';
|
||||
try {
|
||||
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
|
||||
const mime = messageContent.videoMessage.mimetype || 'video/mp4';
|
||||
const ext = mime.includes('mp4') ? '.mp4' : '.mkv';
|
||||
mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
|
||||
const filePath = path.join(DOCUMENT_CACHE_DIR, `vid_${randomBytes(6).toString('hex')}${ext}`);
|
||||
writeFileSync(filePath, buf);
|
||||
mediaUrls.push(filePath);
|
||||
} catch (err) {
|
||||
console.error('[bridge] Failed to download video:', err.message);
|
||||
}
|
||||
} else if (messageContent.audioMessage || messageContent.pttMessage) {
|
||||
hasMedia = true;
|
||||
mediaType = messageContent.pttMessage ? 'ptt' : 'audio';
|
||||
try {
|
||||
const audioMsg = messageContent.pttMessage || messageContent.audioMessage;
|
||||
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
|
||||
const mime = audioMsg.mimetype || 'audio/ogg';
|
||||
const ext = mime.includes('ogg') ? '.ogg' : mime.includes('mp4') ? '.m4a' : '.ogg';
|
||||
mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
|
||||
const filePath = path.join(AUDIO_CACHE_DIR, `aud_${randomBytes(6).toString('hex')}${ext}`);
|
||||
writeFileSync(filePath, buf);
|
||||
mediaUrls.push(filePath);
|
||||
} catch (err) {
|
||||
console.error('[bridge] Failed to download audio:', err.message);
|
||||
}
|
||||
} else if (messageContent.documentMessage) {
|
||||
body = messageContent.documentMessage.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'document';
|
||||
const fileName = messageContent.documentMessage.fileName || 'document';
|
||||
try {
|
||||
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
|
||||
mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
|
||||
const safeFileName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const filePath = path.join(DOCUMENT_CACHE_DIR, `doc_${randomBytes(6).toString('hex')}_${safeFileName}`);
|
||||
writeFileSync(filePath, buf);
|
||||
mediaUrls.push(filePath);
|
||||
} catch (err) {
|
||||
console.error('[bridge] Failed to download document:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// For media without caption, use a placeholder so the API message is never empty
|
||||
if (hasMedia && !body) {
|
||||
body = `[${mediaType} received]`;
|
||||
}
|
||||
|
||||
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
|
||||
if (msg.key.fromMe && ((REPLY_PREFIX && body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty messages
|
||||
if (!body && !hasMedia) {
|
||||
if (WHATSAPP_DEBUG) {
|
||||
try {
|
||||
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
|
||||
} catch (err) {
|
||||
console.error('Failed to log empty message event:', err);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = {
|
||||
messageId: msg.key.id,
|
||||
chatId,
|
||||
senderId,
|
||||
senderName: msg.pushName || senderNumber,
|
||||
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
|
||||
isGroup,
|
||||
body,
|
||||
hasMedia,
|
||||
mediaType,
|
||||
mediaUrls,
|
||||
mentionedIds,
|
||||
quotedMessageId,
|
||||
quotedParticipant,
|
||||
quotedRemoteJid,
|
||||
hasQuotedMessage,
|
||||
botIds,
|
||||
timestamp: msg.messageTimestamp,
|
||||
};
|
||||
|
||||
messageQueue.push(event);
|
||||
if (messageQueue.length > MAX_QUEUE_SIZE) {
|
||||
messageQueue.shift();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP server
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Host-header validation — defends against DNS rebinding.
|
||||
// The bridge binds loopback-only (127.0.0.1) but a victim browser on
|
||||
// the same machine could be tricked into fetching from an attacker
|
||||
// hostname that TTL-flips to 127.0.0.1. Reject any request whose Host
|
||||
// header doesn't resolve to a loopback alias.
|
||||
// See GHSA-ppp5-vxwm-4cf7.
|
||||
const _ACCEPTED_HOST_VALUES = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'[::1]',
|
||||
'::1',
|
||||
]);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const raw = (req.headers.host || '').trim();
|
||||
if (!raw) {
|
||||
return res.status(400).json({ error: 'Missing Host header' });
|
||||
}
|
||||
// Strip port suffix: "localhost:3000" → "localhost"
|
||||
const hostOnly = (raw.includes(':')
|
||||
? raw.substring(0, raw.lastIndexOf(':'))
|
||||
: raw
|
||||
).replace(/^\[|\]$/g, '').toLowerCase();
|
||||
if (!_ACCEPTED_HOST_VALUES.has(hostOnly)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid Host header. Bridge accepts loopback hosts only.',
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Poll for new messages (long-poll style)
|
||||
app.get('/messages', (req, res) => {
|
||||
const msgs = messageQueue.splice(0, messageQueue.length);
|
||||
res.json(msgs);
|
||||
});
|
||||
|
||||
// Send a message
|
||||
app.post('/send', async (req, res) => {
|
||||
if (!sock || connectionState !== 'connected') {
|
||||
return res.status(503).json({ error: 'Not connected to WhatsApp' });
|
||||
}
|
||||
|
||||
const { chatId, message, replyTo } = req.body;
|
||||
if (!chatId || !message) {
|
||||
return res.status(400).json({ error: 'chatId and message are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks = splitLongMessage(formatOutgoingMessage(message));
|
||||
const messageIds = [];
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const sent = await sendWithTimeout(chatId, { text: chunks[i] });
|
||||
trackSentMessageId(sent);
|
||||
if (sent?.key?.id) messageIds.push(sent.key.id);
|
||||
if (chunks.length > 1 && i < chunks.length - 1) {
|
||||
await sleep(CHUNK_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
messageId: messageIds[messageIds.length - 1],
|
||||
messageIds,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Edit a previously sent message
|
||||
app.post('/edit', async (req, res) => {
|
||||
if (!sock || connectionState !== 'connected') {
|
||||
return res.status(503).json({ error: 'Not connected to WhatsApp' });
|
||||
}
|
||||
|
||||
const { chatId, messageId, message } = req.body;
|
||||
if (!chatId || !messageId || !message) {
|
||||
return res.status(400).json({ error: 'chatId, messageId, and message are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const key = { id: messageId, fromMe: true, remoteJid: chatId };
|
||||
const chunks = splitLongMessage(formatOutgoingMessage(message));
|
||||
const messageIds = [];
|
||||
|
||||
await sendWithTimeout(chatId, { text: chunks[0], edit: key });
|
||||
if (chunks.length > 1) {
|
||||
for (let i = 1; i < chunks.length; i += 1) {
|
||||
const sent = await sendWithTimeout(chatId, { text: chunks[i] });
|
||||
trackSentMessageId(sent);
|
||||
if (sent?.key?.id) messageIds.push(sent.key.id);
|
||||
if (i < chunks.length - 1) {
|
||||
await sleep(CHUNK_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, messageIds });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// MIME type map and media type inference for /send-media
|
||||
const MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
webp: 'image/webp', gif: 'image/gif',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska', '3gp': 'video/3gpp',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
function inferMediaType(ext) {
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video';
|
||||
if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
// Send media (image, video, document) natively
|
||||
app.post('/send-media', async (req, res) => {
|
||||
if (!sock || connectionState !== 'connected') {
|
||||
return res.status(503).json({ error: 'Not connected to WhatsApp' });
|
||||
}
|
||||
|
||||
const { chatId, filePath, mediaType, caption, fileName } = req.body;
|
||||
if (!chatId || !filePath) {
|
||||
return res.status(400).json({ error: 'chatId and filePath are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return res.status(404).json({ error: `File not found: ${filePath}` });
|
||||
}
|
||||
|
||||
const buffer = readFileSync(filePath);
|
||||
const ext = filePath.toLowerCase().split('.').pop();
|
||||
const type = mediaType || inferMediaType(ext);
|
||||
let msgPayload;
|
||||
|
||||
switch (type) {
|
||||
case 'image':
|
||||
msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
|
||||
break;
|
||||
case 'video':
|
||||
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
|
||||
break;
|
||||
case 'audio': {
|
||||
// WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
|
||||
// If the caller passes mp3, wav, m4a etc. (e.g. from Edge TTS / NeuTTS),
|
||||
// silently convert to ogg/opus via ffmpeg so ptt is always honoured.
|
||||
let audioBuffer = buffer;
|
||||
let audioExt = ext;
|
||||
const needsConversion = !['ogg', 'opus'].includes(ext);
|
||||
let tmpPath = null;
|
||||
if (needsConversion) {
|
||||
tmpPath = path.join(tmpdir(), `hermes_voice_${randomBytes(6).toString('hex')}.ogg`);
|
||||
try {
|
||||
execSync(
|
||||
`ffmpeg -y -i ${JSON.stringify(filePath)} -ar 48000 -ac 1 -c:a libopus ${JSON.stringify(tmpPath)}`,
|
||||
{ timeout: 30000, stdio: 'pipe' }
|
||||
);
|
||||
audioBuffer = readFileSync(tmpPath);
|
||||
audioExt = 'ogg';
|
||||
} catch (convErr) {
|
||||
// ffmpeg not available or conversion failed — fall back to original format
|
||||
console.warn('[bridge] ffmpeg conversion failed, sending as file attachment:', convErr.message);
|
||||
} finally {
|
||||
try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch (_) {}
|
||||
}
|
||||
}
|
||||
const audioMime = (audioExt === 'ogg' || audioExt === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
|
||||
msgPayload = { audio: audioBuffer, mimetype: audioMime, ptt: audioExt === 'ogg' || audioExt === 'opus' };
|
||||
break;
|
||||
}
|
||||
case 'document':
|
||||
default:
|
||||
msgPayload = {
|
||||
document: buffer,
|
||||
fileName: fileName || path.basename(filePath),
|
||||
caption: caption || undefined,
|
||||
mimetype: MIME_MAP[ext] || 'application/octet-stream',
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const sent = await sendWithTimeout(chatId, msgPayload);
|
||||
|
||||
trackSentMessageId(sent);
|
||||
|
||||
res.json({ success: true, messageId: sent?.key?.id });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Typing indicator
|
||||
app.post('/typing', async (req, res) => {
|
||||
if (!sock || connectionState !== 'connected') {
|
||||
return res.status(503).json({ error: 'Not connected' });
|
||||
}
|
||||
|
||||
const { chatId } = req.body;
|
||||
if (!chatId) return res.status(400).json({ error: 'chatId required' });
|
||||
|
||||
try {
|
||||
await sock.sendPresenceUpdate('composing', chatId);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Chat info
|
||||
app.get('/chat/:id', async (req, res) => {
|
||||
const chatId = req.params.id;
|
||||
const isGroup = chatId.endsWith('@g.us');
|
||||
|
||||
if (isGroup && sock) {
|
||||
try {
|
||||
const metadata = await sock.groupMetadata(chatId);
|
||||
return res.json({
|
||||
name: metadata.subject,
|
||||
isGroup: true,
|
||||
participants: metadata.participants.map(p => p.id),
|
||||
});
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
name: chatId.replace(/@.*/, ''),
|
||||
isGroup,
|
||||
participants: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: connectionState,
|
||||
queueLength: messageQueue.length,
|
||||
uptime: process.uptime(),
|
||||
scriptHash: SCRIPT_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
// Start
|
||||
if (PAIR_ONLY) {
|
||||
// Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
|
||||
console.log('📱 WhatsApp pairing mode');
|
||||
console.log(`📁 Session: ${SESSION_DIR}`);
|
||||
console.log();
|
||||
startSocket();
|
||||
} else {
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
|
||||
console.log(`📁 Session stored in: ${SESSION_DIR}`);
|
||||
if (ALLOWED_USERS.size > 0) {
|
||||
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
|
||||
} else if (WHATSAPP_MODE === 'self-chat') {
|
||||
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
|
||||
} else {
|
||||
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
|
||||
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
|
||||
console.log(` or WHATSAPP_ALLOWED_USERS=* for an explicit open bot.`);
|
||||
}
|
||||
console.log();
|
||||
startSocket();
|
||||
});
|
||||
}
|
||||
+2141
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "hermes-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp bridge for Hermes Agent using Baileys",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
|
||||
"express": "^4.21.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "^7.5.5"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user