mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-19 04:52:06 +00:00
Merge remote-tracking branch 'origin/main' into fix/bundle-size
This commit is contained in:
commit
3197b4de6d
1437 changed files with 219762 additions and 11968 deletions
138
scripts/benchmark_browser_eval.py
Normal file
138
scripts/benchmark_browser_eval.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -81,7 +81,7 @@ def build_catalog() -> dict:
|
|||
def main() -> int:
|
||||
catalog = build_catalog()
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w") as fh:
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as fh:
|
||||
json.dump(catalog, fh, indent=2)
|
||||
fh.write("\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list:
|
|||
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")]
|
||||
skills_sh = [s for s in skills if s["source"] in {"skills.sh", "skills-sh"}]
|
||||
if not skills_sh:
|
||||
return skills
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ def main():
|
|||
}
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w") as f:
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
elapsed = time.time() - overall_start
|
||||
|
|
|
|||
624
scripts/check-windows-footguns.py
Normal file
624
scripts/check-windows-footguns.py
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
#!/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:
|
||||
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:]))
|
||||
|
|
@ -40,7 +40,7 @@ REPO_ROOT = SCRIPT_DIR.parent
|
|||
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"^Cursor(\s+Agent)?$", re.IGNORECASE),
|
||||
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
|
||||
re.compile(r"^dependabot", re.IGNORECASE),
|
||||
re.compile(r"^renovate", re.IGNORECASE),
|
||||
|
|
@ -291,7 +291,7 @@ def check_release_file(release_file, all_contributors):
|
|||
missing: set of handles NOT found in the file
|
||||
"""
|
||||
try:
|
||||
content = Path(release_file).read_text()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -176,9 +176,12 @@ def check_env_vars():
|
|||
|
||||
# Load .env
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
if ENV_FILE.exists():
|
||||
load_dotenv(ENV_FILE)
|
||||
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
|
||||
|
||||
|
|
@ -239,7 +242,7 @@ def check_config(groq_key, eleven_key):
|
|||
if config_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
|
||||
stt_provider = cfg.get("stt", {}).get("provider", "local")
|
||||
|
|
|
|||
|
|
@ -191,19 +191,213 @@ function Test-Python {
|
|||
return $false
|
||||
}
|
||||
|
||||
function Test-Git {
|
||||
function Install-Git {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe
|
||||
which Hermes uses to run shell commands.
|
||||
|
||||
Priority order (deliberately simple — no winget, no registry, no system
|
||||
package manager):
|
||||
1. Existing ``git`` on PATH — use it as-is (the common fast path).
|
||||
2. Download **PortableGit** from the official git-for-windows GitHub
|
||||
release (self-extracting 7z.exe) and unpack it to
|
||||
``%LOCALAPPDATA%\hermes\git`` — never touches system Git, never
|
||||
requires admin, works even on locked-down machines and machines
|
||||
with a broken system Git install.
|
||||
|
||||
**Why PortableGit, not MinGit:** MinGit is the minimal-automation
|
||||
distribution and ships ONLY ``git.exe`` — no bash, no POSIX utilities.
|
||||
Hermes needs ``bash.exe`` to run shell commands. PortableGit is the
|
||||
full Git for Windows distribution without the installer UI; it ships
|
||||
``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``,
|
||||
``ssh``, etc. in ``usr\bin\``.
|
||||
|
||||
We deliberately skip winget because it fails badly when the system Git
|
||||
install is in a half-installed state (partially registered, or uninstall-
|
||||
blocked). Owning the Hermes copy of Git ourselves is predictable and
|
||||
recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git``
|
||||
and re-running this installer fully recovers.
|
||||
|
||||
After install we locate ``bash.exe`` and persist the path in
|
||||
``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh
|
||||
shell without a second PATH refresh.
|
||||
#>
|
||||
Write-Info "Checking Git..."
|
||||
|
||||
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
$version = git --version
|
||||
Write-Success "Git found ($version)"
|
||||
Set-GitBashEnvVar
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Err "Git not found"
|
||||
Write-Info "Please install Git from:"
|
||||
Write-Info " https://git-scm.com/download/win"
|
||||
return $false
|
||||
|
||||
# Download PortableGit into $HermesHome\git. Always works as long as
|
||||
# we can reach github.com — no admin, no winget, no reliance on the
|
||||
# user's possibly-broken system Git install.
|
||||
Write-Info "Git not found — downloading PortableGit to $HermesHome\git\ ..."
|
||||
Write-Info "(no admin rights required; isolated from any system Git install)"
|
||||
|
||||
try {
|
||||
$arch = if ([Environment]::Is64BitOperatingSystem) {
|
||||
# Detect ARM64 vs x64 explicitly; PortableGit ships separate assets.
|
||||
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
|
||||
"arm64"
|
||||
} else {
|
||||
"64-bit"
|
||||
}
|
||||
} else {
|
||||
# PortableGit does not ship a 32-bit build — fall back to MinGit 32-bit
|
||||
# with a warning that bash-based features will be unavailable.
|
||||
"32-bit-mingit"
|
||||
}
|
||||
|
||||
$releaseApi = "https://api.github.com/repos/git-for-windows/git/releases/latest"
|
||||
$release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" }
|
||||
|
||||
if ($arch -eq "32-bit-mingit") {
|
||||
Write-Warn "32-bit Windows detected — PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine."
|
||||
$assetPattern = "MinGit-*-32-bit.zip"
|
||||
$downloadIsZip = $true
|
||||
} elseif ($arch -eq "arm64") {
|
||||
$assetPattern = "PortableGit-*-arm64.7z.exe"
|
||||
$downloadIsZip = $false
|
||||
} else {
|
||||
$assetPattern = "PortableGit-*-64-bit.7z.exe"
|
||||
$downloadIsZip = $false
|
||||
}
|
||||
|
||||
$asset = $release.assets | Where-Object { $_.name -like $assetPattern } | Select-Object -First 1
|
||||
|
||||
if (-not $asset) {
|
||||
throw "Could not find $assetPattern in latest git-for-windows release"
|
||||
}
|
||||
|
||||
$downloadUrl = $asset.browser_download_url
|
||||
$downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" }
|
||||
$tmpFile = "$env:TEMP\$($asset.name)"
|
||||
$gitDir = "$HermesHome\git"
|
||||
|
||||
Write-Info "Downloading $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)..."
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing
|
||||
|
||||
if (Test-Path $gitDir) {
|
||||
Write-Info "Removing previous Git install at $gitDir ..."
|
||||
Remove-Item -Recurse -Force $gitDir
|
||||
}
|
||||
New-Item -ItemType Directory -Path $gitDir -Force | Out-Null
|
||||
|
||||
if ($downloadIsZip) {
|
||||
Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force
|
||||
} else {
|
||||
# PortableGit is a self-extracting 7z archive. Invoke it with
|
||||
# `-o<target> -y` (silent) to extract to $gitDir. No 7z install
|
||||
# required; it's fully self-contained.
|
||||
Write-Info "Extracting PortableGit to $gitDir ..."
|
||||
$extractProc = Start-Process -FilePath $tmpFile `
|
||||
-ArgumentList "-o`"$gitDir`"", "-y" `
|
||||
-NoNewWindow -Wait -PassThru
|
||||
if ($extractProc.ExitCode -ne 0) {
|
||||
throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))"
|
||||
}
|
||||
}
|
||||
Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue
|
||||
|
||||
# PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils)
|
||||
# MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present)
|
||||
$gitExe = "$gitDir\cmd\git.exe"
|
||||
if (-not (Test-Path $gitExe)) {
|
||||
throw "Git extraction did not produce git.exe at $gitExe"
|
||||
}
|
||||
|
||||
# Add to session PATH so the rest of this install run can use git.
|
||||
$env:Path = "$gitDir\cmd;$env:Path"
|
||||
|
||||
# Persist to User PATH so fresh shells see it. PortableGit needs
|
||||
# cmd\ (for git.exe), bin\ (for bash.exe + core tools), and
|
||||
# usr\bin\ (for perl, ssh, curl, and other POSIX coreutils).
|
||||
$newPathEntries = @(
|
||||
"$gitDir\cmd",
|
||||
"$gitDir\bin",
|
||||
"$gitDir\usr\bin"
|
||||
)
|
||||
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
|
||||
$changed = $false
|
||||
foreach ($entry in $newPathEntries) {
|
||||
if ($userPathItems -notcontains $entry) {
|
||||
$userPathItems += $entry
|
||||
$changed = $true
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
|
||||
}
|
||||
|
||||
$version = & $gitExe --version
|
||||
Write-Success "Git $version installed to $gitDir (portable, user-scoped)"
|
||||
Set-GitBashEnvVar
|
||||
return $true
|
||||
} catch {
|
||||
Write-Err "Could not install portable Git: $_"
|
||||
Write-Info ""
|
||||
Write-Info "Fallback: install Git manually from https://git-scm.com/download/win"
|
||||
Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run"
|
||||
Write-Info "shell commands (same as Claude Code and other coding agents)."
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Set-GitBashEnvVar {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Locate ``bash.exe`` from an already-installed Git and persist the path in
|
||||
``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before
|
||||
PATH propagation completes in a newly-spawned shell.
|
||||
#>
|
||||
$candidates = @()
|
||||
|
||||
# Our own portable Git install is ALWAYS checked first, so a broken
|
||||
# system Git doesn't hijack us. If the user had a working system Git
|
||||
# we'd have returned early from Install-Git's fast path and never called
|
||||
# this with a system-Git-only installation anyway.
|
||||
#
|
||||
# Layouts:
|
||||
# PortableGit (our default): $HermesHome\git\bin\bash.exe
|
||||
# MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe
|
||||
$candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary)
|
||||
$candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback
|
||||
|
||||
# git.exe on PATH can tell us where the install root is
|
||||
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
||||
if ($gitCmd) {
|
||||
$gitExe = $gitCmd.Source
|
||||
# Git for Windows (full installer): <root>\cmd\git.exe + <root>\bin\bash.exe
|
||||
# MinGit: <root>\cmd\git.exe + <root>\usr\bin\bash.exe
|
||||
$gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent
|
||||
$candidates += "$gitRoot\bin\bash.exe"
|
||||
$candidates += "$gitRoot\usr\bin\bash.exe"
|
||||
}
|
||||
|
||||
# Standard system install locations as a final fallback. Note:
|
||||
# ProgramFiles(x86) can't be referenced via ${env:...} string interpolation
|
||||
# because of the parens — use [Environment]::GetEnvironmentVariable().
|
||||
$candidates += "${env:ProgramFiles}\Git\bin\bash.exe"
|
||||
$pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)")
|
||||
if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" }
|
||||
$candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe"
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if ($candidate -and (Test-Path $candidate)) {
|
||||
[Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User")
|
||||
$env:HERMES_GIT_BASH_PATH = $candidate
|
||||
Write-Info "Set HERMES_GIT_BASH_PATH=$candidate"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Write-Warn "Could not locate bash.exe — Hermes may not find Git Bash."
|
||||
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
|
||||
}
|
||||
|
||||
function Test-Node {
|
||||
|
|
@ -411,21 +605,71 @@ function Install-SystemPackages {
|
|||
|
||||
function Install-Repository {
|
||||
Write-Info "Installing to $InstallDir..."
|
||||
|
||||
|
||||
$didUpdate = $false
|
||||
|
||||
if (Test-Path $InstallDir) {
|
||||
# Test-Path "$InstallDir\.git" returns True when .git is a file OR a
|
||||
# directory OR a symlink OR a submodule-style gitfile — and also when
|
||||
# it's a broken stub left over from a failed previous install (e.g.
|
||||
# a partial Remove-Item that couldn't delete a locked index.lock).
|
||||
# Validate the repo properly by asking git itself. Two checks
|
||||
# belt-and-braces: rev-parse AND git status. If either fails the
|
||||
# repo is broken and we fall through to a fresh clone.
|
||||
$repoValid = $false
|
||||
if (Test-Path "$InstallDir\.git") {
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
# Reset $LASTEXITCODE before the probe so we don't pick up
|
||||
# a stale 0 from an earlier git call in this session.
|
||||
$global:LASTEXITCODE = 0
|
||||
$revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1
|
||||
$revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true")
|
||||
|
||||
$global:LASTEXITCODE = 0
|
||||
$null = & git -c windows.appendAtomically=false status --short 2>&1
|
||||
$statusOk = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($revParseOk -and $statusOk) {
|
||||
$repoValid = $true
|
||||
}
|
||||
} catch {}
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($repoValid) {
|
||||
Write-Info "Existing installation found, updating..."
|
||||
Push-Location $InstallDir
|
||||
git -c windows.appendAtomically=false fetch origin
|
||||
git -c windows.appendAtomically=false checkout $Branch
|
||||
git -c windows.appendAtomically=false pull origin $Branch
|
||||
Pop-Location
|
||||
try {
|
||||
git -c windows.appendAtomically=false fetch origin
|
||||
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
|
||||
git -c windows.appendAtomically=false checkout $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
|
||||
git -c windows.appendAtomically=false pull origin $Branch
|
||||
if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
$didUpdate = $true
|
||||
} else {
|
||||
Write-Err "Directory exists but is not a git repository: $InstallDir"
|
||||
Write-Info "Remove it or choose a different directory with -InstallDir"
|
||||
throw "Directory exists but is not a git repository: $InstallDir"
|
||||
# Directory exists but isn't a usable git repo. Wipe it and
|
||||
# fall through to a fresh clone. A leftover ``.git`` stub from
|
||||
# a partial uninstall used to lock the installer into the
|
||||
# "update" branch forever, emitting three ``fatal: not a git
|
||||
# repository`` errors and failing with "not in a git directory".
|
||||
Write-Warn "Existing directory at $InstallDir is not a valid git repo — replacing it."
|
||||
try {
|
||||
Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Err "Could not remove $InstallDir : $_"
|
||||
Write-Info "Close any programs that might be using files in $InstallDir (editors,"
|
||||
Write-Info "terminals, running hermes processes) and try again."
|
||||
throw
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (-not $didUpdate) {
|
||||
$cloneSuccess = $false
|
||||
|
||||
# Fix Windows git "copy-fd: write returned: Invalid argument" error.
|
||||
|
|
@ -446,7 +690,7 @@ function Install-Repository {
|
|||
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
|
||||
} catch { }
|
||||
$env:GIT_SSH_COMMAND = $null
|
||||
|
||||
|
||||
if (-not $cloneSuccess) {
|
||||
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
||||
Write-Info "SSH failed, trying HTTPS..."
|
||||
|
|
@ -464,18 +708,18 @@ function Install-Repository {
|
|||
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
|
||||
$zipPath = "$env:TEMP\hermes-agent-$Branch.zip"
|
||||
$extractPath = "$env:TEMP\hermes-agent-extract"
|
||||
|
||||
|
||||
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
|
||||
if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath }
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||
|
||||
|
||||
# GitHub ZIPs extract to repo-branch/ subdirectory
|
||||
$extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1
|
||||
if ($extractedDir) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null
|
||||
Move-Item $extractedDir.FullName $InstallDir -Force
|
||||
Write-Success "Downloaded and extracted"
|
||||
|
||||
|
||||
# Initialize git repo so updates work later
|
||||
Push-Location $InstallDir
|
||||
git -c windows.appendAtomically=false init 2>$null
|
||||
|
|
@ -483,10 +727,10 @@ function Install-Repository {
|
|||
git remote add origin $RepoUrlHttps 2>$null
|
||||
Pop-Location
|
||||
Write-Success "Git repo initialized for future updates"
|
||||
|
||||
|
||||
$cloneSuccess = $true
|
||||
}
|
||||
|
||||
|
||||
# Cleanup temp files
|
||||
Remove-Item -Force $zipPath -ErrorAction SilentlyContinue
|
||||
Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue
|
||||
|
|
@ -499,7 +743,7 @@ function Install-Repository {
|
|||
throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Set per-repo config (harmless if it fails)
|
||||
Push-Location $InstallDir
|
||||
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
|
||||
|
|
@ -513,7 +757,7 @@ function Install-Repository {
|
|||
Write-Success "Submodules ready"
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
|
||||
Write-Success "Repository ready"
|
||||
}
|
||||
|
||||
|
|
@ -550,26 +794,78 @@ function Install-Dependencies {
|
|||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
||||
}
|
||||
|
||||
# Install main package with all extras
|
||||
try {
|
||||
& $UvCmd pip install -e ".[all]" 2>&1 | Out-Null
|
||||
} catch {
|
||||
& $UvCmd pip install -e "." | Out-Null
|
||||
# Install main package. Tiered fallback so a single flaky git+https dep
|
||||
# (atroposlib / tinker in the [rl] extra) doesn't silently drop
|
||||
# dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is
|
||||
# preserved — no Out-Null swallowing — so the user can see what failed.
|
||||
#
|
||||
# Tier 1: [all] — everything, including RL git+https deps (best case).
|
||||
# Tier 2: [core-extras] synthesised locally — all PyPI-only extras we
|
||||
# ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp,
|
||||
# pty, homeassistant, sms, tts-premium, honcho, google, mistral,
|
||||
# bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl]
|
||||
# and [matrix] (linux-only) which are the usual failure culprits.
|
||||
# Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly
|
||||
# believe a user expects `hermes dashboard` / slash commands /
|
||||
# cron / messaging platforms to work out of the box.
|
||||
# Tier 4: bare `.` — last-resort so at least the core CLI launches.
|
||||
$installTiers = @(
|
||||
@{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" },
|
||||
@{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" },
|
||||
@{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" },
|
||||
@{ Name = "core only (no extras)"; Spec = "." }
|
||||
)
|
||||
$installed = $false
|
||||
foreach ($tier in $installTiers) {
|
||||
Write-Info "Trying tier: $($tier.Name) ..."
|
||||
& $UvCmd pip install -e $tier.Spec
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Main package installed ($($tier.Name))"
|
||||
$script:InstalledTier = $tier.Name
|
||||
$installed = $true
|
||||
break
|
||||
}
|
||||
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
|
||||
}
|
||||
if (-not $installed) {
|
||||
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
|
||||
}
|
||||
|
||||
# Verify the dashboard deps specifically — they're the most common thing
|
||||
# users hit and lazy-import errors from `hermes dashboard` are confusing.
|
||||
# If tier 1 failed (the common case), [web] was still picked up by tiers
|
||||
# 2-3; only tier 4 leaves you without it.
|
||||
$pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
|
||||
if (Test-Path $pythonExe) {
|
||||
$webOk = $false
|
||||
try {
|
||||
& $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { $webOk = $true }
|
||||
} catch { }
|
||||
if (-not $webOk) {
|
||||
Write-Warn "fastapi/uvicorn not importable — `hermes dashboard` will not work."
|
||||
Write-Info "Attempting targeted install of [web] extra as last resort..."
|
||||
& $UvCmd pip install -e ".[web]"
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "[web] extra installed; `hermes dashboard` should now work."
|
||||
} else {
|
||||
Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Success "Main package installed"
|
||||
|
||||
# Install optional submodules
|
||||
Write-Info "Installing tinker-atropos (RL training backend)..."
|
||||
# tinker-atropos (RL training) is optional and OFF by default. Matches the
|
||||
# Linux/macOS install.sh behavior. Reasons not to auto-install:
|
||||
# - tinker-atropos/pyproject.toml pulls atroposlib + tinker from git+https
|
||||
# (NousResearch/atropos + thinking-machines-lab/tinker) which can fail on
|
||||
# locked-down networks, flaky DNS, or rate-limited github.com and would
|
||||
# previously kill the whole install mid-flight on Windows.
|
||||
# - It's an RL training submodule, not part of the default agent surface.
|
||||
# Users who don't do RL training never need it.
|
||||
# Users who do want it can run the one-liner we print below.
|
||||
if (Test-Path "tinker-atropos\pyproject.toml") {
|
||||
try {
|
||||
& $UvCmd pip install -e ".\tinker-atropos" 2>&1 | Out-Null
|
||||
Write-Success "tinker-atropos installed"
|
||||
} catch {
|
||||
Write-Warn "tinker-atropos install failed (RL tools may not work)"
|
||||
}
|
||||
} else {
|
||||
Write-Warn "tinker-atropos not found (run: git submodule update --init)"
|
||||
Write-Info "tinker-atropos submodule found — skipping install (optional, for RL training)"
|
||||
Write-Info " To install later: $UvCmd pip install -e `".\tinker-atropos`""
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
|
|
@ -659,13 +955,21 @@ function Copy-ConfigTemplates {
|
|||
Write-Info "~/.hermes/config.yaml already exists, keeping it"
|
||||
}
|
||||
|
||||
# Create SOUL.md if it doesn't exist (global persona file)
|
||||
# Create SOUL.md if it doesn't exist (global persona file).
|
||||
# IMPORTANT: write without a BOM. Windows PowerShell 5.1's
|
||||
# ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark
|
||||
# (the default PS5 behaviour), and Hermes's prompt-injection scanner
|
||||
# flags the BOM as an invisible unicode character and refuses to
|
||||
# load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we
|
||||
# don't control which PowerShell version the user has. Go direct
|
||||
# to .NET with an explicit UTF8Encoding($false) — BOM-free on every
|
||||
# PowerShell version.
|
||||
$soulPath = "$HermesHome\SOUL.md"
|
||||
if (-not (Test-Path $soulPath)) {
|
||||
@"
|
||||
$soulContent = @"
|
||||
# Hermes Agent Persona
|
||||
|
||||
<!--
|
||||
<!--
|
||||
This file defines the agent's personality and tone.
|
||||
The agent will embody whatever you write here.
|
||||
Edit this to customize how Hermes communicates with you.
|
||||
|
|
@ -678,7 +982,9 @@ Examples:
|
|||
This file is loaded fresh each message -- no restart needed.
|
||||
Delete the contents (or this file) to use the default personality.
|
||||
-->
|
||||
"@ | Set-Content -Path $soulPath -Encoding UTF8
|
||||
"@
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
|
||||
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
|
||||
}
|
||||
|
||||
|
|
@ -708,36 +1014,260 @@ function Install-NodeDeps {
|
|||
Write-Info "Skipping Node.js dependencies (Node not installed)"
|
||||
return
|
||||
}
|
||||
|
||||
Push-Location $InstallDir
|
||||
|
||||
if (Test-Path "package.json") {
|
||||
Write-Info "Installing Node.js dependencies (browser tools)..."
|
||||
try {
|
||||
npm install --silent 2>&1 | Out-Null
|
||||
Write-Success "Node.js dependencies installed"
|
||||
} catch {
|
||||
Write-Warn "npm install failed (browser tools may not work)"
|
||||
|
||||
# Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows
|
||||
# ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim).
|
||||
# Get-Command's default ordering picks whichever comes first in PATHEXT,
|
||||
# and on many systems that's .ps1 — but .ps1 requires scripts to be
|
||||
# enabled in PowerShell's execution policy, which most Windows users
|
||||
# don't have (the Restricted / RemoteSigned default blocks unsigned
|
||||
# .ps1 files). .cmd has no such restriction and works on every box.
|
||||
#
|
||||
# Strategy: look next to the npm shim we found and prefer npm.cmd if
|
||||
# it exists in the same directory. Fall back to whatever Get-Command
|
||||
# returned if we can't find a .cmd sibling.
|
||||
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
|
||||
if (-not $npmCmd) {
|
||||
Write-Warn "npm not found on PATH — skipping Node.js dependencies."
|
||||
Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later."
|
||||
return
|
||||
}
|
||||
$npmExe = $npmCmd.Source
|
||||
if ($npmExe -like "*.ps1") {
|
||||
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
|
||||
if (Test-Path $npmCmdSibling) {
|
||||
Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)"
|
||||
$npmExe = $npmCmdSibling
|
||||
} else {
|
||||
Write-Warn "Only npm.ps1 available — install may fail if script execution is disabled."
|
||||
Write-Info " If it fails, either enable PS script execution or install Node via winget."
|
||||
}
|
||||
}
|
||||
|
||||
# Install TUI dependencies
|
||||
|
||||
# Helper: run "npm install" in a given directory and surface the real
|
||||
# error when it fails. Returns $true on success.
|
||||
#
|
||||
# Implementation note: ``Start-Process -FilePath npm.cmd`` fails with
|
||||
# ``%1 is not a valid Win32 application`` on some PowerShell versions
|
||||
# because Start-Process bypasses cmd.exe / PATHEXT and expects a real
|
||||
# PE file. The invocation-operator ``& $npmExe`` routes through the
|
||||
# PowerShell command pipeline which DOES honour .cmd batch shims, so
|
||||
# it works uniformly for npm.cmd, npx.cmd, and bare .exe files.
|
||||
function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
|
||||
Push-Location $installDir
|
||||
try {
|
||||
# Redirect ALL output streams to the log file via 2>&1 and then
|
||||
# ``Tee-Object`` / ``Out-File``. Simpler approach: call npm
|
||||
# with output redirected and inspect $LASTEXITCODE afterwards.
|
||||
& $npmPath install --silent *> $logPath
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -eq 0) {
|
||||
Write-Success "$label dependencies installed"
|
||||
Remove-Item -Force $logPath -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
}
|
||||
Write-Warn "$label npm install failed — exit code $code"
|
||||
if (Test-Path $logPath) {
|
||||
$errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue)
|
||||
if ($errText) {
|
||||
$snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText }
|
||||
Write-Info " npm output:"
|
||||
foreach ($line in $snippet -split "`n") {
|
||||
Write-Host " $line" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Info " Full log: $logPath"
|
||||
}
|
||||
}
|
||||
Write-Info "Run manually later: cd `"$installDir`"; npm install"
|
||||
return $false
|
||||
} catch {
|
||||
Write-Warn "$label npm install could not be launched: $_"
|
||||
return $false
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Browser tools
|
||||
if (Test-Path "$InstallDir\package.json") {
|
||||
Write-Info "Installing Node.js dependencies (browser tools)..."
|
||||
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
|
||||
$browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe
|
||||
|
||||
# Install Playwright Chromium (mirrors scripts/install.sh behaviour for
|
||||
# Linux). Without this, tools/browser_tool.py::check_browser_requirements
|
||||
# returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the
|
||||
# browser_* tools are silently filtered out of the agent's tool schema.
|
||||
# System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by
|
||||
# agent-browser — it expects a Playwright-managed Chromium.
|
||||
if ($browserNpmOk) {
|
||||
Write-Info "Installing browser engine (Playwright Chromium)..."
|
||||
# npx lives next to npm in the same bin dir. Prefer .cmd to dodge
|
||||
# the same execution-policy gotcha that affects npm.ps1 (see above).
|
||||
$npmDir = Split-Path $npmExe -Parent
|
||||
$npxExe = $null
|
||||
foreach ($cand in @("npx.cmd", "npx.exe", "npx")) {
|
||||
$try = Join-Path $npmDir $cand
|
||||
if (Test-Path $try) { $npxExe = $try; break }
|
||||
}
|
||||
if (-not $npxExe) {
|
||||
$npxCmd = Get-Command npx -ErrorAction SilentlyContinue
|
||||
if ($npxCmd) { $npxExe = $npxCmd.Source }
|
||||
}
|
||||
if (-not $npxExe) {
|
||||
Write-Warn "npx not found — cannot install Playwright Chromium."
|
||||
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
|
||||
} else {
|
||||
$pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log"
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
& $npxExe playwright install chromium *> $pwLog
|
||||
$pwCode = $LASTEXITCODE
|
||||
if ($pwCode -eq 0) {
|
||||
Write-Success "Playwright Chromium installed (browser tools ready)"
|
||||
Remove-Item -Force $pwLog -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Warn "Playwright Chromium install failed — exit code $pwCode"
|
||||
Write-Warn "Browser tools will not work until Chromium is installed."
|
||||
if (Test-Path $pwLog) {
|
||||
$pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue
|
||||
if ($pwErr) {
|
||||
$snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr }
|
||||
Write-Info " playwright output:"
|
||||
foreach ($line in $snippet -split "`n") {
|
||||
Write-Host " $line" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Info " Full log: $pwLog"
|
||||
}
|
||||
}
|
||||
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "Playwright Chromium install could not be launched: $_"
|
||||
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# TUI
|
||||
$tuiDir = "$InstallDir\ui-tui"
|
||||
if (Test-Path "$tuiDir\package.json") {
|
||||
Write-Info "Installing TUI dependencies..."
|
||||
Push-Location $tuiDir
|
||||
try {
|
||||
npm install --silent 2>&1 | Out-Null
|
||||
Write-Success "TUI dependencies installed"
|
||||
} catch {
|
||||
Write-Warn "TUI npm install failed (hermes --tui may not work)"
|
||||
}
|
||||
Pop-Location
|
||||
$tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
|
||||
[void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
|
||||
}
|
||||
}
|
||||
|
||||
function Install-PlatformSdks {
|
||||
# Ensure messaging-platform SDKs matching tokens the user added to
|
||||
# ~/.hermes/.env are importable. Two problems this solves:
|
||||
#
|
||||
# 1. The tiered `uv pip install` cascade above can fall through to a
|
||||
# lower tier when the first fails (common when RL git deps choke),
|
||||
# which silently skips some messaging SDKs from [messaging].
|
||||
# 2. `uv` creates the venv without pip. If a messaging SDK ends up
|
||||
# missing, the user can't `pip install python-telegram-bot` to
|
||||
# recover — pip simply isn't in their venv.
|
||||
#
|
||||
# Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then
|
||||
# for each token set in .env, verify the matching SDK imports. If not,
|
||||
# run one targeted `pip install` as last-chance recovery. Keeps fresh
|
||||
# Windows installs from hitting silent "python-telegram-bot not installed"
|
||||
# at runtime.
|
||||
if ($NoVenv) {
|
||||
Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)"
|
||||
return
|
||||
}
|
||||
|
||||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $pythonExe)) {
|
||||
Write-Warn "Skipping platform-SDK verification: $pythonExe not found"
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Pop-Location
|
||||
$envPath = "$HermesHome\.env"
|
||||
if (-not (Test-Path $envPath)) { return }
|
||||
$envLines = Get-Content $envPath -ErrorAction SilentlyContinue
|
||||
|
||||
# Map: env var set in .env -> (import name, pip spec matching [messaging] extra).
|
||||
# Specs mirror pyproject.toml to avoid version drift.
|
||||
$sdkMap = @(
|
||||
@{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" },
|
||||
@{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" },
|
||||
@{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" },
|
||||
@{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" },
|
||||
@{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" }
|
||||
)
|
||||
|
||||
# Which tokens are actually set (not placeholder)?
|
||||
$needed = @()
|
||||
foreach ($sdk in $sdkMap) {
|
||||
$match = $envLines | Where-Object {
|
||||
$_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") `
|
||||
-and $_ -notmatch "your-token-here" `
|
||||
-and $_ -notmatch "^\s*#"
|
||||
}
|
||||
if ($match) { $needed += $sdk }
|
||||
}
|
||||
if ($needed.Count -eq 0) { return }
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Verifying platform SDKs for tokens found in $envPath ..."
|
||||
|
||||
# Verify each SDK's import without triggering side-effect imports.
|
||||
# Quirk: PowerShell wraps non-zero-exit native stderr as a
|
||||
# NativeCommandError that prints even with `2>$null` / `*> $null`
|
||||
# unless we set $ErrorActionPreference to SilentlyContinue for the
|
||||
# span. Save + restore rather than nuking globally.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
try {
|
||||
$missing = @()
|
||||
foreach ($sdk in $needed) {
|
||||
& $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$missing += $sdk
|
||||
Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))"
|
||||
} else {
|
||||
Write-Success " $($sdk.Import) OK"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEAP
|
||||
}
|
||||
if ($missing.Count -eq 0) { return }
|
||||
|
||||
# Bootstrap pip into the venv if it isn't there. `uv` creates venvs
|
||||
# without pip; ensurepip is the stdlib-blessed way to add it.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
try {
|
||||
& $pythonExe -m pip --version 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..."
|
||||
& $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "ensurepip failed — can't auto-install missing SDKs."
|
||||
Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`""
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sdk in $missing) {
|
||||
Write-Info " Installing $($sdk.Spec) ..."
|
||||
& $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success " Installed $($sdk.Import)"
|
||||
} else {
|
||||
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`""
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEAP
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-SetupWizard {
|
||||
|
|
@ -886,13 +1416,35 @@ function Write-Completion {
|
|||
|
||||
function Main {
|
||||
Write-Banner
|
||||
|
||||
|
||||
# Windows refuses to delete a directory any shell is currently cd'd
|
||||
# inside — and silently leaves orphan files behind, which then wedge
|
||||
# "is this a valid git repo" probes on re-install. If the current
|
||||
# working dir is under $InstallDir, step out to the user's home
|
||||
# BEFORE doing anything else. Harmless when the user ran the
|
||||
# installer from somewhere else.
|
||||
try {
|
||||
$currentResolved = (Get-Location).ProviderPath
|
||||
$installResolved = $null
|
||||
if (Test-Path $InstallDir) {
|
||||
$installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath
|
||||
}
|
||||
if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) {
|
||||
Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..."
|
||||
Set-Location $env:USERPROFILE
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" }
|
||||
if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" }
|
||||
if (-not (Test-Git)) { throw "Git not found — install from https://git-scm.com/download/win" }
|
||||
Test-Node # Auto-installs if missing
|
||||
if (-not (Install-Git)) { throw "Git not available and auto-install failed — install from https://git-scm.com/download/win then re-run" }
|
||||
# Test-Node always returns $true (sets $script:HasNode on success, emits a
|
||||
# warning on failure and continues so non-browser installs still work).
|
||||
# Cast to [void] so the bare return value doesn't print "True" to the
|
||||
# console between the "Node found" line and the next installer step.
|
||||
[void](Test-Node)
|
||||
Install-SystemPackages # ripgrep + ffmpeg in one step
|
||||
|
||||
|
||||
Install-Repository
|
||||
Install-Venv
|
||||
Install-Dependencies
|
||||
|
|
@ -900,8 +1452,9 @@ function Main {
|
|||
Set-PathVariable
|
||||
Copy-ConfigTemplates
|
||||
Invoke-SetupWizard
|
||||
Install-PlatformSdks
|
||||
Start-GatewayIfConfigured
|
||||
|
||||
|
||||
Write-Completion
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,23 @@
|
|||
|
||||
set -e
|
||||
|
||||
# Guard against environment leakage when the installer is launched from another
|
||||
# Python-driven tool session (e.g. Hermes terminal tool). A pre-set PYTHONPATH
|
||||
# can force pip/entrypoints to import a different checkout than the one being
|
||||
# installed, which makes fresh installs appear broken or stale.
|
||||
if [ -n "${PYTHONPATH:-}" ]; then
|
||||
echo "⚠ Ignoring inherited PYTHONPATH during install to avoid module shadowing"
|
||||
unset PYTHONPATH
|
||||
fi
|
||||
if [ -n "${PYTHONHOME:-}" ]; then
|
||||
echo "⚠ Ignoring inherited PYTHONHOME during install"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
||||
# wrong user's home directory when running under sudo -u <user>. See #21269.
|
||||
export UV_NO_CONFIG=1
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -602,6 +619,41 @@ install_node() {
|
|||
HAS_NODE=true
|
||||
}
|
||||
|
||||
check_network_prerequisites() {
|
||||
log_info "Checking internet connectivity for package install and web tools..."
|
||||
|
||||
local url
|
||||
local failed=false
|
||||
local checks=("https://pypi.org/simple/" "https://duckduckgo.com/")
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
log_warn "curl not found; skipping connectivity probes"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for url in "${checks[@]}"; do
|
||||
if ! curl -fsSI --max-time 8 "$url" >/dev/null 2>&1; then
|
||||
failed=true
|
||||
log_warn "Could not reach $url"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failed" = false ]; then
|
||||
log_success "Internet connectivity looks good"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_warn "Termux network prerequisites may be incomplete."
|
||||
log_info "Try: pkg install -y ca-certificates curl && pkg update"
|
||||
log_info "If mirrors are stale: termux-change-repo"
|
||||
log_info "Then test: curl -I https://pypi.org/simple/ && curl -I https://duckduckgo.com/"
|
||||
else
|
||||
log_warn "Network checks failed. Hermes install may complete, but web search and dependency downloads can fail."
|
||||
log_info "Verify internet/DNS and retry if pip install fails."
|
||||
fi
|
||||
}
|
||||
|
||||
install_system_packages() {
|
||||
# Detect what's missing
|
||||
HAS_RIPGREP=false
|
||||
|
|
@ -629,7 +681,7 @@ install_system_packages() {
|
|||
# Termux always needs the Android build toolchain for the tested pip path,
|
||||
# even when ripgrep/ffmpeg are already present.
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
local termux_pkgs=(clang rust make pkg-config libffi openssl)
|
||||
local termux_pkgs=(clang rust make pkg-config libffi openssl ca-certificates curl)
|
||||
if [ "$need_ripgrep" = true ]; then
|
||||
termux_pkgs+=("ripgrep")
|
||||
fi
|
||||
|
|
@ -932,17 +984,37 @@ install_deps() {
|
|||
fi
|
||||
|
||||
"$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
|
||||
log_warn "Termux feature install (.[termux]) failed, trying base install..."
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
|
||||
log_error "Package installation failed on Termux."
|
||||
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
|
||||
exit 1
|
||||
|
||||
# On Android, psutil's setup.py rejects sys.platform == 'android' before
|
||||
# it ever invokes the C build, so the next pip install would fail at
|
||||
# "platform android is not supported". Prebuild psutil from the official
|
||||
# sdist with a one-line marker patch (Linux source path is fine on
|
||||
# Android). Stopgap until psutil#2762 ships upstream.
|
||||
if "$PIP_PYTHON" -c 'import sys; raise SystemExit(0 if sys.platform == "android" else 1)' 2>/dev/null; then
|
||||
log_info "Android Python detected: prebuilding psutil compatibility shim..."
|
||||
if ! "$PIP_PYTHON" "$INSTALL_DIR/scripts/install_psutil_android.py" --pip "$PIP_PYTHON -m pip"; then
|
||||
log_warn "psutil Android prebuild failed — package install will likely fail next."
|
||||
log_info "Workaround: manually rerun 'python scripts/install_psutil_android.py' once your toolchain is set up."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try the broad Termux profile first (best-effort "install all" for Android),
|
||||
# then fall back to the conservative Termux baseline, then base package.
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.[termux-all]' -c constraints-termux.txt; then
|
||||
log_warn "Termux broad profile (.[termux-all]) failed, trying baseline Termux profile..."
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
|
||||
log_warn "Termux baseline profile (.[termux]) failed, trying base install..."
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
|
||||
log_error "Package installation failed on Termux."
|
||||
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl ca-certificates curl"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
log_info "Termux note: matrix e2ee and local faster-whisper extras are excluded from .[termux-all] due to upstream Android wheel/toolchain blockers."
|
||||
log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
|
||||
|
||||
if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
|
||||
|
|
@ -1034,7 +1106,7 @@ setup_path() {
|
|||
log_warn "hermes entry point not found at $HERMES_BIN"
|
||||
log_info "This usually means the pip install didn't complete successfully."
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
|
||||
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt"
|
||||
else
|
||||
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
fi
|
||||
|
|
@ -1047,9 +1119,17 @@ setup_path() {
|
|||
command_link_display_dir="$(get_command_link_display_dir)"
|
||||
|
||||
# Create a user-facing shim for the hermes command.
|
||||
# We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars
|
||||
# can't make this launcher import modules from another checkout.
|
||||
mkdir -p "$command_link_dir"
|
||||
ln -sf "$HERMES_BIN" "$command_link_dir/hermes"
|
||||
log_success "Symlinked hermes → $command_link_display_dir/hermes"
|
||||
cat > "$command_link_dir/hermes" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec "$HERMES_BIN" "\$@"
|
||||
EOF
|
||||
chmod +x "$command_link_dir/hermes"
|
||||
log_success "Installed hermes launcher → $command_link_display_dir/hermes"
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
export PATH="$command_link_dir:$PATH"
|
||||
|
|
@ -1549,6 +1629,7 @@ main() {
|
|||
check_python
|
||||
check_git
|
||||
check_node
|
||||
check_network_prerequisites
|
||||
install_system_packages
|
||||
|
||||
clone_repo
|
||||
|
|
|
|||
117
scripts/install_psutil_android.py
Executable file
117
scripts/install_psutil_android.py
Executable file
|
|
@ -0,0 +1,117 @@
|
|||
#!/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 tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Pin a version we know patches cleanly. Update when a newer psutil
|
||||
# changes the marker line shape and we need to follow upstream.
|
||||
PSUTIL_URL = (
|
||||
"https://files.pythonhosted.org/packages/aa/c6/"
|
||||
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
|
||||
"psutil-7.2.2.tar.gz"
|
||||
)
|
||||
|
||||
MARKER = 'LINUX = sys.platform.startswith("linux")'
|
||||
REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
|
||||
|
||||
|
||||
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)
|
||||
with tarfile.open(archive) as tar:
|
||||
tar.extractall(tmp_path)
|
||||
|
||||
try:
|
||||
src_root = next(
|
||||
p for p in tmp_path.iterdir()
|
||||
if p.is_dir() and p.name.startswith("psutil-")
|
||||
)
|
||||
except StopIteration:
|
||||
sys.exit("psutil sdist did not contain a psutil-* directory")
|
||||
|
||||
common_py = src_root / "psutil" / "_common.py"
|
||||
content = common_py.read_text(encoding="utf-8")
|
||||
if MARKER not in content:
|
||||
sys.exit(
|
||||
"psutil Android compatibility patch marker not found — "
|
||||
"upstream may have changed the LINUX detection line. "
|
||||
"Update MARKER/REPLACEMENT in this script."
|
||||
)
|
||||
common_py.write_text(content.replace(MARKER, REPLACEMENT), encoding="utf-8")
|
||||
|
||||
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())
|
||||
81
scripts/keystroke_diagnostic.py
Normal file
81
scripts/keystroke_diagnostic.py
Normal file
|
|
@ -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()
|
||||
207
scripts/lint_diff.py
Executable file
207
scripts/lint_diff.py
Executable file
|
|
@ -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())
|
||||
|
|
@ -35,13 +35,21 @@ 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(Path.home() / ".hermes" / "perf.log")))
|
||||
DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db"
|
||||
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.
|
||||
|
|
@ -106,7 +114,7 @@ def summarize(log: Path, since_ts_ms: int) -> 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().splitlines():
|
||||
for line in log.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
|
@ -338,7 +346,7 @@ def key_metrics(data: dict[str, Any]) -> dict[str, float]:
|
|||
metrics["backpressure_frames"] = bp
|
||||
|
||||
if react:
|
||||
for pid in set(e["id"] for e in 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)
|
||||
|
|
@ -355,7 +363,7 @@ def format_diff(before: dict[str, float], after: dict[str, float]) -> str:
|
|||
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
|
||||
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,
|
||||
|
|
@ -452,7 +460,7 @@ def run_once(args: argparse.Namespace) -> dict[str, Any]:
|
|||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only script (imports pty at top)
|
||||
os.waitpid(pid, 0)
|
||||
except (ProcessLookupError, ChildProcessError):
|
||||
pass
|
||||
|
|
@ -500,7 +508,7 @@ def main() -> int:
|
|||
|
||||
if args.save:
|
||||
path = Path(f"/tmp/perf-{args.save}.json")
|
||||
path.write_text(json.dumps(metrics, indent=2))
|
||||
path.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
|
||||
print(f"\n• saved: {path}")
|
||||
|
||||
if args.compare:
|
||||
|
|
|
|||
|
|
@ -41,14 +41,141 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
|
|||
AUTHOR_MAP = {
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"0x.badfriend@gmail.com": "discodirector",
|
||||
"altriatree@gmail.com": "TruaShamu",
|
||||
"m@mobrienv.dev": "mikeyobrien",
|
||||
"qiyin.zuo@pcitc.com": "qiyin-code",
|
||||
"oleksii.lisikh@gmail.com": "olisikh",
|
||||
"leone.parise@gmail.com": "leoneparise",
|
||||
"buraysandro9@gmail.com": "ygd58",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"piyushvp1@gmail.com": "thelumiereguy",
|
||||
"421774554@qq.com": "wuli666",
|
||||
"harish.kukreja@gmail.com": "counterposition",
|
||||
"1046611633@qq.com": "zhengyn0001",
|
||||
"cleo@edaphic.xyz": "curiouscleo",
|
||||
"hirokazu.ogawa@kwansei.ac.jp": "hrkzogw",
|
||||
"datapod.k@gmail.com": "dandacompany",
|
||||
"treydong.zh@gmail.com": "TreyDong",
|
||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||
"hugosequier@gmail.com": "Hugo-SEQUIER",
|
||||
"128259593+Gutslabs@users.noreply.github.com": "Gutslabs",
|
||||
"50326054+nocturnum91@users.noreply.github.com": "nocturnum91",
|
||||
"223003280+Abd0r@users.noreply.github.com": "Abd0r",
|
||||
"HuangYuChuh@users.noreply.github.com": "HuangYuChuh",
|
||||
"aaronwong1989@gmail.com": "hrygo",
|
||||
"26729613+hrygo@users.noreply.github.com": "hrygo",
|
||||
"erenkar950@gmail.com": "eren-karakus0",
|
||||
"aubrey@freeman-wisco.com": "Freeman-Consulting",
|
||||
"don.rhm@gmail.com": "rahimsais",
|
||||
"40222899+rahimsais@users.noreply.github.com": "rahimsais",
|
||||
"alfred@Alfreds-Mac-mini.local": "NivOO5",
|
||||
"231191380+NivOO5@users.noreply.github.com": "NivOO5",
|
||||
"jameshuang@gmail.com": "kjames2001",
|
||||
"62420081+kjames2001@users.noreply.github.com": "kjames2001",
|
||||
"132184373+wilsen0@users.noreply.github.com": "wilsen0",
|
||||
"ra2157218@gmail.com": "Abd0r",
|
||||
"abdielv@proton.me": "AJV20",
|
||||
"mason@growagainorchids.com": "masonjames",
|
||||
"ytchen0719@gmail.com": "liquidchen",
|
||||
"am@studio1.tailb672fe.ts.net": "subtract0",
|
||||
"mike@grossmann.at": "ReqX",
|
||||
"axmaiqiu@gmail.com": "qWaitCrypto",
|
||||
"44045911+kidonng@users.noreply.github.com": "kidonng",
|
||||
"daniellsmarta@gmail.com": "DanielLSM",
|
||||
"264291321+v1b3coder@users.noreply.github.com": "v1b3coder",
|
||||
"silverchris@foxmail.com": "ming1523",
|
||||
"maksesipov@gmail.com": "Qwinty",
|
||||
"denisamania@gmail.com": "CalmProton",
|
||||
"308068+mbac@users.noreply.github.com": "mbac",
|
||||
"ninso112@proton.me": "Ninso112",
|
||||
"wesleysimplicio@live.com": "wesleysimplicio",
|
||||
"matthew.dean.cater@gmail.com": "SiliconID",
|
||||
"xieniu@proton.me": "xieNniu",
|
||||
"rw8143a@american.edu": "wali-reheman",
|
||||
"egitimviscara@gmail.com": "uzunkuyruk",
|
||||
"zhekinmaksim@gmail.com": "Zhekinmaksim",
|
||||
"obafemiferanmi1999@gmail.com": "KvnGz",
|
||||
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
|
||||
"aludwin+gh@gmail.com": "adamludwin",
|
||||
"ngusev@astralinux.ru": "NikolayGusev-astra",
|
||||
"liuguangyong201@hellobike.com": "liuguangyong93",
|
||||
"2093036+exiao@users.noreply.github.com": "exiao",
|
||||
"20nik.nosov21@gmail.com": "nik1t7n",
|
||||
"thunderggnn@gmail.com": "ggnnggez",
|
||||
"haozhe4547@gmail.com": "ehz0ah",
|
||||
"kevyan1998@gmail.com": "kyan12",
|
||||
"rylen.anil@gmail.com": "rylena",
|
||||
"godnanijatin@gmail.com": "jatingodnani",
|
||||
"252811164+adybag14-cyber@users.noreply.github.com": "adybag14-cyber",
|
||||
"14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",
|
||||
"112875006+donramon77@users.noreply.github.com": "donramon77",
|
||||
"657290301@qq.com": "IMHaoyan",
|
||||
"revar@users.noreply.github.com": "revaraver",
|
||||
"dengtaoyuan@dengtaoyuandeMac-mini.local": "dengtaoyuan450-a11y",
|
||||
"ysfalweshcan@gmail.com": "Junass1",
|
||||
"bartokmagic@proton.me": "Bartok9",
|
||||
"androidhtml@yandex.com": "hllqkb",
|
||||
"25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
|
||||
"jonathan.troyer@overmatch.com": "JTroyerOvermatch",
|
||||
"harryykyle1@gmail.com": "hharry11",
|
||||
"wysie@users.noreply.github.com": "wysie",
|
||||
"jkausel@gmail.com": "jkausel-ai",
|
||||
"e.silacandmr@gmail.com": "Es1la",
|
||||
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
|
||||
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
|
||||
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
|
||||
"sandrohub013@gmail.com": "SandroHub013",
|
||||
"maciekczech@users.noreply.github.com": "maciekczech",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"zjtan1@gmail.com": "zeejaytan",
|
||||
"asslaenn5@gmail.com": "Aslaaen",
|
||||
"trae.anderson17@icloud.com": "Tkander1715",
|
||||
"beardthelion@users.noreply.github.com": "beardthelion",
|
||||
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
|
||||
"leon@agentlinker.ai": "agentlinker",
|
||||
"santoshhumagain1887@gmail.com": "npmisantosh",
|
||||
"novax635@gmail.com": "novax635",
|
||||
"krionex1@gmail.com": "Krionex",
|
||||
"rxdxxxx@users.noreply.github.com": "rxdxxxx",
|
||||
"ma.haohao2@xydigit.com": "MaHaoHao-ch",
|
||||
"29756950+revaraver@users.noreply.github.com": "revaraver",
|
||||
"nexus@eptic.me": "TheEpTic",
|
||||
"74554762+wmagev@users.noreply.github.com": "wmagev",
|
||||
"ashermorse@icloud.com": "ashermorse",
|
||||
"happy5318@users.noreply.github.com": "happy5318",
|
||||
"anatoliygranichenko@gmail.com": "wabrent",
|
||||
"cash.williams@acquia.com": "CashWilliams",
|
||||
"chengoak@users.noreply.github.com": "chengoak",
|
||||
"mrhanoi@outlook.com": "qxxaa",
|
||||
"guillaume.meyer@outlook.com": "guillaumemeyer",
|
||||
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
|
||||
"lazycat.manatee@gmail.com": "manateelazycat",
|
||||
"bzarnitz13@gmail.com": "Beandon13",
|
||||
"tony@tonysimons.dev": "asimons81",
|
||||
"jetha@google.com": "jethac",
|
||||
"jani@0xhoneyjar.xyz": "deep-name",
|
||||
# LINE messaging plugin (synthesis PR)
|
||||
"32443648+leepoweii@users.noreply.github.com": "leepoweii",
|
||||
"openclaw@liyangchen.me": "liyoungc",
|
||||
"charles@perng.com": "perng",
|
||||
"soichiro0111.dev@gmail.com": "soichiyo",
|
||||
"0xde@pieverse.io": "David-0x221Eight",
|
||||
"77736378+David-0x221Eight@users.noreply.github.com": "David-0x221Eight",
|
||||
"74749461+yuga-hashimoto@users.noreply.github.com": "yuga-hashimoto",
|
||||
"xiangyong@zspace.cn": "CES4751",
|
||||
"harish.kukreja@gmail.com": "counterposition",
|
||||
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
|
||||
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
|
||||
"15558128926@qq.com": "xsfX20",
|
||||
"binhnt.ht.92@gmail.com": "binhnt92",
|
||||
"johnny@Jons-MBA-M4.local": "acesjohnny",
|
||||
"1581133593@qq.com": "liu-collab",
|
||||
"haidaoe@proton.me": "haidao1919",
|
||||
"50561768+zhanggttry@users.noreply.github.com": "zhanggttry",
|
||||
"formulahendry@gmail.com": "formulahendry",
|
||||
"93757150+bogerman1@users.noreply.github.com": "bogerman1",
|
||||
"132852777+rob-maron@users.noreply.github.com": "rob-maron",
|
||||
# Matrix parity salvage batch (April 2026)
|
||||
"sr@samirusani": "samrusani",
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
|
|
@ -57,12 +184,18 @@ AUTHOR_MAP = {
|
|||
"luwinyang@deepseek.com": "lsdsjy",
|
||||
"season.saw@gmail.com": "season179",
|
||||
"heathley@Heathley-MacBook-Air.local": "heathley",
|
||||
"maliyldzhn@gmail.com": "heathley",
|
||||
"vlad19@gmail.com": "dandaka",
|
||||
"adamrummer@gmail.com": "cyclingwithelephants",
|
||||
# Temporary tool-progress cleanup salvage (May 2026)
|
||||
"Mrcharlesiv@gmail.com": "mrcharlesiv",
|
||||
"nbot@liizfq.top": "liizfq",
|
||||
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
|
||||
"dejie.guo@gmail.com": "JayGwod",
|
||||
"133716830+0xKingBack@users.noreply.github.com": "0xKingBack",
|
||||
"daixin1204@gmail.com": "SimbaKingjoe",
|
||||
"maxence@groine.fr": "MaxyMoos",
|
||||
"61830395+leprincep35700@users.noreply.github.com": "leprincep35700",
|
||||
# OpenViking viking_read salvage (April 2026)
|
||||
"hitesh@gmail.com": "htsh",
|
||||
"pty819@outlook.com": "pty819",
|
||||
|
|
@ -71,17 +204,33 @@ AUTHOR_MAP = {
|
|||
# Curator fixes (Apr 30 2026)
|
||||
"yuxiangl490@gmail.com": "y0shua1ee",
|
||||
"manmit0x@gmail.com": "0xDevNinja",
|
||||
"stevekelly622@gmail.com": "steezkelly",
|
||||
"momowind@gmail.com": "momowind",
|
||||
"clockwork-codex@users.noreply.github.com": "misery-hl",
|
||||
"207811921+misery-hl@users.noreply.github.com": "misery-hl",
|
||||
"20nik.nosov21@gmail.com": "nik1t7n",
|
||||
"90299797+nik1t7n@users.noreply.github.com": "nik1t7n",
|
||||
"suncokret@protonmail.com": "suncokret12",
|
||||
"mio.imoto.ai@gmail.com": "mioimotoai-lgtm",
|
||||
"aamirjawaid@microsoft.com": "heyitsaamir",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
"thomasjhon6666@gmail.com": "ThomassJonax",
|
||||
"focusflow.app.help@gmail.com": "yes999zc",
|
||||
"rob@atlas.lan": "rmoen",
|
||||
# Slack ephemeral slash-ack salvage (May 2026)
|
||||
"probepark@users.noreply.github.com": "probepark",
|
||||
# Slack batch salvage (May 2026)
|
||||
"280484231+prive-fe-bot@users.noreply.github.com": "priveperfumes",
|
||||
"amr@ghanem.sa": "amroessam",
|
||||
"paperlantern.agent@gmail.com": "Hinotoi-agent",
|
||||
"valda@underscore.jp": "valda",
|
||||
"162235745+0z1-ghb@users.noreply.github.com": "0z1-ghb",
|
||||
"yes999zc@163.com": "yes999zc",
|
||||
"343873859@qq.com": "DrStrangerUJN",
|
||||
"252818347@qq.com": "hejuntt1014",
|
||||
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
|
||||
"beliefanx@gmail.com": "BeliefanX",
|
||||
"changchun989@proton.me": "changchun989",
|
||||
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
||||
"44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
|
||||
"steve.westerhouse@origami-analytics.com": "westers",
|
||||
|
|
@ -92,6 +241,8 @@ AUTHOR_MAP = {
|
|||
"130918800+devorun@users.noreply.github.com": "devorun",
|
||||
"surat.s@itm.kmutnb.ac.th": "beesrsj2500",
|
||||
"beesr@bee.localdomain": "beesrsj2500",
|
||||
"mind-dragon@nous.research": "Mind-Dragon",
|
||||
"juntingpublic@gmail.com": "JustinUssuri",
|
||||
"mtf201013@gmail.com": "ma-pony",
|
||||
"sonoyuncudmr@gmail.com": "Sonoyunchu",
|
||||
"43525405+yatesjalex@users.noreply.github.com": "yatesjalex",
|
||||
|
|
@ -100,11 +251,18 @@ AUTHOR_MAP = {
|
|||
"web3blind@users.noreply.github.com": "web3blind",
|
||||
"julia@alexland.us": "alexg0bot",
|
||||
"christian@scheid.tech": "scheidti",
|
||||
# Moonshot schema anyOf+enum salvage (May 2026)
|
||||
"git@local.invalid": "hendrixfreire",
|
||||
"1060770+benjaminsehl@users.noreply.github.com": "benjaminsehl",
|
||||
"nerijusn76@gmail.com": "Nerijusas",
|
||||
# Compaction salvage batch (May 2026)
|
||||
"MacroAnarchy@users.noreply.github.com": "MacroAnarchy",
|
||||
"itonov@proton.me": "Ito-69",
|
||||
"glesstech@gmail.com": "georgeglessner",
|
||||
"maxim.smetanin@gmail.com": "maxims-oss",
|
||||
# Codex Spark restoration salvage (May 2026)
|
||||
"olegwn@gmail.com": "nederev",
|
||||
"vesper@askclaw.dev": "askclaw-vesper",
|
||||
"nazirulhafiy@gmail.com": "nazirulhafiy",
|
||||
"CREWorx@users.noreply.github.com": "BadTechBandit",
|
||||
"yoimexex@gmail.com": "Yoimex",
|
||||
|
|
@ -112,6 +270,7 @@ AUTHOR_MAP = {
|
|||
"foxion37@gmail.com": "foxion37",
|
||||
"bloodcarter@gmail.com": "bloodcarter",
|
||||
"scott@scotttrinh.com": "scotttrinh",
|
||||
"quocanh261997@gmail.com": "quocanh261997",
|
||||
# contributors (from noreply pattern)
|
||||
"david.vv@icloud.com": "davidvv",
|
||||
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
||||
|
|
@ -162,11 +321,14 @@ AUTHOR_MAP = {
|
|||
"104278804+Sertug17@users.noreply.github.com": "Sertug17",
|
||||
"112503481+caentzminger@users.noreply.github.com": "caentzminger",
|
||||
"258577966+voidborne-d@users.noreply.github.com": "voidborne-d",
|
||||
"3820588+ddupont808@users.noreply.github.com": "ddupont808",
|
||||
"liusway405@gmail.com": "voidborne-d",
|
||||
"xydarcher@uestc.edu.cn": "Readon",
|
||||
"sir_even@icloud.com": "sirEven",
|
||||
"36056348+sirEven@users.noreply.github.com": "sirEven",
|
||||
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
|
||||
"jezzahehn@gmail.com": "JezzaHehn",
|
||||
"barnacleboy.jezzahehn@agentmail.to": "JezzaHehn",
|
||||
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
|
||||
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
||||
"270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
|
||||
|
|
@ -182,6 +344,7 @@ AUTHOR_MAP = {
|
|||
"nish3451@users.noreply.github.com": "nish3451",
|
||||
"Mibayy@users.noreply.github.com": "Mibayy",
|
||||
"mibayy@users.noreply.github.com": "Mibayy",
|
||||
"mibay@clawhub.io": "Mibayy",
|
||||
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
|
||||
"lzy.dev@gmail.com": "zhiyanliu",
|
||||
"me@janstepanovsky.cz": "hhhonzik",
|
||||
|
|
@ -236,6 +399,7 @@ AUTHOR_MAP = {
|
|||
"hakanerten02@hotmail.com": "teyrebaz33",
|
||||
"linux2010@users.noreply.github.com": "Linux2010",
|
||||
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
|
||||
"coktinbaran5@gmail.com": "elmatadorgh",
|
||||
"alexazzjjtt@163.com": "alexzhu0",
|
||||
"1180176+Swift42@users.noreply.github.com": "Swift42",
|
||||
"ruzzgarcn@gmail.com": "Ruzzgar",
|
||||
|
|
@ -292,6 +456,7 @@ AUTHOR_MAP = {
|
|||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
|
||||
"mgparkprint@gmail.com": "vlwkaos",
|
||||
"1317078257maroon@gmail.com": "Oxidane-bot",
|
||||
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
||||
"LyleLengyel@gmail.com": "mcndjxlefnd",
|
||||
"wangshengyang2004@163.com": "Wangshengyang2004",
|
||||
|
|
@ -314,11 +479,14 @@ AUTHOR_MAP = {
|
|||
"camilo@tekelala.com": "tekelala",
|
||||
"vincentcharlebois@gmail.com": "vincentcharlebois",
|
||||
"aryan@synvoid.com": "aryansingh",
|
||||
"johnsonblake1@gmail.com": "blakejohnson",
|
||||
"johnsonblake1@gmail.com": "voteblake",
|
||||
"hcn518@gmail.com": "pedh",
|
||||
"haileymarshall005@gmail.com": "haileymarshall",
|
||||
"bennet.yr.wang@gmail.com": "BennetYrWang",
|
||||
"greer.guthrie@gmail.com": "g-guthrie",
|
||||
"kennyx102@gmail.com": "bobashopcashier",
|
||||
"77253505+bobashopcashier@users.noreply.github.com": "bobashopcashier",
|
||||
"25355950+megastary@users.noreply.github.com": "megastary", # PR #18325
|
||||
"shokatalishaikh95@gmail.com": "areu01or00",
|
||||
"bryan@intertwinesys.com": "bryanyoung",
|
||||
"christo.mitov@gmail.com": "christomitov",
|
||||
|
|
@ -330,6 +498,7 @@ AUTHOR_MAP = {
|
|||
"stefan@dimagents.ai": "dimitrovi",
|
||||
"hermes@noushq.ai": "benbarclay",
|
||||
"chinmingcock@gmail.com": "ChimingLiu",
|
||||
"allard.quek@singtel.com": "AllardQuek",
|
||||
"openclaw@sparklab.ai": "openclaw",
|
||||
"semihcvlk53@gmail.com": "Himess",
|
||||
"erenkar950@gmail.com": "erenkarakus",
|
||||
|
|
@ -346,11 +515,20 @@ AUTHOR_MAP = {
|
|||
"m@statecraft.systems": "mbierling",
|
||||
"balyan.sid@gmail.com": "alt-glitch",
|
||||
"52913345+alt-glitch@users.noreply.github.com": "alt-glitch",
|
||||
"oluwadareab12@gmail.com": "bennytimz",
|
||||
"oluwadareab12@gmail.com": "oluwadareab12",
|
||||
"simon@simonmarcus.org": "simon-marcus",
|
||||
"xowiekk@gmail.com": "Xowiek",
|
||||
"1243352777@qq.com": "zons-zhaozhy",
|
||||
"e.silacandmr@gmail.com": "Es1la",
|
||||
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
|
||||
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
|
||||
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
|
||||
"sandrohub013@gmail.com": "SandroHub013",
|
||||
"maciekczech@users.noreply.github.com": "maciekczech",
|
||||
"h3057183414@gmail.com": "CoreyNoDream",
|
||||
"franksong2702@gmail.com": "franksong2702",
|
||||
"673088860@qq.com": "ambition0802",
|
||||
"beibei1988@proton.me": "beibi9966",
|
||||
# ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
|
||||
# crossref, and GH contributor list matching (April 2026 audit) ──
|
||||
"1115117931@qq.com": "aaronagent",
|
||||
|
|
@ -422,6 +600,8 @@ AUTHOR_MAP = {
|
|||
"ogzerber@users.noreply.github.com": "ogzerber",
|
||||
"cola-runner@users.noreply.github.com": "cola-runner",
|
||||
"ygd58@users.noreply.github.com": "ygd58",
|
||||
"45554392+warabe1122@users.noreply.github.com": "warabe1122",
|
||||
"187001140+willy-scr@users.noreply.github.com": "willy-scr",
|
||||
"vominh1919@users.noreply.github.com": "vominh1919",
|
||||
"iamagenius00@users.noreply.github.com": "iamagenius00",
|
||||
"9219265+cresslank@users.noreply.github.com": "cresslank",
|
||||
|
|
@ -430,6 +610,7 @@ AUTHOR_MAP = {
|
|||
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
||||
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"cine.dreamer.one@gmail.com": "LeonSGP43",
|
||||
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
|
||||
"niyant@spicefi.xyz": "spniyant",
|
||||
"olafthiele@gmail.com": "olafthiele",
|
||||
|
|
@ -446,6 +627,7 @@ AUTHOR_MAP = {
|
|||
"taosiyuan163@153.com": "taosiyuan163",
|
||||
"tesseracttars@gmail.com": "tesseracttars-creator",
|
||||
"tianliangjay@gmail.com": "xingkongliang",
|
||||
"1317078257maroon@gmail.com": "Oxidane-bot",
|
||||
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
||||
"LyleLengyel@gmail.com": "mcndjxlefnd",
|
||||
"unayung@gmail.com": "Unayung",
|
||||
|
|
@ -478,6 +660,11 @@ AUTHOR_MAP = {
|
|||
"michel.belleau@malaiwah.com": "malaiwah",
|
||||
"gnanasekaran.sekareee@gmail.com": "gnanam1990",
|
||||
"jz.pentest@gmail.com": "0xyg3n",
|
||||
"7093928+0xyg3n@users.noreply.github.com": "0xyg3n",
|
||||
"nftpoetrist@gmail.com": "nftpoetrist", # PR #18982
|
||||
"millerc79@users.noreply.github.com": "millerc79", # PR #19033
|
||||
"hermes@example.com": "shellybotmoyer", # PR #18915 (bot-committed)
|
||||
"exx@example.com": "exxmen", # PR #19555
|
||||
"hypnosis.mda@gmail.com": "Hypn0sis",
|
||||
"ywt000818@gmail.com": "OwenYWT",
|
||||
"dhandhalyabhavik@gmail.com": "v1k22",
|
||||
|
|
@ -491,12 +678,16 @@ AUTHOR_MAP = {
|
|||
"hubin_ll@qq.com": "LLQWQ",
|
||||
"memosr_email@gmail.com": "memosr",
|
||||
"jperlow@gmail.com": "perlowja",
|
||||
"jasonpette1783@gmail.com": "web-dev0521",
|
||||
"bjianhang@gmail.com": "bjianhang",
|
||||
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
|
||||
"harryplusplus@gmail.com": "harryplusplus",
|
||||
"anthhub@163.com": "anthhub",
|
||||
"allard.quek@singtel.com": "AllardQuek",
|
||||
"shenuu@gmail.com": "shenuu",
|
||||
"xiayh17@gmail.com": "xiayh0107",
|
||||
"zhujianxyz@gmail.com": "opriz",
|
||||
"tuancanhnguyen706@gmail.com": "xxxigm",
|
||||
"asurla@nvidia.com": "anniesurla",
|
||||
"limkuan24@gmail.com": "WideLee",
|
||||
"aviralarora002@gmail.com": "AviArora02-commits",
|
||||
|
|
@ -536,6 +727,16 @@ AUTHOR_MAP = {
|
|||
"chenb19870707@gmail.com": "ms-alan",
|
||||
"276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123",
|
||||
"22549957+li0near@users.noreply.github.com": "li0near",
|
||||
"guoyu801@gmail.com": "li0near",
|
||||
"ty@tmrtn.com": "tymrtn",
|
||||
"elitovsky@zenproject.net": "kallidean",
|
||||
"5463986+baocin@users.noreply.github.com": "baocin",
|
||||
"107296821+princepal9120@users.noreply.github.com": "princepal9120",
|
||||
"gufo0125@gmail.com": "guglielmofonda",
|
||||
"102474490+yehuosi@users.noreply.github.com": "yehuosi",
|
||||
"yehuosi@users.noreply.github.com": "yehuosi",
|
||||
"31932854+jelrod27@users.noreply.github.com": "jelrod27",
|
||||
"11262660+konsisumer@users.noreply.github.com": "konsisumer",
|
||||
"23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi",
|
||||
"haimu0x0@proton.me": "haimu0x",
|
||||
"abdelmajidnidnasser1@gmail.com": "NIDNASSER-Abdelmajid",
|
||||
|
|
@ -557,6 +758,7 @@ AUTHOR_MAP = {
|
|||
"mike@mikewaters.net": "mikewaters",
|
||||
"65117428+WadydX@users.noreply.github.com": "WadydX",
|
||||
"216480837+isaachuangGMICLOUD@users.noreply.github.com": "isaachuangGMICLOUD",
|
||||
"isaac.h@gmicloud.ai": "isaachuangGMICLOUD",
|
||||
"nukuom976228@gmail.com": "hsy5571616",
|
||||
"11462216+Nan93@users.noreply.github.com": "Nan93",
|
||||
"l973401489@126.com": "zhouxiaoya12",
|
||||
|
|
@ -587,6 +789,101 @@ AUTHOR_MAP = {
|
|||
"2114364329@qq.com": "cuyua9",
|
||||
"2557058999@qq.com": "Disaster-Terminator",
|
||||
"cine.dreamer.one@gmail.com": "LeonSGP43",
|
||||
"zyprothh@gmail.com": "Zyproth",
|
||||
"amitgaur@gmail.com": "amitgaur",
|
||||
"albuquerque.abner@gmail.com": "mrbob-git",
|
||||
"kiala@users.noreply.github.com": "kiala9",
|
||||
"alanxchen@gmail.com": "alanxchen85",
|
||||
"clawbot@clawbots-Mac-mini.local": "John-tip",
|
||||
"der@konsi.org": "konsisumer",
|
||||
"cirwel@The-CIRWEL-Group.local": "CIRWEL",
|
||||
"molvikar8@gmail.com": "molvikar",
|
||||
"nftpoetrist@gmail.com": "nftpoetrist",
|
||||
"dodofun@126.com": "colorcross",
|
||||
"1615063567@qq.com": "zhao0112",
|
||||
"ethanguo.2003@gmail.com": "EthanGuo-coder",
|
||||
"dev0jsh@gmail.com": "tmdgusya",
|
||||
"leavr@163.com": "leavrcn",
|
||||
"17683456+wanazhar@users.noreply.github.com": "wanazhar",
|
||||
"26782336+cixuuz@users.noreply.github.com": "cixuuz",
|
||||
"aleksandr.pasevin@openzeppelin.com": "pasevin",
|
||||
"ubuntu@localhost.localdomain": "holynn-q",
|
||||
"holynn@placeholder.local": "holynn-q",
|
||||
"agent@hermes.local": "jacdevos",
|
||||
"sunsky.lau@gmail.com": "liuhao1024",
|
||||
"qiuqfang98@qq.com": "keepcalmqqf",
|
||||
"261867348+ai-ag2026@users.noreply.github.com": "ai-ag2026",
|
||||
"yanzh.su@gmail.com": "YanzhongSu",
|
||||
"wanderwang@users.noreply.github.com": "WanderWang",
|
||||
"yueheime@gmail.com": "yuehei",
|
||||
"emidomh@gmail.com": "Emidomenge",
|
||||
"2642448440@qq.com": "BlackJulySnow",
|
||||
"4317663+helix4u@users.noreply.github.com": "helix4u",
|
||||
"floptopbot33@gmail.com": "flobo3",
|
||||
"dpaluy@users.noreply.github.com": "dpaluy",
|
||||
"psikonetik@gmail.com": "el-analista",
|
||||
"chenb19870707@gmail.com": "ms-alan",
|
||||
"hex-clawd@users.noreply.github.com": "hex-clawd",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"barteq@hacknotes.local": "barteqpl",
|
||||
"pama0227@gmail.com": "pama0227",
|
||||
"52785845+ee-blog@users.noreply.github.com": "ee-blog",
|
||||
"simplenamebox@gmail.com": "simplenamebox-ops",
|
||||
"balyan.sid@gmail.com": "alt-glitch",
|
||||
"xdord@xdorddeMac-mini.local": "foreverxdord",
|
||||
"k2767567815@gmail.com": "QifengKuang",
|
||||
"88077783+jjjojoj@users.noreply.github.com": "jjjojoj",
|
||||
"valda@underscore.jp": "valda",
|
||||
"lling486@163.com": "M3RCUR2Y",
|
||||
"buraysandro9@gmail.com": "ygd58",
|
||||
"ideathinklab01-source@users.noreply.github.com": "ideathinklab01-source",
|
||||
"27987889@qq.com": "zng8418",
|
||||
"daniuxie88@proton.me": "DaniuXie",
|
||||
"panchanler@gmail.com": "ChanlerDev",
|
||||
"252620095+briandevans@users.noreply.github.com": "briandevans",
|
||||
"141889580+h0tp-ftw@users.noreply.github.com": "h0tp-ftw",
|
||||
"chinadbo@foxmail.com": "chinadbo",
|
||||
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
||||
"xyywtt@gmail.com": "xyiy001",
|
||||
"charliekerfoot@gmail.com": "CharlieKerfoot",
|
||||
"grey0202@users.noreply.github.com": "Grey0202",
|
||||
"vominh1919@gmail.com": "vominh1919",
|
||||
"giwavictor9@gmail.com": "giwaov",
|
||||
"yoimexex@gmail.com": "Yoimex",
|
||||
"76803960+atongrun@users.noreply.github.com": "atongrun",
|
||||
"michaeldanko@icloud.com": "MichaelWDanko",
|
||||
"xudavid429@gmail.com": "YX234",
|
||||
"kathy@Kathy.local": "julysir",
|
||||
"274902531@qq.com": "JanCong",
|
||||
"225304168+e-shizz@users.noreply.github.com": "e-shizz",
|
||||
"vincent_hh@users.noreply.github.com": "VinVC",
|
||||
"1243352777@qq.com": "zons-zhaozhy",
|
||||
"dejie.guo@gmail.com": "JayGwod",
|
||||
"52840391+swithek@users.noreply.github.com": "swithek",
|
||||
"raipratik0101@gmail.com": "PratikRai0101",
|
||||
"code@sasha.id": "sasha-id",
|
||||
"chen.yunbo@xydigit.com": "chenyunbo411",
|
||||
"openclaw@local": "Asce66",
|
||||
"59465365+0xsir0000@users.noreply.github.com": "0xsir0000",
|
||||
"lisanhu2014@hotmail.com": "lisanhu",
|
||||
"0668001438@zte.com.cn": "chenyunbo411",
|
||||
"steven_chanin@alum.mit.edu": "stevenchanin",
|
||||
"fiver@example.com": "halmisen",
|
||||
"mayq0422@gmail.com": "yuqianma",
|
||||
"yuqian@zmetasoft.com": "yuqianma",
|
||||
"scott@bubble.local": "bassings",
|
||||
"highland0971@users.noreply.github.com": "highland0971",
|
||||
"sudolewis@gmail.com": "lewislulu",
|
||||
"gaurav2301v@gmail.com": "Gaurav23V",
|
||||
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
||||
"albert748@gmail.com": "albert748",
|
||||
"ntconguit@gmail.com": "0xharryriddle",
|
||||
"lhysdl@gmail.com": "lhysdl",
|
||||
"shemol@163.com": "SherlockShemol",
|
||||
"enochlam2002@gmail.com": "eloklam",
|
||||
"eloklam@eloklam-ubuntudesktop.tail21966c.ts.net": "eloklam",
|
||||
"clawdia@fmercurio-macstudio.local": "fmercurio",
|
||||
"ricardoporsche001@icloud.com": "Ricardo-M-L",
|
||||
"leozeli@qq.com": "leozeli",
|
||||
"linlehao@cuhk.edu.cn": "LehaoLin",
|
||||
"liutong@isacas.ac.cn": "I3eg1nner",
|
||||
|
|
@ -644,6 +941,47 @@ AUTHOR_MAP = {
|
|||
"web3blind@gmail.com": "web3blind",
|
||||
"ztzheng@163.com": "chengoak", # PR #17467
|
||||
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only
|
||||
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
|
||||
# Debug share upload-time redaction (May 2026)
|
||||
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
|
||||
"mrcoferland@gmail.com": "mrcoferland", # PR #19023
|
||||
"chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050
|
||||
"briansu@Mac-mini.attlocal.net": "likejudy", # PR #19052
|
||||
"leosma@gmail.com": "leon7609", # PR #19069
|
||||
"nouseman666@gmail.com": "nouseman666", # PR #19088
|
||||
"ginwu05@gmail.com": "GinWU05", # PR #19093
|
||||
"shashwatgokhe2@gmail.com": "shashwatgokhe", # PR #19196
|
||||
"stevenchou.ai@gmail.com": "stevenchouai", # PR #19221
|
||||
"leo.gong@phizchat.com": "agilejava", # PR #19346
|
||||
"acc001k@pm.me": "acc001k", # PR #19358
|
||||
"kowenhao@users.noreply.github.com": "kowenhaoai", # PR #19376
|
||||
"hedirman@gmail.com": "hedirman", # PR #19410
|
||||
"lucianopacheco@gmail.com": "LucianoSP", # PR #19412
|
||||
"paultian.research@gmail.com": "paul-tian", # PR #19423
|
||||
"info@glesperance.com": "glesperance", # PR #19443
|
||||
"lxl694522264@gmail.com": "EvilDrag0n", # PR #20651
|
||||
# v0.13.0 additions
|
||||
"clode@clo5de.info": "jackey8616", # via PR salvage
|
||||
"james.russo@heygen.com": "jrusso1020", # via PR salvage
|
||||
"leon@sgp43.com": "LeonSGP43", # PR #18739 salvage of #14570
|
||||
"miniding@miniding.home": "Foolafroos", # PR #20329 French locale
|
||||
"montbra@gmail.com": "Montbra", # PR #20897 salvage of #16189 (TUI voice PTT)
|
||||
"promptsiren@gmail.com": "firefly", # PR #18123 salvage of #16660 (ContextVars)
|
||||
"wtyopenclaw@gmail.com": "WuTianyi123", # PR #20275 salvage of #13723 (feishu markdown)
|
||||
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
|
||||
"agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity)
|
||||
"jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback)
|
||||
# pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
|
||||
"ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix)
|
||||
# Kanban bug-fix batch salvage (May 2026)
|
||||
"frowte3k@gmail.com": "Frowtek", # salvage of #23206 (gateway --board auto-subscribe)
|
||||
"sylw3st3rr@gmail.com": "Sylw3ster", # salvage of #23252 (HERMES_KANBAN_BOARD restore)
|
||||
"hello@dominikh.com": "dmnkhorvath", # salvage of #23358 (kanban worker send_message)
|
||||
"413011+smwbev@users.noreply.github.com": "smwbev", # salvage of #23659 (aria-label colLabel)
|
||||
"58116817+TurgutKural@users.noreply.github.com": "TurgutKural", # salvage of #23356 (HERMES_HOME inject)
|
||||
"openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590)
|
||||
"freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4)
|
||||
"zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1088,7 +1426,7 @@ def main():
|
|||
print(f" SemVer: v{current_version} → v{new_version}")
|
||||
print(f" Previous tag: {prev_tag or '(none — first release)'}")
|
||||
print(f" Commits: {len(commits)}")
|
||||
print(f" Unique authors: {len(set(c['github_author'] for c in commits))}")
|
||||
print(f" Unique authors: {len({c['github_author'] for c in commits})}")
|
||||
print(f" Mode: {'PUBLISH' if args.publish else 'DRY RUN'}")
|
||||
print(f"{'='*60}")
|
||||
print()
|
||||
|
|
@ -1101,7 +1439,7 @@ def main():
|
|||
)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(changelog)
|
||||
Path(args.output).write_text(changelog, encoding="utf-8")
|
||||
print(f"Changelog written to {args.output}")
|
||||
else:
|
||||
print(changelog)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,15 @@ PYTHON="$VENV/bin/python"
|
|||
# ── Ensure pytest-split is installed (required for shard-equivalent runs) ──
|
||||
if ! "$PYTHON" -c "import pytest_split" 2>/dev/null; then
|
||||
echo "→ installing pytest-split into $VENV"
|
||||
"$PYTHON" -m pip install --quiet "pytest-split>=0.9,<1"
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --python "$PYTHON" --quiet "pytest-split>=0.9,<1"
|
||||
elif "$PYTHON" -m pip --version >/dev/null 2>&1; then
|
||||
"$PYTHON" -m pip install --quiet "pytest-split>=0.9,<1"
|
||||
else
|
||||
echo "error: neither uv nor pip is available in $VENV — pytest-split is missing" >&2
|
||||
echo " fix: run uv pip install -e \".[dev]\" from $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Hermetic environment ────────────────────────────────────────────────────
|
||||
|
|
@ -67,6 +75,7 @@ unset HERMES_YOLO_MODE HERMES_INTERACTIVE HERMES_QUIET HERMES_TOOL_PROGRESS \
|
|||
HERMES_TOOL_PROGRESS_MODE HERMES_MAX_ITERATIONS HERMES_SESSION_PLATFORM \
|
||||
HERMES_SESSION_CHAT_ID HERMES_SESSION_CHAT_NAME HERMES_SESSION_THREAD_ID \
|
||||
HERMES_SESSION_SOURCE HERMES_SESSION_KEY HERMES_GATEWAY_SESSION \
|
||||
HERMES_CRON_SESSION \
|
||||
HERMES_PLATFORM HERMES_INFERENCE_PROVIDER HERMES_MANAGED HERMES_DEV \
|
||||
HERMES_CONTAINER HERMES_EPHEMERAL_SYSTEM_PROMPT HERMES_TIMEZONE \
|
||||
HERMES_REDACT_SECRETS HERMES_BACKGROUND_NOTIFICATIONS HERMES_EXEC_ASK \
|
||||
|
|
@ -78,6 +87,22 @@ export LANG=C.UTF-8
|
|||
export LC_ALL=C.UTF-8
|
||||
export PYTHONHASHSEED=0
|
||||
|
||||
# ── Live-gateway test guard (developer machines) ────────────────────────────
|
||||
# If a system-wide hermes pytest_live_guard plugin is installed at
|
||||
# $HOME/.hermes/pytest_live_guard.py, force-load it here so every test run
|
||||
# from this script gets the protection regardless of which worktree is
|
||||
# checked out (in-tree tests/conftest.py guard may be missing on stale
|
||||
# branches). Harmless on CI / fresh machines that don't have the file.
|
||||
if [ -f "$HOME/.hermes/pytest_live_guard.py" ]; then
|
||||
case ":${PYTHONPATH:-}:" in
|
||||
*":$HOME/.hermes:"*) ;;
|
||||
*) export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$HOME/.hermes" ;;
|
||||
esac
|
||||
if [[ ",${PYTEST_PLUGINS:-}," != *,pytest_live_guard,* ]]; then
|
||||
export PYTEST_PLUGINS="${PYTEST_PLUGINS:+$PYTEST_PLUGINS,}pytest_live_guard"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Worker count ────────────────────────────────────────────────────────────
|
||||
# CI uses `-n auto` on ubuntu-latest which gives 4 workers. A 20-core
|
||||
# workstation with `-n auto` gets 20 workers and exposes test-ordering
|
||||
|
|
|
|||
349
scripts/setup_open_webui.sh
Executable file
349
scripts/setup_open_webui.sh
Executable file
|
|
@ -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"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
python -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 "$@"
|
||||
|
|
@ -64,8 +64,12 @@ export function expandWhatsAppIdentifiers(identifier, sessionDir) {
|
|||
}
|
||||
|
||||
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 true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)
|
||||
|
|
|
|||
|
|
@ -57,3 +57,24 @@ test('matchesAllowedUser treats * as allow-all wildcard', () => {
|
|||
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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ import express from 'express';
|
|||
import { Boom } from '@hapi/boom';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
|
||||
|
||||
|
|
@ -53,6 +55,12 @@ const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\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);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function formatOutgoingMessage(message) {
|
||||
// In bot mode, messages come from a different number so the prefix is
|
||||
|
|
@ -62,6 +70,38 @@ function formatOutgoingMessage(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(':', '@');
|
||||
|
|
@ -227,17 +267,34 @@ async function startSocket() {
|
|||
if (!isSelfChat) continue;
|
||||
}
|
||||
|
||||
// Check allowlist for messages from others (resolve LID ↔ phone aliases)
|
||||
if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
||||
try {
|
||||
console.log(JSON.stringify({
|
||||
event: 'ignored',
|
||||
reason: 'allowlist_mismatch',
|
||||
chatId,
|
||||
senderId,
|
||||
}));
|
||||
} catch {}
|
||||
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);
|
||||
|
|
@ -421,17 +478,22 @@ app.post('/send', async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const sent = await sock.sendMessage(chatId, { text: formatOutgoingMessage(message) });
|
||||
|
||||
// Track sent message ID to prevent echo-back loops
|
||||
if (sent?.key?.id) {
|
||||
recentlySentIds.add(sent.key.id);
|
||||
if (recentlySentIds.size > MAX_RECENT_IDS) {
|
||||
recentlySentIds.delete(recentlySentIds.values().next().value);
|
||||
const chunks = splitLongMessage(formatOutgoingMessage(message));
|
||||
const messageIds = [];
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
const sent = await sock.sendMessage(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: sent?.key?.id });
|
||||
res.json({
|
||||
success: true,
|
||||
messageId: messageIds[messageIds.length - 1],
|
||||
messageIds,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
|
@ -450,8 +512,22 @@ app.post('/edit', async (req, res) => {
|
|||
|
||||
try {
|
||||
const key = { id: messageId, fromMe: true, remoteJid: chatId };
|
||||
await sock.sendMessage(chatId, { text: formatOutgoingMessage(message), edit: key });
|
||||
res.json({ success: true });
|
||||
const chunks = splitLongMessage(formatOutgoingMessage(message));
|
||||
const messageIds = [];
|
||||
|
||||
await sock.sendMessage(chatId, { text: chunks[0], edit: key });
|
||||
if (chunks.length > 1) {
|
||||
for (let i = 1; i < chunks.length; i += 1) {
|
||||
const sent = await sock.sendMessage(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 });
|
||||
}
|
||||
|
|
@ -505,8 +581,31 @@ app.post('/send-media', async (req, res) => {
|
|||
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
|
||||
break;
|
||||
case 'audio': {
|
||||
const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
|
||||
msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
|
||||
// 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':
|
||||
|
|
@ -522,13 +621,7 @@ app.post('/send-media', async (req, res) => {
|
|||
|
||||
const sent = await sock.sendMessage(chatId, msgPayload);
|
||||
|
||||
// Track sent message ID to prevent echo-back loops
|
||||
if (sent?.key?.id) {
|
||||
recentlySentIds.add(sent.key.id);
|
||||
if (recentlySentIds.size > MAX_RECENT_IDS) {
|
||||
recentlySentIds.delete(recentlySentIds.values().next().value);
|
||||
}
|
||||
}
|
||||
trackSentMessageId(sent);
|
||||
|
||||
res.json({ success: true, messageId: sent?.key?.id });
|
||||
} catch (err) {
|
||||
|
|
@ -600,8 +693,12 @@ if (PAIR_ONLY) {
|
|||
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 — all messages will be processed`);
|
||||
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();
|
||||
|
|
|
|||
216
scripts/whatsapp-bridge/package-lock.json
generated
216
scripts/whatsapp-bridge/package-lock.json
generated
|
|
@ -25,15 +25,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@cacheable/memory": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
|
||||
"integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
|
||||
"integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/utils": "^2.3.3",
|
||||
"@keyv/bigmap": "^1.3.0",
|
||||
"hookified": "^1.14.0",
|
||||
"keyv": "^5.5.5"
|
||||
"@cacheable/utils": "^2.4.0",
|
||||
"@keyv/bigmap": "^1.3.1",
|
||||
"hookified": "^1.15.1",
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cacheable/node-cache": {
|
||||
|
|
@ -51,19 +51,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@cacheable/utils": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz",
|
||||
"integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz",
|
||||
"integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hashery": "^1.3.0",
|
||||
"hashery": "^1.5.1",
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
|
|
@ -87,9 +87,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
|
|
@ -617,9 +617,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
|
|
@ -645,9 +645,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
|
|
@ -663,9 +663,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
|
|
@ -714,25 +714,20 @@
|
|||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/long": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz",
|
||||
"integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==",
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@whiskeysockets/baileys": {
|
||||
"name": "baileys",
|
||||
"version": "7.0.0-rc.9",
|
||||
"resolved": "git+ssh://git@github.com/WhiskeySockets/Baileys.git#01047debd81beb20da7b7779b08edcb06aa03770",
|
||||
"integrity": "sha512-letWyB96JHD6NdqpAiseOfaUBi13u8AhiRcKSRqcVjc5Vw5xoPTZGvVnw8K/NvGBFAvyLJkwim9Mjvwzhx/SlA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -807,9 +802,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
|
|
@ -820,7 +815,7 @@
|
|||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
|
|
@ -830,6 +825,21 @@
|
|||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
|
@ -840,16 +850,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/cacheable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
|
||||
"integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz",
|
||||
"integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cacheable/memory": "^2.0.7",
|
||||
"@cacheable/utils": "^2.3.3",
|
||||
"@cacheable/memory": "^2.0.8",
|
||||
"@cacheable/utils": "^2.4.0",
|
||||
"hookified": "^1.15.0",
|
||||
"keyv": "^5.5.5",
|
||||
"qified": "^0.6.0"
|
||||
"keyv": "^5.6.0",
|
||||
"qified": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
|
|
@ -1212,21 +1222,21 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hashery": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
|
||||
"integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
|
||||
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookified": "^1.14.0"
|
||||
"hookified": "^1.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
|
|
@ -1327,44 +1337,6 @@
|
|||
"protobufjs": "6.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/libsignal/node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libsignal/node_modules/long": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
|
||||
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/libsignal/node_modules/protobufjs": {
|
||||
"version": "6.8.8",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
|
||||
"integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@types/long": "^4.0.0",
|
||||
"@types/node": "^10.1.0",
|
||||
"long": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbjs": "bin/pbjs",
|
||||
"pbts": "bin/pbts"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
|
|
@ -1372,9 +1344,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -1552,12 +1524,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/p-queue": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
|
||||
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
|
||||
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"p-timeout": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1648,22 +1620,22 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
|
||||
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
|
|
@ -1685,17 +1657,23 @@
|
|||
}
|
||||
},
|
||||
"node_modules/qified": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
|
||||
"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz",
|
||||
"integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookified": "^1.14.0"
|
||||
"hookified": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/qified/node_modules/hookified": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
|
||||
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode-terminal": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
|
|
@ -1922,13 +1900,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -2094,9 +2072,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
|
@ -2139,9 +2117,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -12,5 +12,8 @@
|
|||
"express": "^4.21.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "^7.5.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue