Compare commits

..

1 Commits

Author SHA1 Message Date
Morgane Moss 5f4561b652 feat: deep-research skill 2026-04-13 16:41:48 +00:00
302 changed files with 4046 additions and 20445 deletions
-9
View File
@@ -43,15 +43,6 @@
# KIMI_BASE_URL=https://api.kimi.com/coding/v1 # Default for sk-kimi- keys
# KIMI_BASE_URL=https://api.moonshot.ai/v1 # For legacy Moonshot keys
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # For Moonshot China keys
# KIMI_CN_API_KEY= # Dedicated Moonshot China key
# =============================================================================
# LLM PROVIDER (Arcee AI)
# =============================================================================
# Arcee AI provides access to Trinity models (trinity-mini, trinity-large-*)
# Get an Arcee key at: https://chat.arcee.ai/
# ARCEEAI_API_KEY=
# ARCEE_BASE_URL= # Override default base URL
# =============================================================================
# LLM PROVIDER (MiniMax)
+6 -24
View File
@@ -11,7 +11,6 @@ body:
**Before submitting**, please:
- [ ] Search [existing issues](https://github.com/NousResearch/hermes-agent/issues) to avoid duplicates
- [ ] Update to the latest version (`hermes update`) and confirm the bug still exists
- [ ] Run `hermes debug share` and paste the links below (see Debug Report section)
- type: textarea
id: description
@@ -83,25 +82,6 @@ body:
- Slack
- WhatsApp
- type: textarea
id: debug-report
attributes:
label: Debug Report
description: |
Run `hermes debug share` from your terminal and paste the links it prints here.
This uploads your system info, config, and recent logs to a paste service automatically.
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
If the upload fails, run `hermes debug share --local` and paste the output directly.
placeholder: |
Report https://paste.rs/abc123
agent.log https://paste.rs/def456
gateway.log https://paste.rs/ghi789
render: shell
validations:
required: true
- type: input
id: os
attributes:
@@ -117,6 +97,8 @@ body:
label: Python Version
description: Output of `python --version`
placeholder: "3.11.9"
validations:
required: true
- type: input
id: hermes-version
@@ -124,14 +106,14 @@ body:
label: Hermes Version
description: Output of `hermes version`
placeholder: "2.1.0"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Additional Logs / Traceback (optional)
description: |
The debug report above covers most logs. Use this field for any extra error output,
tracebacks, or screenshots not captured by `hermes debug share`.
label: Relevant Logs / Traceback
description: Paste any error output, traceback, or log messages. This will be auto-formatted as code.
render: shell
- type: textarea
@@ -71,15 +71,3 @@ body:
label: Contribution
options:
- label: I'd like to implement this myself and submit a PR
- type: textarea
id: debug-report
attributes:
label: Debug Report (optional)
description: |
If this feature request is related to a problem you're experiencing, run `hermes debug share` and paste the links here.
In an interactive chat session, you can use `/debug` instead.
This helps us understand your environment and any related logs.
placeholder: |
Report https://paste.rs/abc123
render: shell
+4 -16
View File
@@ -9,8 +9,7 @@ body:
Sorry you're having trouble! Please fill out the details below so we can help.
**Quick checks first:**
- Run `hermes debug share` and paste the links in the Debug Report section below
- If you're in a chat session, you can use `/debug` instead — it does the same thing
- Run `hermes doctor` and include the output below
- Try `hermes update` to get the latest version
- Check the [README troubleshooting section](https://github.com/NousResearch/hermes-agent#troubleshooting)
- For general questions, consider the [Nous Research Discord](https://discord.gg/NousResearch) for faster help
@@ -75,21 +74,10 @@ body:
placeholder: "2.1.0"
- type: textarea
id: debug-report
id: doctor-output
attributes:
label: Debug Report
description: |
Run `hermes debug share` from your terminal and paste the links it prints here.
This uploads your system info, config, and recent logs to a paste service automatically.
If you're in an interactive chat session, you can also use the `/debug` slash command — it does the same thing.
If the upload fails or install didn't get that far, run `hermes debug share --local` and paste the output directly.
If even that doesn't work, run `hermes doctor` and paste that output instead.
placeholder: |
Report https://paste.rs/abc123
agent.log https://paste.rs/def456
gateway.log https://paste.rs/ghi789
label: Output of `hermes doctor`
description: Run `hermes doctor` and paste the full output. This will be auto-formatted.
render: shell
- type: textarea
-73
View File
@@ -1,73 +0,0 @@
name: Contributor Attribution Check
on:
pull_request:
branches: [main]
paths:
# Only run when code files change (not docs-only PRs)
- '*.py'
- '**/*.py'
- '.github/workflows/contributor-check.yml'
permissions:
contents: read
jobs:
check-attribution:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0 # Full history needed for git log
- name: Check for unmapped contributor emails
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
# Find any new author emails in this PR's commits
NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u)
if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
exit 0
fi
# Check each email against AUTHOR_MAP in release.py
MISSING=""
while IFS= read -r email; do
# Skip teknium and bot emails
case "$email" in
*teknium*|*noreply@github.com*|*dependabot*|*github-actions*|*anthropic.com*|*cursor.com*)
continue ;;
esac
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
continue # GitHub noreply emails auto-resolve
fi
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
AUTHOR=$(git log --author="$email" --format='%an' -1)
MISSING="${MISSING}\n ${email} (${AUTHOR})"
fi
done <<< "$NEW_EMAILS"
if [ -n "$MISSING" ]; then
echo ""
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
echo -e "$MISSING"
echo ""
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
echo -e "$MISSING" | while read -r line; do
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
[ -z "$email" ] && continue
echo " \"${email}\": \"<github-username>\","
done
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
exit 1
else
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
fi
+6 -6
View File
@@ -28,20 +28,20 @@ jobs:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2 httpx==0.28.1
run: pip install pyyaml httpx
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
@@ -73,10 +73,10 @@ jobs:
echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
uses: actions/upload-pages-artifact@v3
with:
path: _site
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
uses: actions/deploy-pages@v4
+7 -7
View File
@@ -23,21 +23,21 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@v3
# Build amd64 only so we can `load` the image for smoke testing.
# `load: true` cannot export a multi-arch manifest to the local daemon.
# The multi-arch build follows on push to main / release.
- name: Build image (amd64, smoke test)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -56,14 +56,14 @@ jobs:
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push multi-arch image (main branch)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -75,7 +75,7 @@ jobs:
- name: Push multi-arch image (release)
if: github.event_name == 'release'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
+3 -6
View File
@@ -7,16 +7,13 @@ on:
- '.github/workflows/docs-site-checks.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
@@ -26,7 +23,7 @@ jobs:
run: npm ci
working-directory: website
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@v5
with:
python-version: '3.11'
+1 -4
View File
@@ -14,9 +14,6 @@ on:
- 'run_agent.py'
- 'acp_adapter/**'
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
@@ -29,7 +26,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- name: Check flake
+11 -11
View File
@@ -20,14 +20,14 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install httpx==0.28.1 pyyaml==6.0.2
run: pip install httpx pyyaml
- name: Build skills index
env:
@@ -35,7 +35,7 @@ jobs:
run: python scripts/build_skills_index.py
- name: Upload index artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@v4
with:
name: skills-index
path: website/static/api/skills-index.json
@@ -53,25 +53,25 @@ jobs:
# Only deploy on schedule or manual trigger (not on every push to the script)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- uses: actions/download-artifact@v4
with:
name: skills-index
path: website/static/api/
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2
run: pip install pyyaml
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
@@ -92,10 +92,10 @@ jobs:
echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
uses: actions/upload-pages-artifact@v3
with:
path: _site
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
uses: actions/deploy-pages@v4
+2 -58
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -149,62 +149,6 @@ jobs:
"
fi
# --- CI/CD workflow files modified ---
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
if [ -n "$WORKFLOW_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: CI/CD workflow files modified
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
**Files:**
\`\`\`
${WORKFLOW_HITS}
\`\`\`
"
fi
# --- Dockerfile / container build files modified ---
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
if [ -n "$DOCKER_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Container build files modified
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
**Files:**
\`\`\`
${DOCKER_HITS}
\`\`\`
"
fi
# --- Dependency manifest files modified ---
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
if [ -n "$DEP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Dependency manifest files modified
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
**Files:**
\`\`\`
${DEP_HITS}
\`\`\`
"
fi
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
if [ -n "$ACTIONS_UNPIN" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: GitHub Actions with mutable version tags
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
**Matches:**
\`\`\`
${ACTIONS_UNPIN}
\`\`\`
"
fi
# --- Output results ---
if [ -n "$FINDINGS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
@@ -239,7 +183,7 @@ jobs:
---
*Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
- name: Fail on critical findings
if: steps.scan.outputs.critical == 'true'
+4 -7
View File
@@ -6,9 +6,6 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
# Cancel in-progress runs for the same PR/branch
concurrency:
group: tests-${{ github.ref }}
@@ -20,13 +17,13 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@v5
- name: Set up Python 3.11
run: uv python install 3.11
@@ -52,10 +49,10 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@v5
- name: Set up Python 3.11
run: uv python install 3.11
-107
View File
@@ -1,107 +0,0 @@
# .mailmap — canonical author mapping for git shortlog / git log / GitHub
# Format: Canonical Name <canonical@email> <commit@email>
# See: https://git-scm.com/docs/gitmailmap
#
# This maps commit emails to GitHub noreply addresses so that:
# 1. `git shortlog -sn` shows deduplicated contributor counts
# 2. GitHub's contributor graph can attribute commits correctly
# 3. Contributors with personal/work emails get proper credit
#
# When adding entries: use the contributor's GitHub noreply email as canonical
# so GitHub can link commits to their profile.
# === Teknium (multiple emails) ===
Teknium <127238744+teknium1@users.noreply.github.com> <teknium1@gmail.com>
Teknium <127238744+teknium1@users.noreply.github.com> <teknium@nousresearch.com>
# === Contributors — personal/work emails mapped to GitHub noreply ===
# Format: Canonical Name <GH-noreply> <commit-email>
# Verified via GH API email search
luyao618 <364939526@qq.com> <364939526@qq.com>
ethernet8023 <arilotter@gmail.com> <arilotter@gmail.com>
nicoloboschi <boschi1997@gmail.com> <boschi1997@gmail.com>
cherifya <chef.ya@gmail.com> <chef.ya@gmail.com>
BongSuCHOI <chlqhdtn98@gmail.com> <chlqhdtn98@gmail.com>
dsocolobsky <dsocolobsky@gmail.com> <dsocolobsky@gmail.com>
pefontana <fontana.pedro93@gmail.com> <fontana.pedro93@gmail.com>
Helmi <frank@helmschrott.de> <frank@helmschrott.de>
hata1234 <hata1234@gmail.com> <hata1234@gmail.com>
# Verified via PR investigation / salvage PR bodies
DeployFaith <agents@kylefrench.dev> <agents@kylefrench.dev>
flobo3 <floptopbot33@gmail.com> <floptopbot33@gmail.com>
gaixianggeng <gaixg94@gmail.com> <gaixg94@gmail.com>
KUSH42 <xush@xush.org> <xush@xush.org>
konsisumer <der@konsi.org> <der@konsi.org>
WorldInnovationsDepartment <vorvul.danylo@gmail.com> <vorvul.danylo@gmail.com>
m0n5t3r <iacobs@m0n5t3r.info> <iacobs@m0n5t3r.info>
sprmn24 <oncuevtv@gmail.com> <oncuevtv@gmail.com>
fancydirty <fancydirty@gmail.com> <fancydirty@gmail.com>
fxfitz <francis.x.fitzpatrick@gmail.com> <francis.x.fitzpatrick@gmail.com>
limars874 <limars874@gmail.com> <limars874@gmail.com>
AaronWong1999 <aaronwong1999@icloud.com> <aaronwong1999@icloud.com>
dippwho <dipp.who@gmail.com> <dipp.who@gmail.com>
duerzy <duerzy@gmail.com> <duerzy@gmail.com>
geoffwellman <geoff.wellman@gmail.com> <geoff.wellman@gmail.com>
hcshen0111 <shenhaocheng19990111@gmail.com> <shenhaocheng19990111@gmail.com>
jamesarch <han.shan@live.cn> <han.shan@live.cn>
stephenschoettler <stephenschoettler@gmail.com> <stephenschoettler@gmail.com>
Tranquil-Flow <tranquil_flow@protonmail.com> <tranquil_flow@protonmail.com>
Dusk1e <yusufalweshdemir@gmail.com> <yusufalweshdemir@gmail.com>
Awsh1 <ysfalweshcan@gmail.com> <ysfalweshcan@gmail.com>
WAXLYY <ysfwaxlycan@gmail.com> <ysfwaxlycan@gmail.com>
donrhmexe <don.rhm@gmail.com> <don.rhm@gmail.com>
hqhq1025 <1506751656@qq.com> <1506751656@qq.com>
BlackishGreen33 <s5460703@gmail.com> <s5460703@gmail.com>
tomqiaozc <zqiao@microsoft.com> <zqiao@microsoft.com>
MagicRay1217 <mingjwan@microsoft.com> <mingjwan@microsoft.com>
aaronagent <1115117931@qq.com> <1115117931@qq.com>
YoungYang963 <young@YoungdeMacBook-Pro.local> <young@YoungdeMacBook-Pro.local>
LongOddCode <haolong@microsoft.com> <haolong@microsoft.com>
Cafexss <coffeemjj@gmail.com> <coffeemjj@gmail.com>
Cygra <sjtuwbh@gmail.com> <sjtuwbh@gmail.com>
DomGrieco <dgrieco@redhat.com> <dgrieco@redhat.com>
# Duplicate email mapping (same person, multiple emails)
Sertug17 <104278804+Sertug17@users.noreply.github.com> <srhtsrht17@gmail.com>
yyovil <birdiegyal@gmail.com> <tanishq231003@gmail.com>
DomGrieco <dgrieco@redhat.com> <dgrieco@redhat.com>
dsocolobsky <dsocolobsky@gmail.com> <dylan.socolobsky@lambdaclass.com>
olafthiele <programming@olafthiele.com> <olafthiele@gmail.com>
# Verified via git display name matching GH contributor username
cokemine <aptx4561@gmail.com> <aptx4561@gmail.com>
dalianmao000 <dalianmao0107@gmail.com> <dalianmao0107@gmail.com>
emozilla <emozilla@nousresearch.com> <emozilla@nousresearch.com>
jjovalle99 <juan.ovalle@mistral.ai> <juan.ovalle@mistral.ai>
kagura-agent <kagura.chen28@gmail.com> <kagura.chen28@gmail.com>
spniyant <niyant@spicefi.xyz> <niyant@spicefi.xyz>
olafthiele <programming@olafthiele.com> <programming@olafthiele.com>
r266-tech <r2668940489@gmail.com> <r2668940489@gmail.com>
xingkongliang <tianliangjay@gmail.com> <tianliangjay@gmail.com>
win4r <win4r@outlook.com> <win4r@outlook.com>
zhouboli <zhouboli@gmail.com> <zhouboli@gmail.com>
yongtenglei <yongtenglei@gmail.com> <yongtenglei@gmail.com>
# Nous Research team
benbarclay <ben@nousresearch.com> <ben@nousresearch.com>
jquesnelle <jonny@nousresearch.com> <jonny@nousresearch.com>
# GH contributor list verified
spideystreet <dhicham.pro@gmail.com> <dhicham.pro@gmail.com>
dorukardahan <dorukardahan@hotmail.com> <dorukardahan@hotmail.com>
MustafaKara7 <karamusti912@gmail.com> <karamusti912@gmail.com>
Hmbown <hmbown@gmail.com> <hmbown@gmail.com>
kamil-gwozdz <kamil@gwozdz.me> <kamil@gwozdz.me>
kira-ariaki <kira@ariaki.me> <kira@ariaki.me>
knopki <knopki@duck.com> <knopki@duck.com>
Unayung <unayung@gmail.com> <unayung@gmail.com>
SeeYangZhi <yangzhi.see@gmail.com> <yangzhi.see@gmail.com>
Julientalbot <julien.talbot@ergonomia.re> <julien.talbot@ergonomia.re>
lesterli <lisicheng168@gmail.com> <lisicheng168@gmail.com>
JiayuuWang <jiayuw794@gmail.com> <jiayuw794@gmail.com>
tesseracttars-creator <tesseracttars@gmail.com> <tesseracttars@gmail.com>
xinbenlv <zzn+pa@zzn.im> <zzn+pa@zzn.im>
SaulJWu <saul.jj.wu@gmail.com> <saul.jj.wu@gmail.com>
angelos <angelos@oikos.lan.home.malaiwah.com> <angelos@oikos.lan.home.malaiwah.com>
+1 -1
View File
@@ -55,7 +55,7 @@ hermes-agent/
├── gateway/ # Messaging platform gateway
│ ├── run.py # Main loop, slash commands, message dispatch
│ ├── session.py # SessionStore — conversation persistence
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── cron/ # Scheduler (jobs.py, scheduler.py)
├── environments/ # RL training environments (Atropos)
+1 -1
View File
@@ -12,7 +12,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# Install system dependencies in one layer, clear APT cache
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git && \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
rm -rf /var/lib/apt/lists/*
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
+1 -1
View File
@@ -13,7 +13,7 @@
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
<table>
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
-329
View File
@@ -1,329 +0,0 @@
# Hermes Agent v0.9.0 (v2026.4.13)
**Release Date:** April 13, 2026
**Since v0.8.0:** 487 commits · 269 merged PRs · 167 resolved issues · 493 files changed · 63,281 insertions · 24 contributors
> The everywhere release — Hermes goes mobile with Termux/Android, adds iMessage and WeChat, ships Fast Mode for OpenAI and Anthropic, introduces background process monitoring, launches a local web dashboard for managing your agent, and delivers the deepest security hardening pass yet across 16 supported platforms.
---
## ✨ Highlights
- **Local Web Dashboard** — A new browser-based dashboard for managing your Hermes Agent locally. Configure settings, monitor sessions, browse skills, and manage your gateway — all from a clean web interface without touching config files or the terminal. The easiest way to get started with Hermes.
- **Fast Mode (`/fast`)** — Priority processing for OpenAI and Anthropic models. Toggle `/fast` to route through priority queues for significantly lower latency on supported models (GPT-5.4, Codex, Claude). Expands across all OpenAI Priority Processing models and Anthropic's fast tier. ([#6875](https://github.com/NousResearch/hermes-agent/pull/6875), [#6960](https://github.com/NousResearch/hermes-agent/pull/6960), [#7037](https://github.com/NousResearch/hermes-agent/pull/7037))
- **iMessage via BlueBubbles** — Full iMessage integration through BlueBubbles, bringing Hermes to Apple's messaging ecosystem. Auto-webhook registration, setup wizard integration, and crash resilience. ([#6437](https://github.com/NousResearch/hermes-agent/pull/6437), [#6460](https://github.com/NousResearch/hermes-agent/pull/6460), [#6494](https://github.com/NousResearch/hermes-agent/pull/6494))
- **WeChat (Weixin) & WeCom Callback Mode** — Native WeChat support via iLink Bot API and a new WeCom callback-mode adapter for self-built enterprise apps. Streaming cursor, media uploads, markdown link handling, and atomic state persistence. Hermes now covers the Chinese messaging ecosystem end-to-end. ([#7166](https://github.com/NousResearch/hermes-agent/pull/7166), [#7943](https://github.com/NousResearch/hermes-agent/pull/7943))
- **Termux / Android Support** — Run Hermes natively on Android via Termux. Adapted install paths, TUI optimizations for mobile screens, voice backend support, and the `/image` command work on-device. ([#6834](https://github.com/NousResearch/hermes-agent/pull/6834))
- **Background Process Monitoring (`watch_patterns`)** — Set patterns to watch for in background process output and get notified in real-time when they match. Monitor for errors, wait for specific events ("listening on port"), or watch build logs — all without polling. ([#7635](https://github.com/NousResearch/hermes-agent/pull/7635))
- **Native xAI & Xiaomi MiMo Providers** — First-class provider support for xAI (Grok) and Xiaomi MiMo, with direct API access, model catalogs, and setup wizard integration. Plus Qwen OAuth with portal request support. ([#7372](https://github.com/NousResearch/hermes-agent/pull/7372), [#7855](https://github.com/NousResearch/hermes-agent/pull/7855))
- **Pluggable Context Engine** — Context management is now a pluggable slot via `hermes plugins`. Swap in custom context engines that control what the agent sees each turn — filtering, summarization, or domain-specific context injection. ([#7464](https://github.com/NousResearch/hermes-agent/pull/7464))
- **Unified Proxy Support** — SOCKS proxy, `DISCORD_PROXY`, and system proxy auto-detection across all gateway platforms. Hermes behind corporate firewalls just works. ([#6814](https://github.com/NousResearch/hermes-agent/pull/6814))
- **Comprehensive Security Hardening** — Path traversal protection in checkpoint manager, shell injection neutralization in sandbox writes, SSRF redirect guards in Slack image uploads, Twilio webhook signature validation (SMS RCE fix), API server auth enforcement, git argument injection prevention, and approval button authorization. ([#7933](https://github.com/NousResearch/hermes-agent/pull/7933), [#7944](https://github.com/NousResearch/hermes-agent/pull/7944), [#7940](https://github.com/NousResearch/hermes-agent/pull/7940), [#7151](https://github.com/NousResearch/hermes-agent/pull/7151), [#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- **`hermes backup` & `hermes import`** — Full backup and restore of your Hermes configuration, sessions, skills, and memory. Migrate between machines or create snapshots before major changes. ([#7997](https://github.com/NousResearch/hermes-agent/pull/7997))
- **16 Supported Platforms** — With BlueBubbles (iMessage) and WeChat joining Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, SMS, DingTalk, Feishu, WeCom, Mattermost, Home Assistant, and Webhooks, Hermes now runs on 16 messaging platforms out of the box.
- **`/debug` & `hermes debug share`** — New debugging toolkit: `/debug` slash command across all platforms for quick diagnostics, plus `hermes debug share` to upload a full debug report to a pastebin for easy sharing when troubleshooting. ([#8681](https://github.com/NousResearch/hermes-agent/pull/8681))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
- **Native xAI (Grok) provider** with direct API access and model catalog ([#7372](https://github.com/NousResearch/hermes-agent/pull/7372))
- **Xiaomi MiMo as first-class provider** — setup wizard, model catalog, empty response recovery ([#7855](https://github.com/NousResearch/hermes-agent/pull/7855))
- **Qwen OAuth provider** with portal request support ([#6282](https://github.com/NousResearch/hermes-agent/pull/6282))
- **Fast Mode** — `/fast` toggle for OpenAI Priority Processing + Anthropic fast tier ([#6875](https://github.com/NousResearch/hermes-agent/pull/6875), [#6960](https://github.com/NousResearch/hermes-agent/pull/6960), [#7037](https://github.com/NousResearch/hermes-agent/pull/7037))
- **Structured API error classification** for smart failover decisions ([#6514](https://github.com/NousResearch/hermes-agent/pull/6514))
- **Rate limit header capture** shown in `/usage` ([#6541](https://github.com/NousResearch/hermes-agent/pull/6541))
- **API server model name** derived from profile name ([#6857](https://github.com/NousResearch/hermes-agent/pull/6857))
- **Custom providers** now included in `/model` listings and resolution ([#7088](https://github.com/NousResearch/hermes-agent/pull/7088))
- **Fallback provider activation** on repeated empty responses with user-visible status ([#7505](https://github.com/NousResearch/hermes-agent/pull/7505))
- **OpenRouter variant tags** (`:free`, `:extended`, `:fast`) preserved during model switch ([#6383](https://github.com/NousResearch/hermes-agent/pull/6383))
- **Credential exhaustion TTL** reduced from 24 hours to 1 hour ([#6504](https://github.com/NousResearch/hermes-agent/pull/6504))
- **OAuth credential lifecycle** hardening — stale pool keys, auth.json sync, Codex CLI race fixes ([#6874](https://github.com/NousResearch/hermes-agent/pull/6874))
- Empty response recovery for reasoning models (MiMo, Qwen, GLM) ([#8609](https://github.com/NousResearch/hermes-agent/pull/8609))
- MiniMax context lengths, thinking guard, endpoint corrections ([#6082](https://github.com/NousResearch/hermes-agent/pull/6082), [#7126](https://github.com/NousResearch/hermes-agent/pull/7126))
- Z.AI endpoint auto-detect via probe and cache ([#5763](https://github.com/NousResearch/hermes-agent/pull/5763))
### Agent Loop & Conversation
- **Pluggable context engine slot** via `hermes plugins` ([#7464](https://github.com/NousResearch/hermes-agent/pull/7464))
- **Background process monitoring** — `watch_patterns` for real-time output alerts ([#7635](https://github.com/NousResearch/hermes-agent/pull/7635))
- **Improved context compression** — higher limits, tool tracking, degradation warnings, token-budget tail protection ([#6395](https://github.com/NousResearch/hermes-agent/pull/6395), [#6453](https://github.com/NousResearch/hermes-agent/pull/6453))
- **`/compress <focus>`** — guided compression with a focus topic ([#8017](https://github.com/NousResearch/hermes-agent/pull/8017))
- **Tiered context pressure warnings** with gateway dedup ([#6411](https://github.com/NousResearch/hermes-agent/pull/6411))
- **Staged inactivity warning** before timeout escalation ([#6387](https://github.com/NousResearch/hermes-agent/pull/6387))
- **Prevent agent from stopping mid-task** — compression floor, budget overhaul, activity tracking ([#7983](https://github.com/NousResearch/hermes-agent/pull/7983))
- **Propagate child activity to parent** during `delegate_task` ([#7295](https://github.com/NousResearch/hermes-agent/pull/7295))
- **Truncated streaming tool call detection** before execution ([#6847](https://github.com/NousResearch/hermes-agent/pull/6847))
- Empty response retry (3 attempts with nudge) ([#6488](https://github.com/NousResearch/hermes-agent/pull/6488))
- Adaptive streaming backoff + cursor strip to prevent message truncation ([#7683](https://github.com/NousResearch/hermes-agent/pull/7683))
- Compression uses live session model instead of stale persisted config ([#8258](https://github.com/NousResearch/hermes-agent/pull/8258))
- Strip `<thought>` tags from Gemma 4 responses ([#8562](https://github.com/NousResearch/hermes-agent/pull/8562))
- Prevent `<think>` in prose from suppressing response output ([#6968](https://github.com/NousResearch/hermes-agent/pull/6968))
- Turn-exit diagnostic logging to agent loop ([#6549](https://github.com/NousResearch/hermes-agent/pull/6549))
- Scope tool interrupt signal per-thread to prevent cross-session leaks ([#7930](https://github.com/NousResearch/hermes-agent/pull/7930))
### Memory & Sessions
- **Hindsight memory plugin** — feature parity, setup wizard, config improvements — @nicoloboschi ([#6428](https://github.com/NousResearch/hermes-agent/pull/6428))
- **Honcho** — opt-in `initOnSessionStart` for tools mode — @Kathie-yu ([#6995](https://github.com/NousResearch/hermes-agent/pull/6995))
- Orphan children instead of cascade-deleting in prune/delete ([#6513](https://github.com/NousResearch/hermes-agent/pull/6513))
- Doctor command only checks the active memory provider ([#6285](https://github.com/NousResearch/hermes-agent/pull/6285))
---
## 📱 Messaging Platforms (Gateway)
### New Platforms
- **BlueBubbles (iMessage)** — full adapter with auto-webhook registration, setup wizard, and crash resilience ([#6437](https://github.com/NousResearch/hermes-agent/pull/6437), [#6460](https://github.com/NousResearch/hermes-agent/pull/6460), [#6494](https://github.com/NousResearch/hermes-agent/pull/6494), [#7107](https://github.com/NousResearch/hermes-agent/pull/7107))
- **Weixin (WeChat)** — native support via iLink Bot API with streaming, media uploads, markdown links ([#7166](https://github.com/NousResearch/hermes-agent/pull/7166), [#8665](https://github.com/NousResearch/hermes-agent/pull/8665))
- **WeCom Callback Mode** — self-built enterprise app adapter with atomic state persistence ([#7943](https://github.com/NousResearch/hermes-agent/pull/7943), [#7928](https://github.com/NousResearch/hermes-agent/pull/7928))
### Discord
- **Allowed channels whitelist** config — @jarvis-phw ([#7044](https://github.com/NousResearch/hermes-agent/pull/7044))
- **Forum channel topic inheritance** in thread sessions — @hermes-agent-dhabibi ([#6377](https://github.com/NousResearch/hermes-agent/pull/6377))
- **DISCORD_REPLY_TO_MODE** setting ([#6333](https://github.com/NousResearch/hermes-agent/pull/6333))
- Accept `.log` attachments, raise document size limit — @kira-ariaki ([#6467](https://github.com/NousResearch/hermes-agent/pull/6467))
- Decouple readiness from slash sync ([#8016](https://github.com/NousResearch/hermes-agent/pull/8016))
### Slack
- **Consolidated Slack improvements** — 7 community PRs salvaged into one ([#6809](https://github.com/NousResearch/hermes-agent/pull/6809))
- Handle assistant thread lifecycle events ([#6433](https://github.com/NousResearch/hermes-agent/pull/6433))
### Matrix
- **Migrated from matrix-nio to mautrix-python** ([#7518](https://github.com/NousResearch/hermes-agent/pull/7518))
- SQLite crypto store replacing pickle (fixes E2EE decryption) — @alt-glitch ([#7981](https://github.com/NousResearch/hermes-agent/pull/7981))
- Cross-signing recovery key verification for E2EE migration ([#8282](https://github.com/NousResearch/hermes-agent/pull/8282))
- DM mention threads + group chat events for Feishu ([#7423](https://github.com/NousResearch/hermes-agent/pull/7423))
### Gateway Core
- **Unified proxy support** — SOCKS, DISCORD_PROXY, multi-platform with macOS auto-detection ([#6814](https://github.com/NousResearch/hermes-agent/pull/6814))
- **Inbound text batching** for Discord, Matrix, WeCom + adaptive delay ([#6979](https://github.com/NousResearch/hermes-agent/pull/6979))
- **Surface natural mid-turn assistant messages** in chat platforms ([#7978](https://github.com/NousResearch/hermes-agent/pull/7978))
- **WSL-aware gateway** with smart systemd detection ([#7510](https://github.com/NousResearch/hermes-agent/pull/7510))
- **All missing platforms added to setup wizard** ([#7949](https://github.com/NousResearch/hermes-agent/pull/7949))
- **Per-platform `tool_progress` overrides** ([#6348](https://github.com/NousResearch/hermes-agent/pull/6348))
- **Configurable 'still working' notification interval** ([#8572](https://github.com/NousResearch/hermes-agent/pull/8572))
- `/model` switch persists across messages ([#7081](https://github.com/NousResearch/hermes-agent/pull/7081))
- `/usage` shows rate limits, cost, and token details between turns ([#7038](https://github.com/NousResearch/hermes-agent/pull/7038))
- Drain in-flight work before restart ([#7503](https://github.com/NousResearch/hermes-agent/pull/7503))
- Don't evict cached agent on failed runs — prevents MCP restart loop ([#7539](https://github.com/NousResearch/hermes-agent/pull/7539))
- Replace `os.environ` session state with `contextvars` ([#7454](https://github.com/NousResearch/hermes-agent/pull/7454))
- Derive channel directory platforms from enum instead of hardcoded list ([#7450](https://github.com/NousResearch/hermes-agent/pull/7450))
- Validate image downloads before caching (cross-platform) ([#7125](https://github.com/NousResearch/hermes-agent/pull/7125))
- Cross-platform webhook delivery for all platforms ([#7095](https://github.com/NousResearch/hermes-agent/pull/7095))
- Cron Discord thread_id delivery support ([#7106](https://github.com/NousResearch/hermes-agent/pull/7106))
- Feishu QR-based bot onboarding ([#8570](https://github.com/NousResearch/hermes-agent/pull/8570))
- Gateway status scoped to active profile ([#7951](https://github.com/NousResearch/hermes-agent/pull/7951))
- Prevent background process notifications from triggering false pairing requests ([#6434](https://github.com/NousResearch/hermes-agent/pull/6434))
---
## 🖥️ CLI & User Experience
### Interactive CLI
- **Termux / Android support** — adapted install paths, TUI, voice, `/image` ([#6834](https://github.com/NousResearch/hermes-agent/pull/6834))
- **Native `/model` picker modal** for provider → model selection ([#8003](https://github.com/NousResearch/hermes-agent/pull/8003))
- **Live per-tool elapsed timer** restored in TUI spinner ([#7359](https://github.com/NousResearch/hermes-agent/pull/7359))
- **Stacked tool progress scrollback** in TUI ([#8201](https://github.com/NousResearch/hermes-agent/pull/8201))
- **Random tips on new session start** (CLI + gateway, 279 tips) ([#8225](https://github.com/NousResearch/hermes-agent/pull/8225), [#8237](https://github.com/NousResearch/hermes-agent/pull/8237))
- **`hermes dump`** — copy-pasteable setup summary for debugging ([#6550](https://github.com/NousResearch/hermes-agent/pull/6550))
- **`hermes backup` / `hermes import`** — full config backup and restore ([#7997](https://github.com/NousResearch/hermes-agent/pull/7997))
- **WSL environment hint** in system prompt ([#8285](https://github.com/NousResearch/hermes-agent/pull/8285))
- **Profile creation UX** — seed SOUL.md + credential warning ([#8553](https://github.com/NousResearch/hermes-agent/pull/8553))
- Shell-aware sudo detection, empty password support ([#6517](https://github.com/NousResearch/hermes-agent/pull/6517))
- Flush stdin after curses/terminal menus to prevent escape sequence leakage ([#7167](https://github.com/NousResearch/hermes-agent/pull/7167))
- Handle broken stdin in prompt_toolkit startup ([#8560](https://github.com/NousResearch/hermes-agent/pull/8560))
### Setup & Configuration
- **Per-platform display verbosity** configuration ([#8006](https://github.com/NousResearch/hermes-agent/pull/8006))
- **Component-separated logging** with session context and filtering ([#7991](https://github.com/NousResearch/hermes-agent/pull/7991))
- **`network.force_ipv4`** config to fix IPv6 timeout issues ([#8196](https://github.com/NousResearch/hermes-agent/pull/8196))
- **Standardize message whitespace and JSON formatting** ([#7988](https://github.com/NousResearch/hermes-agent/pull/7988))
- **Rebrand OpenClaw → Hermes** during migration ([#8210](https://github.com/NousResearch/hermes-agent/pull/8210))
- Config.yaml takes priority over env vars for auxiliary settings ([#7889](https://github.com/NousResearch/hermes-agent/pull/7889))
- Harden setup provider flows + live OpenRouter catalog refresh ([#7078](https://github.com/NousResearch/hermes-agent/pull/7078))
- Normalize reasoning effort ordering across all surfaces ([#6804](https://github.com/NousResearch/hermes-agent/pull/6804))
- Remove dead `LLM_MODEL` env var + migration to clear stale entries ([#6543](https://github.com/NousResearch/hermes-agent/pull/6543))
- Remove `/prompt` slash command — prefix expansion footgun ([#6752](https://github.com/NousResearch/hermes-agent/pull/6752))
- `HERMES_HOME_MODE` env var to override permissions — @ygd58 ([#6993](https://github.com/NousResearch/hermes-agent/pull/6993))
- Fall back to default model when model config is empty ([#8303](https://github.com/NousResearch/hermes-agent/pull/8303))
- Warn when compression model context is too small ([#7894](https://github.com/NousResearch/hermes-agent/pull/7894))
---
## 🔧 Tool System
### Environments & Execution
- **Unified spawn-per-call execution layer** for environments ([#6343](https://github.com/NousResearch/hermes-agent/pull/6343))
- **Unified file sync** with mtime tracking, deletion, and transactional state ([#7087](https://github.com/NousResearch/hermes-agent/pull/7087))
- **Persistent sandbox envs** survive between turns ([#6412](https://github.com/NousResearch/hermes-agent/pull/6412))
- **Bulk file sync** via tar pipe for SSH/Modal backends — @alt-glitch ([#8014](https://github.com/NousResearch/hermes-agent/pull/8014))
- **Daytona** — bulk upload, config bridge, silent disk cap ([#7538](https://github.com/NousResearch/hermes-agent/pull/7538))
- Foreground timeout cap to prevent session deadlocks ([#7082](https://github.com/NousResearch/hermes-agent/pull/7082))
- Guard invalid command values ([#6417](https://github.com/NousResearch/hermes-agent/pull/6417))
### MCP
- **`hermes mcp add --env` and `--preset`** support ([#7970](https://github.com/NousResearch/hermes-agent/pull/7970))
- Combine `content` and `structuredContent` when both present ([#7118](https://github.com/NousResearch/hermes-agent/pull/7118))
- MCP tool name deconfliction fixes ([#7654](https://github.com/NousResearch/hermes-agent/pull/7654))
### Browser
- Browser hardening — dead code removal, caching, scroll perf, security, thread safety ([#7354](https://github.com/NousResearch/hermes-agent/pull/7354))
- `/browser connect` auto-launch uses dedicated Chrome profile dir ([#6821](https://github.com/NousResearch/hermes-agent/pull/6821))
- Reap orphaned browser sessions on startup ([#7931](https://github.com/NousResearch/hermes-agent/pull/7931))
### Voice & Vision
- **Voxtral TTS provider** (Mistral AI) ([#7653](https://github.com/NousResearch/hermes-agent/pull/7653))
- **TTS speed support** for Edge TTS, OpenAI TTS, MiniMax ([#8666](https://github.com/NousResearch/hermes-agent/pull/8666))
- **Vision auto-resize** for oversized images, raise limit to 20 MB, retry-on-failure ([#7883](https://github.com/NousResearch/hermes-agent/pull/7883), [#7902](https://github.com/NousResearch/hermes-agent/pull/7902))
- STT provider-model mismatch fix (whisper-1 vs faster-whisper) ([#7113](https://github.com/NousResearch/hermes-agent/pull/7113))
### Other Tools
- **`hermes dump`** command for setup summary ([#6550](https://github.com/NousResearch/hermes-agent/pull/6550))
- TODO store enforces ID uniqueness during replace operations ([#7986](https://github.com/NousResearch/hermes-agent/pull/7986))
- List all available toolsets in `delegate_task` schema description ([#8231](https://github.com/NousResearch/hermes-agent/pull/8231))
- API server: tool progress as custom SSE event to prevent model corruption ([#7500](https://github.com/NousResearch/hermes-agent/pull/7500))
- API server: share one Docker container across all conversations ([#7127](https://github.com/NousResearch/hermes-agent/pull/7127))
---
## 🧩 Skills Ecosystem
- **Centralized skills index + tree cache** — eliminates rate-limit failures on install ([#8575](https://github.com/NousResearch/hermes-agent/pull/8575))
- **More aggressive skill loading instructions** in system prompt (v3) ([#8209](https://github.com/NousResearch/hermes-agent/pull/8209), [#8286](https://github.com/NousResearch/hermes-agent/pull/8286))
- **Google Workspace skill** migrated to GWS CLI backend ([#6788](https://github.com/NousResearch/hermes-agent/pull/6788))
- **Creative divergence strategies** skill — @SHL0MS ([#6882](https://github.com/NousResearch/hermes-agent/pull/6882))
- **Creative ideation** — constraint-driven project generation — @SHL0MS ([#7555](https://github.com/NousResearch/hermes-agent/pull/7555))
- Parallelize skills browse/search to prevent hanging ([#7301](https://github.com/NousResearch/hermes-agent/pull/7301))
- Read name from SKILL.md frontmatter in skills_sync ([#7623](https://github.com/NousResearch/hermes-agent/pull/7623))
---
## 🔒 Security & Reliability
### Security Hardening
- **Twilio webhook signature validation** — SMS RCE fix ([#7933](https://github.com/NousResearch/hermes-agent/pull/7933))
- **Shell injection neutralization** in `_write_to_sandbox` via path quoting ([#7940](https://github.com/NousResearch/hermes-agent/pull/7940))
- **Git argument injection** and path traversal prevention in checkpoint manager ([#7944](https://github.com/NousResearch/hermes-agent/pull/7944))
- **SSRF redirect bypass** in Slack image uploads + base.py cache helpers ([#7151](https://github.com/NousResearch/hermes-agent/pull/7151))
- **Path traversal, credential gate, DANGEROUS_PATTERNS gaps** ([#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- **API bind guard** — enforce `API_SERVER_KEY` for non-loopback binding ([#7455](https://github.com/NousResearch/hermes-agent/pull/7455))
- **Approval button authorization** — require auth for session continuation — @Cafexss ([#6930](https://github.com/NousResearch/hermes-agent/pull/6930))
- Path boundary enforcement in skill manager operations ([#7156](https://github.com/NousResearch/hermes-agent/pull/7156))
- DingTalk/API webhook URL origin validation, header injection rejection ([#7455](https://github.com/NousResearch/hermes-agent/pull/7455))
### Reliability
- **Contextual error diagnostics** for invalid API responses ([#8565](https://github.com/NousResearch/hermes-agent/pull/8565))
- **Prevent 400 format errors** from triggering compression loop on Codex ([#6751](https://github.com/NousResearch/hermes-agent/pull/6751))
- **Don't halve context_length** on output-cap-too-large errors — @KUSH42 ([#6664](https://github.com/NousResearch/hermes-agent/pull/6664))
- **Recover primary client** on OpenAI transport errors ([#7108](https://github.com/NousResearch/hermes-agent/pull/7108))
- **Credential pool rotation** on billing-classified 400s ([#7112](https://github.com/NousResearch/hermes-agent/pull/7112))
- **Auto-increase stream read timeout** for local LLM providers ([#6967](https://github.com/NousResearch/hermes-agent/pull/6967))
- **Fall back to default certs** when CA bundle path doesn't exist ([#7352](https://github.com/NousResearch/hermes-agent/pull/7352))
- **Disambiguate usage-limit patterns** in error classifier — @sprmn24 ([#6836](https://github.com/NousResearch/hermes-agent/pull/6836))
- Harden cron script timeout and provider recovery ([#7079](https://github.com/NousResearch/hermes-agent/pull/7079))
- Gateway interrupt detection resilient to monitor task failures ([#8208](https://github.com/NousResearch/hermes-agent/pull/8208))
- Prevent unwanted session auto-reset after graceful gateway restarts ([#8299](https://github.com/NousResearch/hermes-agent/pull/8299))
- Prevent duplicate update prompt spam in gateway watcher ([#8343](https://github.com/NousResearch/hermes-agent/pull/8343))
- Deduplicate reasoning items in Responses API input ([#7946](https://github.com/NousResearch/hermes-agent/pull/7946))
### Infrastructure
- **Multi-arch Docker image** — amd64 + arm64 ([#6124](https://github.com/NousResearch/hermes-agent/pull/6124))
- **Docker runs as non-root user** with virtualenv — @benbarclay contributing ([#8226](https://github.com/NousResearch/hermes-agent/pull/8226))
- **Use `uv`** for Docker dependency resolution to fix resolution-too-deep ([#6965](https://github.com/NousResearch/hermes-agent/pull/6965))
- **Container-aware Nix CLI** — auto-route into managed container — @alt-glitch ([#7543](https://github.com/NousResearch/hermes-agent/pull/7543))
- **Nix shared-state permission model** for interactive CLI users — @alt-glitch ([#6796](https://github.com/NousResearch/hermes-agent/pull/6796))
- **Per-profile subprocess HOME isolation** ([#7357](https://github.com/NousResearch/hermes-agent/pull/7357))
- Profile paths fixed in Docker — profiles go to mounted volume ([#7170](https://github.com/NousResearch/hermes-agent/pull/7170))
- Docker container gateway pathway hardened ([#8614](https://github.com/NousResearch/hermes-agent/pull/8614))
- Enable unbuffered stdout for live Docker logs ([#6749](https://github.com/NousResearch/hermes-agent/pull/6749))
- Install procps in Docker image — @HiddenPuppy ([#7032](https://github.com/NousResearch/hermes-agent/pull/7032))
- Shallow git clone for faster installation — @sosyz ([#8396](https://github.com/NousResearch/hermes-agent/pull/8396))
- `hermes update` always reset on stash conflict ([#7010](https://github.com/NousResearch/hermes-agent/pull/7010))
- Write update exit code before gateway restart (cgroup kill race) ([#8288](https://github.com/NousResearch/hermes-agent/pull/8288))
- Nix: `setupSecrets` optional, tirith runtime dep — @devorun, @ethernet8023 ([#6261](https://github.com/NousResearch/hermes-agent/pull/6261), [#6721](https://github.com/NousResearch/hermes-agent/pull/6721))
- launchd stop uses `bootout` so `KeepAlive` doesn't respawn ([#7119](https://github.com/NousResearch/hermes-agent/pull/7119))
---
## 🐛 Notable Bug Fixes
- Fix: `/model` switch not persisting across gateway messages ([#7081](https://github.com/NousResearch/hermes-agent/pull/7081))
- Fix: session-scoped gateway model overrides ignored — @Hygaard ([#7662](https://github.com/NousResearch/hermes-agent/pull/7662))
- Fix: compaction model context length ignoring config — 3 related issues ([#8258](https://github.com/NousResearch/hermes-agent/pull/8258), [#8107](https://github.com/NousResearch/hermes-agent/pull/8107))
- Fix: OpenCode.ai context window resolved to 128K instead of 1M ([#6472](https://github.com/NousResearch/hermes-agent/pull/6472))
- Fix: Codex fallback auth-store lookup — @cherifya ([#6462](https://github.com/NousResearch/hermes-agent/pull/6462))
- Fix: duplicate completion notifications when process killed ([#7124](https://github.com/NousResearch/hermes-agent/pull/7124))
- Fix: agent daemon thread prevents orphan CLI processes on tab close ([#8557](https://github.com/NousResearch/hermes-agent/pull/8557))
- Fix: stale image attachment on text paste and voice input ([#7077](https://github.com/NousResearch/hermes-agent/pull/7077))
- Fix: DM thread session seeding causing cross-thread contamination ([#7084](https://github.com/NousResearch/hermes-agent/pull/7084))
- Fix: OpenClaw migration shows dry-run preview before executing ([#6769](https://github.com/NousResearch/hermes-agent/pull/6769))
- Fix: auth errors misclassified as retryable — @kuishou68 ([#7027](https://github.com/NousResearch/hermes-agent/pull/7027))
- Fix: Copilot-Integration-Id header missing ([#7083](https://github.com/NousResearch/hermes-agent/pull/7083))
- Fix: ACP session capabilities — @luyao618 ([#6985](https://github.com/NousResearch/hermes-agent/pull/6985))
- Fix: ACP PromptResponse usage from top-level fields ([#7086](https://github.com/NousResearch/hermes-agent/pull/7086))
- Fix: several failing/flaky tests on main — @dsocolobsky ([#6777](https://github.com/NousResearch/hermes-agent/pull/6777))
- Fix: backup marker filenames — @sprmn24 ([#8600](https://github.com/NousResearch/hermes-agent/pull/8600))
- Fix: `NoneType` in fast_mode check — @0xbyt4 ([#7350](https://github.com/NousResearch/hermes-agent/pull/7350))
- Fix: missing imports in uninstall.py — @JiayuuWang ([#7034](https://github.com/NousResearch/hermes-agent/pull/7034))
---
## 📚 Documentation
- Platform adapter developer guide + WeCom Callback docs ([#7969](https://github.com/NousResearch/hermes-agent/pull/7969))
- Cron troubleshooting guide ([#7122](https://github.com/NousResearch/hermes-agent/pull/7122))
- Streaming timeout auto-detection for local LLMs ([#6990](https://github.com/NousResearch/hermes-agent/pull/6990))
- Tool-use enforcement documentation expanded ([#7984](https://github.com/NousResearch/hermes-agent/pull/7984))
- BlueBubbles pairing instructions ([#6548](https://github.com/NousResearch/hermes-agent/pull/6548))
- Telegram proxy support section ([#6348](https://github.com/NousResearch/hermes-agent/pull/6348))
- `hermes dump` and `hermes logs` CLI reference ([#6552](https://github.com/NousResearch/hermes-agent/pull/6552))
- `tool_progress_overrides` configuration reference ([#6364](https://github.com/NousResearch/hermes-agent/pull/6364))
- Compression model context length warning docs ([#7879](https://github.com/NousResearch/hermes-agent/pull/7879))
---
## 👥 Contributors
**269 merged PRs** from **24 contributors** across **487 commits**.
### Community Contributors
- **@alt-glitch** (6 PRs) — Nix container-aware CLI, shared-state permissions, Matrix SQLite crypto store, bulk SSH/Modal file sync, Matrix mautrix compat
- **@SHL0MS** (2 PRs) — Creative divergence strategies skill, creative ideation skill
- **@sprmn24** (2 PRs) — Error classifier disambiguation, backup marker fix
- **@nicoloboschi** — Hindsight memory plugin feature parity
- **@Hygaard** — Session-scoped gateway model override fix
- **@jarvis-phw** — Discord allowed_channels whitelist
- **@Kathie-yu** — Honcho initOnSessionStart for tools mode
- **@hermes-agent-dhabibi** — Discord forum channel topic inheritance
- **@kira-ariaki** — Discord .log attachments and size limit
- **@cherifya** — Codex fallback auth-store lookup
- **@Cafexss** — Security: auth for session continuation
- **@KUSH42** — Compaction context_length fix
- **@kuishou68** — Auth error retryable classification fix
- **@luyao618** — ACP session capabilities
- **@ygd58** — HERMES_HOME_MODE env var override
- **@0xbyt4** — Fast mode NoneType fix
- **@JiayuuWang** — CLI uninstall import fix
- **@HiddenPuppy** — Docker procps installation
- **@dsocolobsky** — Test suite fixes
- **@bobashopcashier** (1 PR) — Graceful gateway drain before restart (salvaged into #7503 from #7290)
- **@benbarclay** — Docker image tag simplification
- **@sosyz** — Shallow git clone for faster install
- **@devorun** — Nix setupSecrets optional
- **@ethernet8023** — Nix tirith runtime dep
---
**Full Changelog**: [v2026.4.8...v2026.4.13](https://github.com/NousResearch/hermes-agent/compare/v2026.4.8...v2026.4.13)
+7 -8
View File
@@ -1230,10 +1230,9 @@ def build_anthropic_kwargs(
When *base_url* points to a third-party Anthropic-compatible endpoint,
thinking block signatures are stripped (they are Anthropic-proprietary).
When *fast_mode* is True, adds ``extra_body["speed"] = "fast"`` and the
fast-mode beta header for ~2.5x faster output throughput on Opus 4.6.
Currently only supported on native Anthropic endpoints (not third-party
compatible ones).
When *fast_mode* is True, adds ``speed: "fast"`` and the fast-mode beta
header for ~2.5x faster output throughput on Opus 4.6. Currently only
supported on native Anthropic endpoints (not third-party compatible ones).
"""
system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
@@ -1334,11 +1333,11 @@ def build_anthropic_kwargs(
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
# output speed. Only for native Anthropic endpoints — third-party
# providers would reject the unknown beta header and speed parameter.
# Adds speed:"fast" + the fast-mode beta header for ~2.5x output speed.
# Only for native Anthropic endpoints — third-party providers would
# reject the unknown beta header and speed parameter.
if fast_mode and not _is_third_party_anthropic_endpoint(base_url):
kwargs.setdefault("extra_body", {})["speed"] = "fast"
kwargs["speed"] = "fast"
# Build extra_headers with ALL applicable betas (the per-request
# extra_headers override the client-level anthropic-beta header).
betas = list(_common_betas_for_base_url(base_url))
+3 -56
View File
@@ -64,8 +64,6 @@ _PROVIDER_ALIASES = {
"zhipu": "zai",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
"moonshot-cn": "kimi-coding-cn",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"claude": "anthropic",
@@ -96,7 +94,6 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
"kimi-coding-cn": "kimi-k2-turbo-preview",
"minimax": "MiniMax-M2.7",
"minimax-cn": "MiniMax-M2.7",
"anthropic": "claude-haiku-4-5-20251001",
@@ -112,7 +109,6 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
# "exotic provider" branch checks this before falling back to the main model.
_PROVIDER_VISION_MODELS: Dict[str, str] = {
"xiaomi": "mimo-v2-omni",
"zai": "glm-5v-turbo",
}
# OpenRouter app attribution headers
@@ -1224,12 +1220,6 @@ def _to_async_client(sync_client, model: str):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
try:
from agent.copilot_acp_client import CopilotACPClient
if isinstance(sync_client, CopilotACPClient):
return sync_client, model
except ImportError:
pass
async_kwargs = {
"api_key": sync_client.api_key,
@@ -1448,14 +1438,10 @@ def resolve_provider_client(
custom_entry = _get_named_custom_provider(provider)
if custom_entry:
custom_base = custom_entry.get("base_url", "").strip()
custom_key = custom_entry.get("api_key", "").strip()
custom_key_env = custom_entry.get("key_env", "").strip()
if not custom_key and custom_key_env:
custom_key = os.getenv(custom_key_env, "").strip()
custom_key = custom_key or "no-key-required"
custom_key = custom_entry.get("api_key", "").strip() or "no-key-required"
if custom_base:
final_model = _normalize_resolved_model(
model or custom_entry.get("model") or _read_main_model() or "gpt-4o-mini",
model or _read_main_model() or "gpt-4o-mini",
provider,
)
client = OpenAI(api_key=custom_key, base_url=custom_base)
@@ -1474,11 +1460,7 @@ def resolve_provider_client(
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
try:
from hermes_cli.auth import (
PROVIDER_REGISTRY,
resolve_api_key_provider_credentials,
resolve_external_process_provider_credentials,
)
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
except ImportError:
logger.debug("hermes_cli.auth not available for provider %s", provider)
return None, None
@@ -1552,41 +1534,6 @@ def resolve_provider_client(
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
if pconfig.auth_type == "external_process":
creds = resolve_external_process_provider_credentials(provider)
final_model = _normalize_resolved_model(model or _read_main_model(), provider)
if provider == "copilot-acp":
api_key = str(creds.get("api_key", "")).strip()
base_url = str(creds.get("base_url", "")).strip()
command = str(creds.get("command", "")).strip() or None
args = list(creds.get("args") or [])
if not final_model:
logger.warning(
"resolve_provider_client: copilot-acp requested but no model "
"was provided or configured"
)
return None, None
if not api_key or not base_url:
logger.warning(
"resolve_provider_client: copilot-acp requested but external "
"process credentials are incomplete"
)
return None, None
from agent.copilot_acp_client import CopilotACPClient
client = CopilotACPClient(
api_key=api_key,
base_url=base_url,
command=command,
args=args,
)
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))
logger.warning("resolve_provider_client: external-process provider %s not "
"directly supported", provider)
return None, None
elif pconfig.auth_type in ("oauth_device_code", "oauth_external"):
# OAuth providers — route through their specific try functions
if provider == "nous":
+1 -1
View File
@@ -26,7 +26,7 @@ Lifecycle:
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
class ContextEngine(ABC):
+1 -61
View File
@@ -18,6 +18,7 @@ import hermes_cli.auth as auth_mod
from hermes_cli.auth import (
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
KIMI_CODE_BASE_URL,
PROVIDER_REGISTRY,
_auth_store_lock,
_codex_access_token_is_expiring,
@@ -288,14 +289,6 @@ def _iter_custom_providers(config: Optional[dict] = None):
return
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
# Fall back to the v12+ providers dict via the compatibility layer
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(config)
except Exception:
return
if not custom_providers:
return
for entry in custom_providers:
if not isinstance(entry, dict):
@@ -1152,59 +1145,6 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
},
)
elif provider == "copilot":
# Copilot tokens are resolved dynamically via `gh auth token` or
# env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN). They don't live in
# the auth store or credential pool, so we resolve them here.
try:
from hermes_cli.copilot_auth import resolve_copilot_token
token, source = resolve_copilot_token()
if token:
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_API_KEY,
"access_token": token,
"label": source,
},
)
except Exception as exc:
logger.debug("Copilot token seed failed: %s", exc)
elif provider == "qwen-oauth":
# Qwen OAuth tokens live in ~/.qwen/oauth_creds.json, written by
# the Qwen CLI (`qwen auth qwen-oauth`). They aren't in the
# Hermes auth store or env vars, so resolve them here.
# Use refresh_if_expiring=False to avoid network calls during
# pool loading / provider discovery.
try:
from hermes_cli.auth import resolve_qwen_runtime_credentials
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
token = creds.get("api_key", "")
if token:
source_name = creds.get("source", "qwen-cli")
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": token,
"expires_at_ms": creds.get("expires_at_ms"),
"base_url": creds.get("base_url", ""),
"label": creds.get("auth_file", source_name),
},
)
except Exception as exc:
logger.debug("Qwen OAuth token seed failed: %s", exc)
elif provider == "openai-codex":
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
+6
View File
@@ -77,6 +77,12 @@ def _diff_ansi() -> dict[str, str]:
return _diff_colors_cached
def reset_diff_colors() -> None:
"""Reset cached diff colors (call after /skin switch)."""
global _diff_colors_cached
_diff_colors_cached = None
# Module-level helpers — each call resolves from the active skin lazily.
def _diff_dim(): return _diff_ansi()["dim"]
def _diff_file(): return _diff_ansi()["file"]
+1 -12
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import enum
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
@@ -156,18 +157,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"prompt exceeds max length",
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
"exceeds the max_model_len",
"max_model_len",
"prompt length", # "engine prompt length X exceeds"
"input is too long",
"maximum model length",
# Ollama patterns
"context length exceeded",
"truncating input",
# llama.cpp / llama-server patterns
"slot context", # "slot context: N tokens, prompt N tokens"
"n_ctx_slot",
# Chinese error messages (some providers return these)
"超过最大长度",
"上下文长度",
+1
View File
@@ -27,6 +27,7 @@ from agent.usage_pricing import (
DEFAULT_PRICING,
estimate_usage_cost,
format_duration_compact,
get_pricing,
has_known_pricing,
)
+1
View File
@@ -28,6 +28,7 @@ Usage in run_agent.py:
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
+5 -16
View File
@@ -5,6 +5,7 @@ and run_agent.py for pre-flight context checks.
"""
import logging
import os
import re
import time
from pathlib import Path
@@ -23,19 +24,17 @@ logger = logging.getLogger(__name__)
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek",
"gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
"qwen-oauth",
"xiaomi",
"arcee",
"custom", "local",
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"github-models", "kimi", "moonshot", "claude", "deep-seek",
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
"arcee-ai", "arceeai",
"qwen-portal",
})
@@ -106,15 +105,9 @@ DEFAULT_CONTEXT_LENGTHS = {
"claude-sonnet-4.6": 1000000,
# Catch-all for older Claude models (must sort after specific entries)
"claude": 200000,
# OpenAI — GPT-5 family (most have 400k; specific overrides first)
# Source: https://developers.openai.com/api/docs/models
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
"gpt-5.3-codex-spark": 128000, # Spark variant has reduced 128k context
"gpt-5.1-chat": 128000, # Chat variant has 128k context
"gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k)
# OpenAI
"gpt-4.1": 1047576,
"gpt-5": 128000,
"gpt-4": 128000,
# Google
"gemini": 1048576,
@@ -156,8 +149,6 @@ DEFAULT_CONTEXT_LENGTHS = {
"kimi": 262144,
# Arcee
"trinity": 262144,
# OpenRouter
"elephant": 262144,
# Hugging Face Inference Providers — model IDs use org/name format
"Qwen/Qwen3.5-397B-A17B": 131072,
"Qwen/Qwen3.5-35B-A3B": 131072,
@@ -220,9 +211,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"api.anthropic.com": "anthropic",
"api.z.ai": "zai",
"api.moonshot.ai": "kimi-coding",
"api.moonshot.cn": "kimi-coding-cn",
"api.kimi.com": "kimi-coding",
"api.arcee.ai": "arcee",
"api.minimax": "minimax",
"dashscope.aliyuncs.com": "alibaba",
"dashscope-intl.aliyuncs.com": "alibaba",
+96 -1
View File
@@ -18,8 +18,10 @@ Other modules should import the dataclasses and query functions from here
rather than parsing the raw JSON themselves.
"""
import difflib
import json
import logging
import os
import time
from dataclasses import dataclass
from pathlib import Path
@@ -146,7 +148,6 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openai-codex": "openai",
"zai": "zai",
"kimi-coding": "kimi-for-coding",
"kimi-coding-cn": "kimi-for-coding",
"minimax": "minimax",
"minimax-cn": "minimax-cn",
"deepseek": "deepseek",
@@ -175,6 +176,13 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None
def _get_reverse_mapping() -> Dict[str, str]:
"""Return models.dev ID → Hermes provider ID mapping."""
global _MODELS_DEV_TO_PROVIDER
if _MODELS_DEV_TO_PROVIDER is None:
_MODELS_DEV_TO_PROVIDER = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}
return _MODELS_DEV_TO_PROVIDER
def _get_cache_path() -> Path:
"""Return path to disk cache file."""
@@ -455,6 +463,93 @@ def list_agentic_models(provider: str) -> List[str]:
return result
def search_models_dev(
query: str, provider: str = None, limit: int = 5
) -> List[Dict[str, Any]]:
"""Fuzzy search across models.dev catalog. Returns matching model entries.
Args:
query: Search string to match against model IDs.
provider: Optional Hermes provider ID to restrict search scope.
If None, searches across all providers in PROVIDER_TO_MODELS_DEV.
limit: Maximum number of results to return.
Returns:
List of dicts, each containing 'provider', 'model_id', and the full
model 'entry' from models.dev.
"""
data = fetch_models_dev()
if not data:
return []
# Build list of (provider_id, model_id, entry) candidates
candidates: List[tuple] = []
if provider is not None:
# Search only the specified provider
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
if not mdev_provider_id:
return []
provider_data = data.get(mdev_provider_id, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((provider, mid, mdata))
else:
# Search across all mapped providers
for hermes_prov, mdev_prov in PROVIDER_TO_MODELS_DEV.items():
provider_data = data.get(mdev_prov, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((hermes_prov, mid, mdata))
if not candidates:
return []
# Use difflib for fuzzy matching — case-insensitive comparison
model_ids_lower = [c[1].lower() for c in candidates]
query_lower = query.lower()
# First try exact substring matches (more intuitive than pure edit-distance)
substring_matches = []
for prov, mid, mdata in candidates:
if query_lower in mid.lower():
substring_matches.append({"provider": prov, "model_id": mid, "entry": mdata})
# Then add difflib fuzzy matches for any remaining slots
fuzzy_ids = difflib.get_close_matches(
query_lower, model_ids_lower, n=limit * 2, cutoff=0.4
)
seen_ids: set = set()
results: List[Dict[str, Any]] = []
# Prioritize substring matches
for match in substring_matches:
key = (match["provider"], match["model_id"])
if key not in seen_ids:
seen_ids.add(key)
results.append(match)
if len(results) >= limit:
return results
# Add fuzzy matches
for fid in fuzzy_ids:
# Find original-case candidates matching this lowered ID
for prov, mid, mdata in candidates:
if mid.lower() == fid:
key = (prov, mid)
if key not in seen_ids:
seen_ids.add(key)
results.append({"provider": prov, "model_id": mid, "entry": mdata})
if len(results) >= limit:
return results
return results
# ---------------------------------------------------------------------------
# Rich dataclass constructors — parse raw models.dev JSON into dataclasses
-6
View File
@@ -376,12 +376,6 @@ PLATFORM_HINTS = {
"downloaded and sent as native photos. Do NOT tell the user you lack file-sending "
"capability — use MEDIA: syntax whenever a file delivery is appropriate."
),
"qqbot": (
"You are on QQ, a popular Chinese messaging platform. QQ supports markdown formatting "
"and emoji. You can send media files natively: include MEDIA:/absolute/path/to/file in "
"your response. Images are sent as native photos, and other files arrive as downloadable "
"documents."
),
}
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -24,7 +24,7 @@ from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
from typing import Any, Dict, Mapping, Optional
@dataclass
+1 -23
View File
@@ -10,7 +10,7 @@ import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import Any, Dict, List, Set, Tuple
from hermes_constants import get_config_path, get_skills_dir
@@ -441,25 +441,3 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
# ── Namespace helpers for plugin-provided skills ───────────────────────────
_NAMESPACE_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
"""Split ``'namespace:skill-name'`` into ``(namespace, bare_name)``.
Returns ``(None, name)`` when there is no ``':'``.
"""
if ":" not in name:
return None, name
return tuple(name.split(":", 1)) # type: ignore[return-value]
def is_valid_namespace(candidate: Optional[str]) -> bool:
"""Check whether *candidate* is a valid namespace (``[a-zA-Z0-9_-]+``)."""
if not candidate:
return False
return bool(_NAMESPACE_RE.match(candidate))
+19
View File
@@ -575,6 +575,25 @@ def has_known_pricing(
return entry is not None
def get_pricing(
model_name: str,
provider: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Dict[str, float]:
"""Backward-compatible thin wrapper for legacy callers.
Returns only non-cache input/output fields when a pricing entry exists.
Unknown routes return zeroes.
"""
entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
if not entry:
return {"input": 0.0, "output": 0.0}
return {
"input": float(entry.input_cost_per_million or _ZERO),
"output": float(entry.output_cost_per_million or _ZERO),
}
def format_duration_compact(seconds: float) -> str:
if seconds < 60:
+10 -1
View File
@@ -538,6 +538,7 @@ class BatchRunner:
reasoning_config: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
max_samples: int = None,
output_dir: str = None,
):
"""
Initialize the batch runner.
@@ -590,7 +591,7 @@ class BatchRunner:
raise ValueError(f"Unknown distribution: {distribution}. Available: {list(list_distributions().keys())}")
# Setup output directory
self.output_dir = Path("data") / run_name
self.output_dir = Path(output_dir) if output_dir else Path("data") / run_name
self.output_dir.mkdir(parents=True, exist_ok=True)
# Checkpoint file
@@ -1124,6 +1125,7 @@ def main(
verbose: bool = False,
list_distributions: bool = False,
ephemeral_system_prompt: str = None,
ephemeral_system_prompt_file: str = None,
log_prefix_chars: int = 100,
providers_allowed: str = None,
providers_ignored: str = None,
@@ -1134,6 +1136,7 @@ def main(
reasoning_disabled: bool = False,
prefill_messages_file: str = None,
max_samples: int = None,
output_dir: str = None,
):
"""
Run batch processing of agent prompts from a dataset.
@@ -1200,6 +1203,11 @@ def main(
print(" --run_name=my_run --distribution=<name>")
return
# Load system prompt from file if provided
if ephemeral_system_prompt_file and not ephemeral_system_prompt:
with open(ephemeral_system_prompt_file) as _f:
ephemeral_system_prompt = _f.read()
# Validate required arguments
if not dataset_file:
print("❌ Error: --dataset_file is required")
@@ -1271,6 +1279,7 @@ def main(
reasoning_config=reasoning_config,
prefill_messages=prefill_messages,
max_samples=max_samples,
output_dir=output_dir,
)
runner.run(resume=resume)
+1 -4
View File
@@ -25,7 +25,6 @@ model:
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
# "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY)
# "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY)
# "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY)
# "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)
#
@@ -523,7 +522,7 @@ agent:
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
# - A list of individual toolsets to compose your own (see list below)
#
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot
# Supported platform keys: cli, telegram, discord, whatsapp, slack
#
# Examples:
#
@@ -552,7 +551,6 @@ agent:
# slack: hermes-slack (same as telegram)
# signal: hermes-signal (same as telegram)
# homeassistant: hermes-homeassistant (same as telegram)
# qqbot: hermes-qqbot (same as telegram)
#
platform_toolsets:
cli: [hermes-cli]
@@ -562,7 +560,6 @@ platform_toolsets:
slack: [hermes-slack]
signal: [hermes-signal]
homeassistant: [hermes-homeassistant]
qqbot: [hermes-qqbot]
# ─────────────────────────────────────────────────────────────────────────────
# Available toolsets (use these names in platform_toolsets or the toolsets list)
+62 -37
View File
@@ -988,19 +988,19 @@ def _prune_orphaned_branches(repo_root: str) -> None:
# ANSI building blocks for conversation display
_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RST = "\033[0m"
def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
"""Convert a hex color like '#268bd2' to a true-color ANSI escape."""
def _hex_to_ansi_bold(hex_color: str) -> str:
"""Convert a hex color like '#268bd2' to a bold true-color ANSI escape."""
try:
r = int(hex_color[1:3], 16)
g = int(hex_color[3:5], 16)
b = int(hex_color[5:7], 16)
prefix = "1;" if bold else ""
return f"\033[{prefix}38;2;{r};{g};{b}m"
return f"\033[1;38;2;{r};{g};{b}m"
except (ValueError, IndexError):
return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m"
return _ACCENT_ANSI_DEFAULT
class _SkinAwareAnsi:
@@ -1010,22 +1010,20 @@ class _SkinAwareAnsi:
force re-resolution after a ``/skin`` switch.
"""
def __init__(self, skin_key: str, fallback_hex: str = "#FFD700", *, bold: bool = False):
def __init__(self, skin_key: str, fallback_hex: str = "#FFD700"):
self._skin_key = skin_key
self._fallback_hex = fallback_hex
self._bold = bold
self._cached: str | None = None
def __str__(self) -> str:
if self._cached is None:
try:
from hermes_cli.skin_engine import get_active_skin
self._cached = _hex_to_ansi(
get_active_skin().get_color(self._skin_key, self._fallback_hex),
bold=self._bold,
self._cached = _hex_to_ansi_bold(
get_active_skin().get_color(self._skin_key, self._fallback_hex)
)
except Exception:
self._cached = _hex_to_ansi(self._fallback_hex, bold=self._bold)
self._cached = _hex_to_ansi_bold(self._fallback_hex)
return self._cached
def __add__(self, other: str) -> str:
@@ -1039,8 +1037,7 @@ class _SkinAwareAnsi:
self._cached = None
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
_DIM = _SkinAwareAnsi("banner_dim", "#B8860B")
_ACCENT = _SkinAwareAnsi("response_border", "#FFD700")
def _accent_hex() -> str:
@@ -4477,6 +4474,53 @@ class HermesCLI:
_ask()
return result[0]
def _interactive_provider_selection(
self, providers: list, current_model: str, current_provider: str
) -> str | None:
"""Show provider picker, return slug or None on cancel."""
choices = []
for p in providers:
count = p.get("total_models", len(p.get("models", [])))
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
if p.get("is_current"):
label += " ← current"
choices.append(label)
default_idx = next(
(i for i, p in enumerate(providers) if p.get("is_current")), 0
)
idx = self._run_curses_picker(
f"Select a provider (current: {current_model} on {current_provider}):",
choices,
default_index=default_idx,
)
if idx is None:
return None
return providers[idx]["slug"]
def _interactive_model_selection(
self, model_list: list, provider_data: dict
) -> str | None:
"""Show model picker for a given provider, return model_id or None on cancel."""
pname = provider_data.get("name", provider_data.get("slug", ""))
total = provider_data.get("total_models", len(model_list))
if not model_list:
_cprint(f"\n No models listed for {pname}.")
return self._prompt_text_input(" Enter model name manually (or Enter to cancel): ")
choices = list(model_list) + ["Enter custom model name"]
idx = self._run_curses_picker(
f"Select model from {pname} ({len(model_list)} of {total}):",
choices,
)
if idx is None:
return None
if idx < len(model_list):
return model_list[idx]
return self._prompt_text_input(" Enter model name: ")
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
@@ -4666,10 +4710,10 @@ class HermesCLI:
user_provs = None
custom_provs = None
try:
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_cli.config import load_config
cfg = load_config()
user_provs = cfg.get("providers")
custom_provs = get_compatible_custom_providers(cfg)
custom_provs = cfg.get("custom_providers")
except Exception:
pass
@@ -6159,7 +6203,6 @@ class HermesCLI:
set_active_skin(new_skin)
_ACCENT.reset() # Re-resolve ANSI color for the new skin
_DIM.reset() # Re-resolve dim/secondary ANSI color for the new skin
if save_config_value("display.skin", new_skin):
print(f" Skin set to: {new_skin} (saved)")
else:
@@ -8631,24 +8674,6 @@ class HermesCLI:
self._should_exit = True
event.app.exit()
_modal_prompt_active = Condition(
lambda: bool(self._secret_state or self._sudo_state)
)
@kb.add('escape', filter=_modal_prompt_active, eager=True)
def handle_escape_modal(event):
"""ESC cancels active secret/sudo prompts."""
if self._secret_state:
self._cancel_secret_capture()
event.app.current_buffer.reset()
event.app.invalidate()
return
if self._sudo_state:
self._sudo_state["response_queue"].put("")
self._sudo_state = None
event.app.invalidate()
return
@kb.add('c-z')
def handle_ctrl_z(event):
"""Handle Ctrl+Z - suspend process to background (Unix only)."""
@@ -8946,9 +8971,9 @@ class HermesCLI:
if cli_ref._voice_processing:
return "transcribing..."
if cli_ref._sudo_state:
return "type password (hidden), Enter to submit · ESC to skip"
return "type password (hidden), Enter to skip"
if cli_ref._secret_state:
return "type secret (hidden), Enter to submit · ESC to skip"
return "type secret (hidden), Enter to skip"
if cli_ref._approval_state:
return ""
if cli_ref._clarify_freetext:
@@ -9191,7 +9216,7 @@ class HermesCLI:
prompt = state.get("prompt") or f"Enter value for {state.get('var_name', 'secret')}"
metadata = state.get("metadata") or {}
help_text = metadata.get("help")
body = 'Enter secret below (hidden), ESC or Ctrl+C to skip'
body = 'Enter secret below (hidden), or press Enter to skip'
content_lines = [prompt, body]
if help_text:
content_lines.insert(1, str(help_text))
-15
View File
@@ -1,15 +0,0 @@
# Termux / Android dependency constraints for Hermes Agent.
#
# Usage:
# python -m pip install -e '.[termux]' -c constraints-termux.txt
#
# These pins keep the tested Android install path stable when upstream packages
# move faster than Termux-compatible wheels / sdists.
ipython<10
jedi>=0.18.1,<0.20
parso>=0.8.4,<0.9
stack-data>=0.6,<0.7
pexpect>4.3,<5
matplotlib-inline>=0.1.7,<0.2
asttokens>=2.1,<3
-2
View File
@@ -45,7 +45,6 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({
"telegram", "discord", "slack", "whatsapp", "signal",
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles",
"qqbot",
})
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
@@ -255,7 +254,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
"email": Platform.EMAIL,
"sms": Platform.SMS,
"bluebubbles": Platform.BLUEBUBBLES,
"qqbot": Platform.QQBOT,
}
platform = platform_map.get(platform_name.lower())
if not platform:
-8
View File
@@ -41,14 +41,6 @@ colors:
session_label: "#DAA520" # Session label
session_border: "#8B8682" # Session ID dim color
# TUI surfaces
status_bar_bg: "#1a1a2e" # Status / usage bar background
voice_status_bg: "#1a1a2e" # Voice-mode badge background
completion_menu_bg: "#1a1a2e" # Completion list background
completion_menu_current_bg: "#333355" # Active completion row background
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
completion_menu_meta_current_bg: "#333355" # Active completion meta background
# ── Spinner ─────────────────────────────────────────────────────────────────
# Customize the animated spinner shown during API calls and tool execution.
spinner:
+2
View File
@@ -18,7 +18,9 @@ suppress delivery.
"""
import logging
import os
import threading
from pathlib import Path
logger = logging.getLogger("hooks.boot-md")
-35
View File
@@ -66,7 +66,6 @@ class Platform(Enum):
WECOM_CALLBACK = "wecom_callback"
WEIXIN = "weixin"
BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot"
@dataclass
@@ -304,9 +303,6 @@ class GatewayConfig:
# BlueBubbles uses extra dict for local server config
elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"):
connected.append(platform)
# QQBot uses extra dict for app credentials
elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
connected.append(platform)
return connected
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
@@ -625,11 +621,6 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
ignored_threads = telegram_cfg.get("ignored_threads")
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
if isinstance(ignored_threads, list):
ignored_threads = ",".join(str(v) for v in ignored_threads)
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
@@ -1118,32 +1109,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
name=os.getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"),
)
# QQ (Official Bot API v2)
qq_app_id = os.getenv("QQ_APP_ID")
qq_client_secret = os.getenv("QQ_CLIENT_SECRET")
if qq_app_id or qq_client_secret:
if Platform.QQBOT not in config.platforms:
config.platforms[Platform.QQBOT] = PlatformConfig()
config.platforms[Platform.QQBOT].enabled = True
extra = config.platforms[Platform.QQBOT].extra
if qq_app_id:
extra["app_id"] = qq_app_id
if qq_client_secret:
extra["client_secret"] = qq_client_secret
qq_allowed_users = os.getenv("QQ_ALLOWED_USERS", "").strip()
if qq_allowed_users:
extra["allow_from"] = qq_allowed_users
qq_group_allowed = os.getenv("QQ_GROUP_ALLOWED_USERS", "").strip()
if qq_group_allowed:
extra["group_allow_from"] = qq_group_allowed
qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip()
if qq_home:
config.platforms[Platform.QQBOT].home_channel = HomeChannel(
platform=Platform.QQBOT,
chat_id=qq_home,
name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"),
)
# Session settings
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
if idle_minutes:
+1 -1
View File
@@ -12,7 +12,7 @@ import logging
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Union
from hermes_cli.config import get_hermes_home
+23 -11
View File
@@ -9,10 +9,6 @@ Resolution order (first non-None wins):
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` built-in sensible default
4. ``_GLOBAL_DEFAULTS[<key>]`` built-in global default
Exception: ``display.streaming`` is CLI-only. Gateway streaming follows the
top-level ``streaming`` config unless ``display.platforms.<platform>.streaming``
sets an explicit per-platform override.
Backward compatibility: ``display.tool_progress_overrides`` is still read as a
fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A
config migration (version bump) automatically moves the old format into the new
@@ -147,13 +143,10 @@ def resolve_display_setting(
if val is not None:
return _normalise(setting, val)
# 2. Global user setting (display.<key>). Skip display.streaming because
# that key controls only CLI terminal streaming; gateway token streaming is
# governed by the top-level streaming config plus per-platform overrides.
if setting != "streaming":
val = display_cfg.get(setting)
if val is not None:
return _normalise(setting, val)
# 2. Global user setting (display.<key>)
val = display_cfg.get(setting)
if val is not None:
return _normalise(setting, val)
# 3. Built-in platform default
plat_defaults = _PLATFORM_DEFAULTS.get(platform_key)
@@ -170,6 +163,25 @@ def resolve_display_setting(
return fallback
def get_platform_defaults(platform_key: str) -> dict[str, Any]:
"""Return the built-in default display settings for a platform.
Falls back to ``_GLOBAL_DEFAULTS`` for unknown platforms.
"""
return dict(_PLATFORM_DEFAULTS.get(platform_key, _GLOBAL_DEFAULTS))
def get_effective_display(user_config: dict, platform_key: str) -> dict[str, Any]:
"""Return the fully-resolved display settings for a platform.
Useful for status commands that want to show all effective settings.
"""
return {
key: resolve_display_setting(user_config, platform_key, key)
for key in OVERRIDEABLE_KEYS
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+1 -25
View File
@@ -3,12 +3,11 @@ Event Hook System
A lightweight event-driven system that fires handlers at key lifecycle points.
Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
- HOOK.yaml (metadata: name, description, events list, optional startup_readiness)
- HOOK.yaml (metadata: name, description, events list)
- handler.py (Python handler with async def handle(event_type, context))
Events:
- gateway:startup -- Gateway process starts
- gateway:shutdown -- Gateway process is shutting down
- session:start -- New session created (first message of a new session)
- session:end -- Session ends (user ran /new or /reset)
- session:reset -- Session reset completed (new session entry created)
@@ -32,26 +31,6 @@ from hermes_cli.config import get_hermes_home
HOOKS_DIR = get_hermes_home() / "hooks"
def _normalize_startup_readiness(hook_name: str, manifest: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Validate and normalize optional startup readiness metadata."""
readiness = manifest.get("startup_readiness")
if readiness is None:
return None
if not isinstance(readiness, dict):
print(f"[hooks] Ignoring startup_readiness for {hook_name}: expected mapping", flush=True)
return None
check_id = str(readiness.get("id", "")).strip()
if not check_id:
print(f"[hooks] Ignoring startup_readiness for {hook_name}: missing id", flush=True)
return None
return {
"id": check_id,
"required": bool(readiness.get("required", True)),
}
class HookRegistry:
"""
Discovers, loads, and fires event hooks.
@@ -83,7 +62,6 @@ class HookRegistry:
"description": "Run ~/.hermes/BOOT.md on gateway startup",
"events": ["gateway:startup"],
"path": "(builtin)",
"startup_readiness": None,
})
except Exception as e:
print(f"[hooks] Could not load built-in boot-md hook: {e}", flush=True)
@@ -124,7 +102,6 @@ class HookRegistry:
if not events:
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
continue
startup_readiness = _normalize_startup_readiness(hook_name, manifest)
# Dynamically load the handler module
spec = importlib.util.spec_from_file_location(
@@ -151,7 +128,6 @@ class HookRegistry:
"description": manifest.get("description", ""),
"events": events,
"path": str(hook_dir),
"startup_readiness": startup_readiness,
})
print(f"[hooks] Loaded hook '{hook_name}' for events: {events}", flush=True)
-2
View File
@@ -9,11 +9,9 @@ Each adapter handles:
"""
from .base import BasePlatformAdapter, MessageEvent, SendResult
from .qqbot import QQAdapter
__all__ = [
"BasePlatformAdapter",
"MessageEvent",
"SendResult",
"QQAdapter",
]
-23
View File
@@ -10,7 +10,6 @@ Exposes an HTTP server with endpoints:
- POST /v1/runs start a run, returns run_id immediately (202)
- GET /v1/runs/{run_id}/events SSE stream of structured lifecycle events
- GET /health health check
- GET /health/detailed rich status for cross-container dashboard probing
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
AnythingLLM, NextChat, ChatBox, etc.) can connect to hermes-agent
@@ -566,27 +565,6 @@ class APIServerAdapter(BasePlatformAdapter):
"""GET /health — simple health check."""
return web.json_response({"status": "ok", "platform": "hermes-agent"})
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
"""GET /health/detailed — rich status for cross-container dashboard probing.
Returns gateway state, connected platforms, PID, and uptime so the
dashboard can display full status without needing a shared PID file or
/proc access. No authentication required.
"""
from gateway.status import read_runtime_status
runtime = read_runtime_status() or {}
return web.json_response({
"status": "ok",
"platform": "hermes-agent",
"gateway_state": runtime.get("gateway_state"),
"platforms": runtime.get("platforms", {}),
"active_agents": runtime.get("active_agents", 0),
"exit_reason": runtime.get("exit_reason"),
"updated_at": runtime.get("updated_at"),
"pid": os.getpid(),
})
async def _handle_models(self, request: "web.Request") -> "web.Response":
"""GET /v1/models — return hermes-agent as an available model."""
auth_err = self._check_auth(request)
@@ -1805,7 +1783,6 @@ class APIServerAdapter(BasePlatformAdapter):
self._app = web.Application(middlewares=mws)
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
self._app.router.add_get("/v1/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
+32 -24
View File
@@ -224,21 +224,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
host = "localhost"
return f"http://{host}:{self.webhook_port}{self.webhook_path}"
@property
def _webhook_register_url(self) -> str:
"""Webhook URL registered with BlueBubbles, including the password as
a query param so inbound webhook POSTs carry credentials.
BlueBubbles posts events to the exact URL registered via
``/api/v1/webhook``. Its webhook registration API does not support
custom headers, so embedding the password in the URL is the only
way to authenticate inbound webhooks without disabling auth.
"""
base = self._webhook_url
if self.password:
return f"{base}?password={quote(self.password, safe='')}"
return base
async def _find_registered_webhooks(self, url: str) -> list:
"""Return list of BB webhook entries matching *url*."""
try:
@@ -260,7 +245,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not self.client:
return False
webhook_url = self._webhook_register_url
webhook_url = self._webhook_url
# Crash resilience — reuse an existing registration if present
existing = await self._find_registered_webhooks(webhook_url)
@@ -272,7 +257,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
payload = {
"url": webhook_url,
"events": ["new-message", "updated-message"],
"events": ["new-message", "updated-message", "message"],
}
try:
@@ -307,7 +292,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not self.client:
return False
webhook_url = self._webhook_register_url
webhook_url = self._webhook_url
removed = False
try:
@@ -619,6 +604,35 @@ class BlueBubblesAdapter(BasePlatformAdapter):
# Tapback reactions
# ------------------------------------------------------------------
async def send_reaction(
self,
chat_id: str,
message_guid: str,
reaction: str,
part_index: int = 0,
) -> SendResult:
"""Send a tapback reaction (requires Private API helper)."""
if not self._private_api_enabled or not self._helper_connected:
return SendResult(
success=False, error="Private API helper not connected"
)
guid = await self._resolve_chat_guid(chat_id)
if not guid:
return SendResult(success=False, error=f"Chat not found: {chat_id}")
try:
res = await self._api_post(
"/api/v1/message/react",
{
"chatGuid": guid,
"selectedMessageGuid": message_guid,
"reaction": reaction,
"partIndex": part_index,
},
)
return SendResult(success=True, raw_response=res)
except Exception as exc:
return SendResult(success=False, error=str(exc))
# ------------------------------------------------------------------
# Chat info
# ------------------------------------------------------------------
@@ -850,12 +864,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
payload.get("chat_guid"),
payload.get("guid"),
)
# Fallback: BlueBubbles v1.9+ webhook payloads omit top-level chatGuid;
# the chat GUID is nested under data.chats[0].guid instead.
if not chat_guid:
_chats = record.get("chats") or []
if _chats and isinstance(_chats[0], dict):
chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid")
chat_identifier = self._value(
record.get("chatIdentifier"),
record.get("identifier"),
+1
View File
@@ -21,6 +21,7 @@ import asyncio
import logging
import os
import re
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
+28 -79
View File
@@ -10,6 +10,7 @@ Uses discord.py library for:
"""
import asyncio
import json
import logging
import os
import struct
@@ -18,6 +19,7 @@ import tempfile
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Callable, Dict, Optional, Any
logger = logging.getLogger(__name__)
@@ -1736,90 +1738,46 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_btw(interaction: discord.Interaction, question: str):
await self._run_simple_slash(interaction, f"/btw {question}")
# Register skills under a single /skill command group with category
# subcommand groups. This uses 1 top-level slot instead of N,
# supporting up to 25 categories × 25 skills = 625 skills.
self._register_skill_group(tree)
def _register_skill_group(self, tree) -> None:
"""Register a ``/skill`` command group with category subcommand groups.
Skills are organized by their directory category under ``SKILLS_DIR``.
Each category becomes a subcommand group; root-level skills become
direct subcommands. Discord supports 25 subcommand groups × 25
subcommands each = 625 skills well beyond the old 100-command cap.
"""
# Register installed skills as native slash commands (parity with
# Telegram, which uses telegram_menu_commands() in commands.py).
# Discord allows up to 100 application commands globally.
_DISCORD_CMD_LIMIT = 100
try:
from hermes_cli.commands import discord_skill_commands_by_category
from hermes_cli.commands import discord_skill_commands
existing_names = set()
try:
existing_names = {cmd.name for cmd in tree.get_commands()}
except Exception:
pass
existing_names = {cmd.name for cmd in tree.get_commands()}
remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names))
categories, uncategorized, hidden = discord_skill_commands_by_category(
skill_entries, skipped = discord_skill_commands(
max_slots=remaining_slots,
reserved_names=existing_names,
)
if not categories and not uncategorized:
return
for discord_name, description, cmd_key in skill_entries:
# Closure factory to capture cmd_key per iteration
def _make_skill_handler(_key: str):
async def _skill_slash(interaction: discord.Interaction, args: str = ""):
await self._run_simple_slash(interaction, f"{_key} {args}".strip())
return _skill_slash
skill_group = discord.app_commands.Group(
name="skill",
description="Run a Hermes skill",
)
handler = _make_skill_handler(cmd_key)
handler.__name__ = f"skill_{discord_name.replace('-', '_')}"
# ── Helper: build a callback for a skill command key ──
def _make_handler(_key: str):
@discord.app_commands.describe(args="Optional arguments for the skill")
async def _handler(interaction: discord.Interaction, args: str = ""):
await self._run_simple_slash(interaction, f"{_key} {args}".strip())
_handler.__name__ = f"skill_{_key.lstrip('/').replace('-', '_')}"
return _handler
# ── Uncategorized (root-level) skills → direct subcommands ──
for discord_name, description, cmd_key in uncategorized:
cmd = discord.app_commands.Command(
name=discord_name,
description=description or f"Run the {discord_name} skill",
callback=_make_handler(cmd_key),
description=description,
callback=handler,
)
skill_group.add_command(cmd)
discord.app_commands.describe(args="Optional arguments for the skill")(cmd)
tree.add_command(cmd)
# ── Category subcommand groups ──
for cat_name in sorted(categories):
cat_desc = f"{cat_name.replace('-', ' ').title()} skills"
if len(cat_desc) > 100:
cat_desc = cat_desc[:97] + "..."
cat_group = discord.app_commands.Group(
name=cat_name,
description=cat_desc,
parent=skill_group,
)
for discord_name, description, cmd_key in categories[cat_name]:
cmd = discord.app_commands.Command(
name=discord_name,
description=description or f"Run the {discord_name} skill",
callback=_make_handler(cmd_key),
)
cat_group.add_command(cmd)
tree.add_command(skill_group)
total = sum(len(v) for v in categories.values()) + len(uncategorized)
logger.info(
"[%s] Registered /skill group: %d skill(s) across %d categories"
" + %d uncategorized",
self.name, total, len(categories), len(uncategorized),
)
if hidden:
if skipped:
logger.warning(
"[%s] %d skill(s) not registered (Discord subcommand limits)",
self.name, hidden,
"[%s] Discord slash command limit reached (%d): %d skill(s) not registered",
self.name, _DISCORD_CMD_LIMIT, skipped,
)
except Exception as exc:
logger.warning("[%s] Failed to register /skill group: %s", self.name, exc)
logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc)
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
"""Build a MessageEvent from a Discord slash command interaction."""
@@ -2518,14 +2476,6 @@ class DiscordAdapter(BasePlatformAdapter):
_parent_id = str(getattr(_chan, "parent_id", "") or "")
_chan_id = str(getattr(_chan, "id", ""))
_skills = self._resolve_channel_skills(_chan_id, _parent_id or None)
reply_to_id = None
reply_to_text = None
if message.reference:
reply_to_id = str(message.reference.message_id)
if message.reference.resolved:
reply_to_text = getattr(message.reference.resolved, "content", None) or None
event = MessageEvent(
text=event_text,
message_type=msg_type,
@@ -2534,8 +2484,7 @@ class DiscordAdapter(BasePlatformAdapter):
message_id=str(message.id),
media_urls=media_urls,
media_types=media_types,
reply_to_message_id=reply_to_id,
reply_to_text=reply_to_text,
reply_to_message_id=str(message.reference.message_id) if message.reference else None,
timestamp=message.created_at,
auto_skill=_skills,
)
+87 -109
View File
@@ -72,10 +72,7 @@ try:
UpdateMessageRequestBody,
)
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard,
P2CardActionTriggerResponse,
)
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
from lark_oapi.ws import Client as FeishuWSClient
@@ -83,7 +80,6 @@ try:
except ImportError:
FEISHU_AVAILABLE = False
lark = None # type: ignore[assignment]
CallBackCard = None # type: ignore[assignment]
P2CardActionTriggerResponse = None # type: ignore[assignment]
EventDispatcherHandler = None # type: ignore[assignment]
FeishuWSClient = None # type: ignore[assignment]
@@ -173,19 +169,6 @@ _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS = 30 # max seconds to read request
_FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25 # consecutive error responses before WARNING log
_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60 # anomaly tracker TTL (6 hours) — matches openclaw
_FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup window (15 min)
_APPROVAL_CHOICE_MAP: Dict[str, str] = {
"approve_once": "once",
"approve_session": "session",
"approve_always": "always",
"deny": "deny",
}
_APPROVAL_LABEL_MAP: Dict[str, str] = {
"once": "Approved once",
"session": "Approved for session",
"always": "Approved permanently",
"deny": "Denied",
}
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
_FEISHU_ACK_EMOJI = "OK"
@@ -447,6 +430,14 @@ def _build_markdown_post_payload(content: str) -> str:
)
def parse_feishu_post_content(raw_content: str) -> FeishuPostParseResult:
try:
parsed = json.loads(raw_content) if raw_content else {}
except json.JSONDecodeError:
return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT)
return parse_feishu_post_payload(parsed)
def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult:
resolved = _resolve_post_payload(payload)
if not resolved:
@@ -1507,12 +1498,14 @@ class FeishuAdapter(BasePlatformAdapter):
logger.warning("[Feishu] send_exec_approval failed: %s", exc)
return SendResult(success=False, error=str(exc))
@staticmethod
def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:
"""Build raw card JSON for a resolved approval action."""
async def _update_approval_card(
self, message_id: str, label: str, user_name: str, choice: str,
) -> None:
"""Replace the approval card with a resolved status card."""
if not self._client or not message_id:
return
icon = "" if choice == "deny" else ""
label = _APPROVAL_LABEL_MAP.get(choice, "Resolved")
return {
card = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"content": f"{icon} {label}", "tag": "plain_text"},
@@ -1525,6 +1518,13 @@ class FeishuAdapter(BasePlatformAdapter):
},
],
}
try:
payload = json.dumps(card, ensure_ascii=False)
body = self._build_update_message_body(msg_type="interactive", content=payload)
request = self._build_update_message_request(message_id=message_id, request_body=body)
await asyncio.to_thread(self._client.im.v1.message.update, request)
except Exception as exc:
logger.warning("[Feishu] Failed to update approval card %s: %s", message_id, exc)
async def send_voice(
self,
@@ -1853,82 +1853,20 @@ class FeishuAdapter(BasePlatformAdapter):
future.add_done_callback(self._log_background_failure)
def _on_card_action_trigger(self, data: Any) -> Any:
"""Handle card-action callback from the Feishu SDK (synchronous).
For approval actions: parses the event once, returns the resolved card
inline (the only reliable way to sync all clients), and schedules a
lightweight async method to actually unblock the agent.
For other card actions: delegates to ``_handle_card_action_event``.
"""
"""Schedule Feishu card actions on the adapter loop and acknowledge immediately."""
loop = self._loop
if not self._loop_accepts_callbacks(loop):
if loop is None or bool(getattr(loop, "is_closed", lambda: False)()):
logger.warning("[Feishu] Dropping card action before adapter loop is ready")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
event = getattr(data, "event", None)
action = getattr(event, "action", None)
action_value = getattr(action, "value", {}) or {}
hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
if hermes_action:
return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)
self._submit_on_loop(loop, self._handle_card_action_event(data))
else:
future = asyncio.run_coroutine_threadsafe(
self._handle_card_action_event(data),
loop,
)
future.add_done_callback(self._log_background_failure)
if P2CardActionTriggerResponse is None:
return None
return P2CardActionTriggerResponse()
@staticmethod
def _loop_accepts_callbacks(loop: Any) -> bool:
"""Return True when the adapter loop can accept thread-safe submissions."""
return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
def _submit_on_loop(self, loop: Any, coro: Any) -> None:
"""Schedule background work on the adapter loop with shared failure logging."""
future = asyncio.run_coroutine_threadsafe(coro, loop)
future.add_done_callback(self._log_background_failure)
def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:
"""Schedule approval resolution and build the synchronous callback response."""
approval_id = action_value.get("approval_id")
if approval_id is None:
logger.debug("[Feishu] Card action missing approval_id, ignoring")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
operator = getattr(event, "operator", None)
open_id = str(getattr(operator, "open_id", "") or "")
user_name = self._get_cached_sender_name(open_id) or open_id
self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name))
if P2CardActionTriggerResponse is None:
return None
response = P2CardActionTriggerResponse()
if CallBackCard is not None:
card = CallBackCard()
card.type = "raw"
card.data = self._build_resolved_approval_card(choice=choice, user_name=user_name)
response.card = card
return response
async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:
"""Pop approval state and unblock the waiting agent thread."""
state = self._approval_state.pop(approval_id, None)
if not state:
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
return
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(state["session_key"], choice)
logger.info(
"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, state["session_key"], choice, user_name,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
async def _handle_reaction_event(self, event_type: str, data: Any) -> None:
"""Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event."""
if not self._client:
@@ -2020,6 +1958,51 @@ class FeishuAdapter(BasePlatformAdapter):
action_tag = str(getattr(action, "tag", "") or "button")
action_value = getattr(action, "value", {}) or {}
# --- Exec approval button intercept ---
hermes_action = action_value.get("hermes_action") if isinstance(action_value, dict) else None
if hermes_action:
approval_id = action_value.get("approval_id")
state = self._approval_state.pop(approval_id, None)
if not state:
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
return
choice_map = {
"approve_once": "once",
"approve_session": "session",
"approve_always": "always",
"deny": "deny",
}
choice = choice_map.get(hermes_action, "deny")
label_map = {
"once": "Approved once",
"session": "Approved for session",
"always": "Approved permanently",
"deny": "Denied",
}
label = label_map.get(choice, "Resolved")
# Resolve sender name for the status card
sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)
sender_profile = await self._resolve_sender_profile(sender_id)
user_name = sender_profile.get("user_name") or open_id
# Resolve the approval — unblocks the agent thread
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(state["session_key"], choice)
logger.info(
"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, state["session_key"], choice, user_name,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
# Update the card to show the decision
await self._update_approval_card(state.get("message_id", ""), label, user_name, choice)
return
synthetic_text = f"/card {action_tag}"
if action_value:
try:
@@ -2705,6 +2688,12 @@ class FeishuAdapter(BasePlatformAdapter):
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
return MessageType.TEXT
def _normalize_inbound_text(self, text: str) -> str:
"""Strip Feishu mention placeholders from inbound text."""
text = _MENTION_RE.sub(" ", text or "")
text = _MULTISPACE_RE.sub(" ", text)
return text.strip()
async def _maybe_extract_text_document(self, cached_path: str, media_type: str) -> str:
if not cached_path or not media_type.startswith("text/"):
return ""
@@ -2922,19 +2911,6 @@ class FeishuAdapter(BasePlatformAdapter):
"user_id_alt": union_id,
}
def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:
"""Return a cached sender name only while its TTL is still valid."""
if not sender_id:
return None
cached = self._sender_name_cache.get(sender_id)
if cached is None:
return None
name, expire_at = cached
if time.time() < expire_at:
return name
self._sender_name_cache.pop(sender_id, None)
return None
async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:
"""Fetch the sender's display name from the Feishu contact API with a 10-minute cache.
@@ -2947,9 +2923,11 @@ class FeishuAdapter(BasePlatformAdapter):
if not trimmed:
return None
now = time.time()
cached_name = self._get_cached_sender_name(trimmed)
if cached_name is not None:
return cached_name
cached = self._sender_name_cache.get(trimmed)
if cached is not None:
name, expire_at = cached
if now < expire_at:
return name
try:
from lark_oapi.api.contact.v3 import GetUserRequest # lazy import
if trimmed.startswith("ou_"):
+59 -10
View File
@@ -25,6 +25,7 @@ Environment variables:
from __future__ import annotations
import asyncio
import json
import logging
import mimetypes
import os
@@ -958,16 +959,6 @@ class MatrixAdapter(BasePlatformAdapter):
sync_data = await client.sync(
since=next_batch, timeout=30000,
)
# nio returns SyncError objects (not exceptions) for auth
# failures like M_UNKNOWN_TOKEN. Detect and stop immediately.
_sync_msg = getattr(sync_data, "message", None)
if _sync_msg and isinstance(_sync_msg, str):
_lower = _sync_msg.lower()
if "m_unknown_token" in _lower or "unknown_token" in _lower:
logger.error("Matrix: permanent auth error from sync: %s — stopping", _sync_msg)
return
if isinstance(sync_data, dict):
# Update joined rooms from sync response.
rooms_join = sync_data.get("rooms", {}).get("join", {})
@@ -1621,6 +1612,52 @@ class MatrixAdapter(BasePlatformAdapter):
logger.warning("Matrix: redact error: %s", exc)
return False
# ------------------------------------------------------------------
# Room history
# ------------------------------------------------------------------
async def fetch_room_history(
self,
room_id: str,
limit: int = 50,
start: str = "",
) -> list:
"""Fetch recent messages from a room."""
if not self._client:
return []
try:
resp = await self._client.get_messages(
RoomID(room_id),
direction=PaginationDirection.BACKWARD,
from_token=SyncToken(start) if start else None,
limit=limit,
)
except Exception as exc:
logger.warning("Matrix: get_messages failed for %s: %s", room_id, exc)
return []
if not resp:
return []
events = getattr(resp, "chunk", []) or (resp.get("chunk", []) if isinstance(resp, dict) else [])
messages = []
for event in reversed(events):
body = ""
content = getattr(event, "content", None)
if content:
if hasattr(content, "body"):
body = content.body or ""
elif isinstance(content, dict):
body = content.get("body", "")
messages.append({
"event_id": str(getattr(event, "event_id", "")),
"sender": str(getattr(event, "sender", "")),
"body": body,
"timestamp": getattr(event, "timestamp", 0) or getattr(event, "server_timestamp", 0),
"type": type(event).__name__,
})
return messages
# ------------------------------------------------------------------
# Room creation & management
# ------------------------------------------------------------------
@@ -1724,6 +1761,18 @@ class MatrixAdapter(BasePlatformAdapter):
except Exception as exc:
return SendResult(success=False, error=str(exc))
async def send_emote(
self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an emote message (/me style action)."""
return await self._send_simple_message(chat_id, text, "m.emote")
async def send_notice(
self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a notice message (bot-appropriate, non-alerting)."""
return await self._send_simple_message(chat_id, text, "m.notice")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+16
View File
@@ -17,6 +17,7 @@ import json
import logging
import os
import random
import re
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -780,6 +781,21 @@ class SignalAdapter(BasePlatformAdapter):
# Typing Indicators
# ------------------------------------------------------------------
async def _start_typing_indicator(self, chat_id: str) -> None:
"""Start a typing indicator loop for a chat."""
if chat_id in self._typing_tasks:
return # Already running
async def _typing_loop():
try:
while True:
await self.send_typing(chat_id)
await asyncio.sleep(TYPING_INTERVAL)
except asyncio.CancelledError:
pass
self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop())
async def _stop_typing_indicator(self, chat_id: str) -> None:
"""Stop a typing indicator loop for a chat."""
task = self._typing_tasks.pop(chat_id, None)
+2 -41
View File
@@ -1916,20 +1916,9 @@ class TelegramAdapter(BasePlatformAdapter):
)
# 9) Convert blockquotes: > at line start → protect > from escaping
# Handle both regular blockquotes (> text) and expandable blockquotes
# (Telegram MarkdownV2: **> for expandable start, || to end the quote)
def _convert_blockquote(m):
prefix = m.group(1) # >, >>, >>>, **>, or **>> etc.
content = m.group(2)
# Check if content ends with || (expandable blockquote end marker)
# In this case, preserve the trailing || unescaped for Telegram
if prefix.startswith('**') and content.endswith('||'):
return _ph(f'{prefix} {_escape_mdv2(content[:-2])}||')
return _ph(f'{prefix} {_escape_mdv2(content)}')
text = re.sub(
r'^((?:\*\*)?>{1,3}) (.+)$',
_convert_blockquote,
r'^(>{1,3}) (.+)$',
lambda m: _ph(m.group(1) + ' ' + _escape_mdv2(m.group(2))),
text,
flags=re.MULTILINE,
)
@@ -2002,27 +1991,6 @@ class TelegramAdapter(BasePlatformAdapter):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _telegram_ignored_threads(self) -> set[int]:
raw = self.config.extra.get("ignored_threads")
if raw is None:
raw = os.getenv("TELEGRAM_IGNORED_THREADS", "")
if isinstance(raw, list):
values = raw
else:
values = str(raw).split(",")
ignored: set[int] = set()
for value in values:
text = str(value).strip()
if not text:
continue
try:
ignored.add(int(text))
except (TypeError, ValueError):
logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value)
return ignored
def _compile_mention_patterns(self) -> List[re.Pattern]:
"""Compile optional regex wake-word patterns for group triggers."""
patterns = self.config.extra.get("mention_patterns")
@@ -2134,13 +2102,6 @@ class TelegramAdapter(BasePlatformAdapter):
"""
if not self._is_group_chat(message):
return True
thread_id = getattr(message, "message_thread_id", None)
if thread_id is not None:
try:
if int(thread_id) in self._telegram_ignored_threads():
return False
except (TypeError, ValueError):
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
return True
if not self._telegram_require_mention():
+1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import ipaddress
import logging
import os
import socket
from typing import Iterable, Optional
+1 -1
View File
@@ -27,6 +27,7 @@ import hashlib
import hmac
import json
import logging
import os
import re
import subprocess
import time
@@ -203,7 +204,6 @@ class WebhookAdapter(BasePlatformAdapter):
"wecom_callback",
"weixin",
"bluebubbles",
"qqbot",
):
return await self._deliver_cross_platform(
deliver_type, content, delivery
+1
View File
@@ -37,6 +37,7 @@ import logging
import mimetypes
import os
import re
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
+46 -481
View File
@@ -1391,65 +1391,6 @@ class GatewayRunner:
except Exception as e:
logger.debug("Failed interrupting agent during shutdown: %s", e)
async def _notify_active_sessions_of_shutdown(self) -> None:
"""Send a notification to every chat with an active agent.
Called at the very start of stop() adapters are still connected so
messages can be delivered. Best-effort: individual send failures are
logged and swallowed so they never block the shutdown sequence.
"""
active = self._snapshot_running_agents()
if not active:
return
action = "restarting" if self._restart_requested else "shutting down"
hint = (
"Your current task will be interrupted. "
"Use /retry after restart to continue."
if self._restart_requested
else "Your current task will be interrupted."
)
msg = f"⚠️ Gateway {action}{hint}"
notified: set = set()
for session_key in active:
# Parse platform + chat_id from the session key.
# Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]
parts = session_key.split(":")
if len(parts) < 5:
continue
platform_str = parts[2]
chat_id = parts[4]
# Deduplicate: one notification per chat, even if multiple
# sessions (different users/threads) share the same chat.
dedup_key = (platform_str, chat_id)
if dedup_key in notified:
continue
try:
platform = Platform(platform_str)
adapter = self.adapters.get(platform)
if not adapter:
continue
# Include thread_id if present so the message lands in the
# correct forum topic / thread.
thread_id = parts[5] if len(parts) > 5 else None
metadata = {"thread_id": thread_id} if thread_id else None
await adapter.send(chat_id, msg, metadata=metadata)
notified.add(dedup_key)
logger.info(
"Sent shutdown notification to %s:%s",
platform_str, chat_id,
)
except Exception as e:
logger.debug(
"Failed to send shutdown notification to %s:%s: %s",
platform_str, chat_id, e,
)
def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:
for agent in active_agents.values():
try:
@@ -1540,7 +1481,7 @@ class GatewayRunner:
pass
try:
from gateway.status import write_runtime_status
write_runtime_status(gateway_state="starting", exit_reason=None, startup_checks={})
write_runtime_status(gateway_state="starting", exit_reason=None)
except Exception:
pass
@@ -1558,7 +1499,6 @@ class GatewayRunner:
"WECOM_CALLBACK_ALLOWED_USERS",
"WEIXIN_ALLOWED_USERS",
"BLUEBUBBLES_ALLOWED_USERS",
"QQ_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
)
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
@@ -1572,8 +1512,7 @@ class GatewayRunner:
"WECOM_ALLOW_ALL_USERS",
"WECOM_CALLBACK_ALLOW_ALL_USERS",
"WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS")
"BLUEBUBBLES_ALLOW_ALL_USERS")
)
if not _any_allowlist and not _allow_all:
logger.warning(
@@ -1582,23 +1521,8 @@ class GatewayRunner:
"or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id)."
)
# Discover plugins before hooks so plugin-owned hook bundles can
# participate in this same startup cycle.
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception as e:
logger.warning("Plugin discovery during gateway startup failed: %s", e)
# Discover and load event hooks
self.hooks.discover_and_load()
try:
from gateway.status import reset_startup_checks
reset_startup_checks(self.hooks.loaded_hooks)
except Exception as e:
logger.warning("Startup readiness initialization failed: %s", e)
# Recover background processes from checkpoint (crash recovery)
try:
@@ -2092,10 +2016,6 @@ class GatewayRunner:
self._running = False
self._draining = True
# Notify all chats with active agents BEFORE draining.
# Adapters are still connected here, so messages can be sent.
await self._notify_active_sessions_of_shutdown()
timeout = self._restart_drain_timeout
active_agents, timed_out = await self._drain_active_agents(timeout)
if timed_out:
@@ -2119,11 +2039,6 @@ class GatewayRunner:
logger.error("Failed to launch detached gateway restart: %s", e)
self._finalize_shutdown_agents(active_agents)
await self.hooks.emit("gateway:shutdown", {
"restart": self._restart_requested,
"service_restart": self._restart_via_service,
"detached_restart": self._restart_detached,
})
for platform, adapter in list(self.adapters.items()):
try:
@@ -2171,23 +2086,12 @@ class GatewayRunner:
# Write a clean-shutdown marker so the next startup knows this
# wasn't a crash. suspend_recently_active() only needs to run
# after unexpected exits. However, if the drain timed out and
# agents were force-interrupted, their sessions may be in an
# incomplete state (trailing tool response, no final assistant
# message). Skip the marker in that case so the next startup
# suspends those sessions — giving users a clean slate instead
# of resuming a half-finished tool loop.
if not timed_out:
try:
(_hermes_home / ".clean_shutdown").touch()
except Exception:
pass
else:
logger.info(
"Skipping .clean_shutdown marker — drain timed out with "
"interrupted agents; next startup will suspend recently "
"active sessions."
)
# after unexpected exits — graceful shutdowns already drain
# active agents, so there's no stuck-session risk.
try:
(_hermes_home / ".clean_shutdown").touch()
except Exception:
pass
if self._restart_requested and self._restart_via_service:
self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
@@ -2351,15 +2255,8 @@ class GatewayRunner:
return None
return BlueBubblesAdapter(config)
elif platform == Platform.QQBOT:
from gateway.platforms.qqbot import QQAdapter, check_qq_requirements
if not check_qq_requirements():
logger.warning("QQBot: aiohttp/httpx missing or QQ_APP_ID/QQ_CLIENT_SECRET not configured")
return None
return QQAdapter(config)
return None
def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
@@ -2399,7 +2296,6 @@ class GatewayRunner:
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
@@ -2417,7 +2313,6 @@ class GatewayRunner:
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
}
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
@@ -2651,8 +2546,11 @@ class GatewayRunner:
self._pending_messages.pop(_quick_key, None)
if _quick_key in self._running_agents:
del self._running_agents[_quick_key]
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
return "⚡ Stopped. You can continue this session."
# Mark session suspended so the next message starts fresh
# instead of resuming the stuck context (#7536).
self.session_store.suspend_session(_quick_key)
logger.info("HARD STOP for session %s — suspended, session lock released", _quick_key[:20])
return "⚡ Force-stopped. The session is suspended — your next message will start fresh."
# /reset and /new must bypass the running-agent guard so they
# actually dispatch as commands instead of being queued as user
@@ -3432,26 +3330,21 @@ class GatewayRunner:
# Must run after runtime resolution so _hyg_base_url is set.
if _hyg_config_context_length is None and _hyg_base_url:
try:
try:
from hermes_cli.config import get_compatible_custom_providers as _gw_gcp
_hyg_custom_providers = _gw_gcp(_hyg_data)
except Exception:
_hyg_custom_providers = _hyg_data.get("custom_providers")
if not isinstance(_hyg_custom_providers, list):
_hyg_custom_providers = []
for _cp in _hyg_custom_providers:
if not isinstance(_cp, dict):
continue
_cp_url = (_cp.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
_cp_models = _cp.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(_hyg_model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
_hyg_config_context_length = int(_cp_ctx)
break
_hyg_custom_providers = _hyg_data.get("custom_providers")
if isinstance(_hyg_custom_providers, list):
for _cp in _hyg_custom_providers:
if not isinstance(_cp, dict):
continue
_cp_url = (_cp.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
_cp_models = _cp.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(_hyg_model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
_hyg_config_context_length = int(_cp_ctx)
break
except (TypeError, ValueError):
pass
except Exception:
@@ -4065,11 +3958,6 @@ class GatewayRunner:
_cached = self._agent_cache.get(session_key)
_old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None
if _old_agent is not None:
try:
if hasattr(_old_agent, "shutdown_memory_provider"):
_old_agent.shutdown_memory_provider()
except Exception:
pass
try:
if hasattr(_old_agent, "close"):
_old_agent.close()
@@ -4227,7 +4115,9 @@ class GatewayRunner:
only through normal command dispatch (no running agent) or as a
fallback. Force-clean the session lock in all cases for safety.
The session is preserved so the user can continue the conversation.
When there IS a running/pending agent, the session is also marked
as *suspended* so the next message starts a fresh session instead
of resuming the stuck context (#7536).
"""
source = event.source
session_entry = self.session_store.get_or_create_session(source)
@@ -4238,15 +4128,17 @@ class GatewayRunner:
# Force-clean the sentinel so the session is unlocked.
if session_key in self._running_agents:
del self._running_agents[session_key]
logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20])
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
self.session_store.suspend_session(session_key)
logger.info("HARD STOP (pending) for session %s — suspended, sentinel cleared", session_key[:20])
return "⚡ Force-stopped. The agent was still starting — your next message will start fresh."
if agent:
agent.interrupt("Stop requested")
# Force-clean the session lock so a truly hung agent doesn't
# keep it locked forever.
if session_key in self._running_agents:
del self._running_agents[session_key]
return "⚡ Stopped. You can continue this session."
self.session_store.suspend_session(session_key)
return "⚡ Force-stopped. Your next message will start a fresh session."
else:
return "No active task to stop."
@@ -4404,11 +4296,7 @@ class GatewayRunner:
current_provider = model_cfg.get("provider", current_provider)
current_base_url = model_cfg.get("base_url", "")
user_provs = cfg.get("providers")
try:
from hermes_cli.config import get_compatible_custom_providers
custom_provs = get_compatible_custom_providers(cfg)
except Exception:
custom_provs = cfg.get("custom_providers")
custom_provs = cfg.get("custom_providers")
except Exception:
pass
@@ -6406,7 +6294,7 @@ class GatewayRunner:
"""Handle /reload-mcp command -- disconnect and reconnect all MCP servers."""
loop = asyncio.get_event_loop()
try:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
# Capture old server names before shutdown
with _lock:
@@ -6579,7 +6467,7 @@ class GatewayRunner:
Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP,
Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX,
Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK,
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL,
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL,
})
async def _handle_debug_command(self, event: MessageEvent) -> str:
@@ -7502,263 +7390,6 @@ class GatewayRunner:
with _lock:
self._agent_cache.pop(session_key, None)
# ------------------------------------------------------------------
# Proxy mode: forward messages to a remote Hermes API server
# ------------------------------------------------------------------
def _get_proxy_url(self) -> Optional[str]:
"""Return the proxy URL if proxy mode is configured, else None.
Checks GATEWAY_PROXY_URL env var first (convenient for Docker),
then ``gateway.proxy_url`` in config.yaml.
"""
url = os.getenv("GATEWAY_PROXY_URL", "").strip()
if url:
return url.rstrip("/")
cfg = _load_gateway_config()
url = (cfg.get("gateway") or {}).get("proxy_url", "").strip()
if url:
return url.rstrip("/")
return None
async def _run_agent_via_proxy(
self,
message: str,
context_prompt: str,
history: List[Dict[str, Any]],
source: "SessionSource",
session_id: str,
session_key: str = None,
event_message_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Forward the message to a remote Hermes API server instead of
running a local AIAgent.
When ``GATEWAY_PROXY_URL`` (or ``gateway.proxy_url`` in config.yaml)
is set, the gateway becomes a thin relay: it handles platform I/O
(encryption, threading, media) and delegates all agent work to the
remote server via ``POST /v1/chat/completions`` with SSE streaming.
This lets a Docker container handle Matrix E2EE while the actual
agent runs on the host with full access to local files, memory,
skills, and a unified session store.
"""
try:
from aiohttp import ClientSession as _AioClientSession, ClientTimeout
except ImportError:
return {
"final_response": "⚠️ Proxy mode requires aiohttp. Install with: pip install aiohttp",
"messages": [],
"api_calls": 0,
"tools": [],
}
proxy_url = self._get_proxy_url()
if not proxy_url:
return {
"final_response": "⚠️ Proxy URL not configured (GATEWAY_PROXY_URL or gateway.proxy_url)",
"messages": [],
"api_calls": 0,
"tools": [],
}
proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip()
# Build messages in OpenAI chat format --------------------------
#
# The remote api_server can maintain session continuity via
# X-Hermes-Session-Id, so it loads its own history. We only
# need to send the current user message. If the remote has
# no history for this session yet, include what we have locally
# so the first exchange has context.
#
# We always include the current message. For history, send a
# compact version (text-only user/assistant turns) — the remote
# handles tool replay and system prompts.
api_messages: List[Dict[str, str]] = []
if context_prompt:
api_messages.append({"role": "system", "content": context_prompt})
for msg in history:
role = msg.get("role")
content = msg.get("content")
if role in ("user", "assistant") and content:
api_messages.append({"role": role, "content": content})
api_messages.append({"role": "user", "content": message})
# HTTP headers ---------------------------------------------------
headers: Dict[str, str] = {"Content-Type": "application/json"}
if proxy_key:
headers["Authorization"] = f"Bearer {proxy_key}"
if session_id:
headers["X-Hermes-Session-Id"] = session_id
body = {
"model": "hermes-agent",
"messages": api_messages,
"stream": True,
}
# Set up platform streaming if available -------------------------
_stream_consumer = None
_scfg = getattr(getattr(self, "config", None), "streaming", None)
if _scfg is None:
from gateway.config import StreamingConfig
_scfg = StreamingConfig()
platform_key = _platform_config_key(source.platform)
user_config = _load_gateway_config()
from gateway.display_config import resolve_display_setting
_plat_streaming = resolve_display_setting(
user_config, platform_key, "streaming"
)
_streaming_enabled = (
_scfg.enabled and _scfg.transport != "off"
if _plat_streaming is None
else bool(_plat_streaming)
)
if source.thread_id:
_thread_metadata: Optional[Dict[str, Any]] = {"thread_id": source.thread_id}
else:
_thread_metadata = None
if _streaming_enabled:
try:
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
from gateway.config import Platform
_adapter = self.adapters.get(source.platform)
if _adapter:
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
if source.platform == Platform.MATRIX:
_effective_cursor = ""
_consumer_cfg = StreamConsumerConfig(
edit_interval=_scfg.edit_interval,
buffer_threshold=_scfg.buffer_threshold,
cursor=_effective_cursor,
)
_stream_consumer = GatewayStreamConsumer(
adapter=_adapter,
chat_id=source.chat_id,
config=_consumer_cfg,
metadata=_thread_metadata,
)
except Exception as _sc_err:
logger.debug("Proxy: could not set up stream consumer: %s", _sc_err)
# Run the stream consumer task in the background
stream_task = None
if _stream_consumer:
stream_task = asyncio.create_task(_stream_consumer.run())
# Send typing indicator
_adapter = self.adapters.get(source.platform)
if _adapter:
try:
await _adapter.send_typing(source.chat_id, metadata=_thread_metadata)
except Exception:
pass
# Make the HTTP request with SSE streaming -----------------------
full_response = ""
_start = time.time()
try:
_timeout = ClientTimeout(total=0, sock_read=1800)
async with _AioClientSession(timeout=_timeout) as session:
async with session.post(
f"{proxy_url}/v1/chat/completions",
json=body,
headers=headers,
) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.warning(
"Proxy error (%d) from %s: %s",
resp.status, proxy_url, error_text[:500],
)
return {
"final_response": f"⚠️ Proxy error ({resp.status}): {error_text[:300]}",
"messages": [],
"api_calls": 0,
"tools": [],
}
# Parse SSE stream
buffer = ""
async for chunk in resp.content.iter_any():
text = chunk.decode("utf-8", errors="replace")
buffer += text
# Process complete SSE lines
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
if line.startswith("data: "):
data = line[6:]
if data.strip() == "[DONE]":
break
try:
obj = json.loads(data)
choices = obj.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
full_response += content
if _stream_consumer:
_stream_consumer.on_delta(content)
except json.JSONDecodeError:
pass
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("Proxy connection error to %s: %s", proxy_url, e)
if not full_response:
return {
"final_response": f"⚠️ Proxy connection error: {e}",
"messages": [],
"api_calls": 0,
"tools": [],
}
# Partial response — return what we got
finally:
# Finalize stream consumer
if _stream_consumer:
_stream_consumer.finish()
if stream_task:
try:
await asyncio.wait_for(stream_task, timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
stream_task.cancel()
_elapsed = time.time() - _start
logger.info(
"proxy response: url=%s session=%s time=%.1fs response=%d chars",
proxy_url, (session_id or "")[:20], _elapsed, len(full_response),
)
return {
"final_response": full_response or "(No response from remote agent)",
"messages": [
{"role": "user", "content": message},
{"role": "assistant", "content": full_response},
],
"api_calls": 1,
"tools": [],
"history_offset": len(history),
"session_id": session_id,
"response_previewed": _stream_consumer is not None and bool(full_response),
}
# ------------------------------------------------------------------
async def _run_agent(
self,
message: str,
@@ -7782,18 +7413,6 @@ class GatewayRunner:
This is run in a thread pool to not block the event loop.
Supports interruption via new messages.
"""
# ---- Proxy mode: delegate to remote API server ----
if self._get_proxy_url():
return await self._run_agent_via_proxy(
message=message,
context_prompt=context_prompt,
history=history,
source=source,
session_id=session_id,
session_key=session_key,
event_message_id=event_message_id,
)
from run_agent import AIAgent
import queue
@@ -8188,19 +7807,13 @@ class GatewayRunner:
_adapter = self.adapters.get(source.platform)
if _adapter:
# Platforms that don't support editing sent messages
# (e.g. QQ, WeChat) should skip streaming entirely —
# without edit support, the consumer sends a partial
# first message that can never be updated, resulting in
# duplicate messages (partial + final).
# (e.g. WeChat) must not show a cursor in intermediate
# sends — the cursor would be permanently visible because
# it can never be edited away. Use an empty cursor for
# such platforms so streaming still delivers the final
# response, just without the typing indicator.
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
if not _adapter_supports_edit:
raise RuntimeError("skip streaming for non-editable platform")
_effective_cursor = _scfg.cursor
# Some Matrix clients render the streaming cursor
# as a visible tofu/white-box artifact. Keep
# streaming text on Matrix, but suppress the cursor.
if source.platform == Platform.MATRIX:
_effective_cursor = ""
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
_consumer_cfg = StreamConsumerConfig(
edit_interval=_scfg.edit_interval,
buffer_threshold=_scfg.buffer_threshold,
@@ -9281,41 +8894,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
runner = GatewayRunner(config)
# Track whether a signal initiated the shutdown (vs. internal request).
# When an unexpected SIGTERM kills the gateway, we exit non-zero so
# systemd's Restart=on-failure revives the process. systemctl stop
# is safe: systemd tracks stop-requested state independently of exit
# code, so Restart= never fires for a deliberate stop.
_signal_initiated_shutdown = False
# Set up signal handlers
def shutdown_signal_handler():
nonlocal _signal_initiated_shutdown
_signal_initiated_shutdown = True
logger.info("Received SIGTERM/SIGINT — initiating shutdown")
# Diagnostic: log all hermes-related processes so we can identify
# what triggered the signal (hermes update, hermes gateway restart,
# a stale detached subprocess, etc.).
try:
import subprocess as _sp
_ps = _sp.run(
["ps", "aux"],
capture_output=True, text=True, timeout=3,
)
_hermes_procs = [
line for line in _ps.stdout.splitlines()
if ("hermes" in line.lower() or "gateway" in line.lower())
and str(os.getpid()) not in line.split()[1:2] # exclude self
]
if _hermes_procs:
logger.warning(
"Shutdown diagnostic — other hermes processes running:\n %s",
"\n ".join(_hermes_procs),
)
else:
logger.info("Shutdown diagnostic — no other hermes processes found")
except Exception:
pass
asyncio.create_task(runner.stop())
def restart_signal_handler():
@@ -9385,21 +8965,6 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
if runner.exit_code is not None:
raise SystemExit(runner.exit_code)
# When a signal (SIGTERM/SIGINT) caused the shutdown and it wasn't a
# planned restart (/restart, /update, SIGUSR1), exit non-zero so
# systemd's Restart=on-failure revives the process. This covers:
# - hermes update killing the gateway mid-work
# - External kill commands
# - WSL2/container runtime sending unexpected signals
# systemctl stop is safe: systemd tracks "stop requested" state
# independently of exit code, so Restart= never fires for it.
if _signal_initiated_shutdown and not runner._restart_requested:
logger.info(
"Exiting with code 1 (signal-initiated shutdown without restart "
"request) so systemd Restart=on-failure can revive the gateway."
)
return False # → sys.exit(1) in the caller
return True
+1
View File
@@ -12,6 +12,7 @@ import hashlib
import logging
import os
import json
import re
import threading
import uuid
from pathlib import Path
+3 -153
View File
@@ -27,7 +27,6 @@ _RUNTIME_STATUS_FILE = "gateway_state.json"
_LOCKS_DIRNAME = "gateway-locks"
_IS_WINDOWS = sys.platform == "win32"
_UNSET = object()
_VALID_STARTUP_CHECK_STATES = {"pending", "ready", "failed"}
def _get_pid_path() -> Path:
@@ -163,39 +162,11 @@ def _build_runtime_status_record() -> dict[str, Any]:
"restart_requested": False,
"active_agents": 0,
"platforms": {},
"startup_checks": {},
"updated_at": _utc_now_iso(),
})
return payload
def _normalize_startup_check_entries(
startup_checks: Optional[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Normalize persisted startup readiness entries."""
if not isinstance(startup_checks, dict):
return {}
now = _utc_now_iso()
normalized: dict[str, dict[str, Any]] = {}
for raw_id, raw_payload in startup_checks.items():
check_id = str(raw_id).strip()
if not check_id:
continue
payload = raw_payload if isinstance(raw_payload, dict) else {}
state = str(payload.get("state", "pending")).strip().lower()
if state not in _VALID_STARTUP_CHECK_STATES:
state = "pending"
normalized[check_id] = {
"state": state,
"required": bool(payload.get("required", True)),
"source": payload.get("source"),
"detail": payload.get("detail"),
"updated_at": payload.get("updated_at") or now,
}
return normalized
def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
if not path.exists():
return None
@@ -252,7 +223,6 @@ def write_runtime_status(
exit_reason: Any = _UNSET,
restart_requested: Any = _UNSET,
active_agents: Any = _UNSET,
startup_checks: Any = _UNSET,
platform: Any = _UNSET,
platform_state: Any = _UNSET,
error_code: Any = _UNSET,
@@ -275,8 +245,6 @@ def write_runtime_status(
payload["restart_requested"] = bool(restart_requested)
if active_agents is not _UNSET:
payload["active_agents"] = max(0, int(active_agents))
if startup_checks is not _UNSET:
payload["startup_checks"] = _normalize_startup_check_entries(startup_checks)
if platform is not _UNSET:
platform_payload = payload["platforms"].get(platform, {})
@@ -294,131 +262,13 @@ def write_runtime_status(
def read_runtime_status() -> Optional[dict[str, Any]]:
"""Read the persisted gateway runtime health/status information."""
payload = _read_json_file(_get_runtime_status_path())
if payload is None:
return None
payload.setdefault("platforms", {})
payload["startup_checks"] = _normalize_startup_check_entries(payload.get("startup_checks"))
return payload
def reset_startup_checks(checks: Optional[list[dict[str, Any]]] = None) -> dict[str, dict[str, Any]]:
"""Replace persisted startup readiness checks for the current run."""
normalized: dict[str, dict[str, Any]] = {}
now = _utc_now_iso()
for hook in checks or []:
if not isinstance(hook, dict):
continue
readiness = hook.get("startup_readiness")
if not isinstance(readiness, dict):
continue
check_id = str(readiness.get("id", "")).strip()
if not check_id:
continue
normalized[check_id] = {
"state": "pending",
"required": bool(readiness.get("required", True)),
"source": hook.get("name"),
"detail": None,
"updated_at": now,
}
write_runtime_status(startup_checks=normalized)
return normalized
def update_startup_check(
check_id: str,
state: str,
*,
detail: Any = _UNSET,
required: Any = _UNSET,
source: Any = _UNSET,
) -> dict[str, Any]:
"""Update a single startup readiness check in the runtime status file."""
normalized_id = str(check_id).strip()
if not normalized_id:
raise ValueError("startup readiness check id is required")
normalized_state = str(state).strip().lower()
if normalized_state not in _VALID_STARTUP_CHECK_STATES:
raise ValueError(f"invalid startup readiness state: {state}")
path = _get_runtime_status_path()
payload = _read_json_file(path) or _build_runtime_status_record()
checks = _normalize_startup_check_entries(payload.get("startup_checks"))
existing = checks.get(normalized_id, {})
now = _utc_now_iso()
checks[normalized_id] = {
"state": normalized_state,
"required": bool(existing.get("required", True) if required is _UNSET else required),
"source": existing.get("source") if source is _UNSET else source,
"detail": existing.get("detail") if detail is _UNSET else detail,
"updated_at": now,
}
payload["startup_checks"] = checks
payload.setdefault("platforms", {})
payload.setdefault("kind", _GATEWAY_KIND)
payload["pid"] = os.getpid()
payload["start_time"] = _get_process_start_time(os.getpid())
payload["updated_at"] = now
_write_json_file(path, payload)
return checks[normalized_id]
def mark_startup_check_pending(
check_id: str,
*,
detail: Any = _UNSET,
required: Any = _UNSET,
source: Any = _UNSET,
) -> dict[str, Any]:
return update_startup_check(check_id, "pending", detail=detail, required=required, source=source)
def mark_startup_check_ready(
check_id: str,
*,
detail: Any = _UNSET,
required: Any = _UNSET,
source: Any = _UNSET,
) -> dict[str, Any]:
return update_startup_check(check_id, "ready", detail=detail, required=required, source=source)
def mark_startup_check_failed(
check_id: str,
*,
detail: Any = _UNSET,
required: Any = _UNSET,
source: Any = _UNSET,
) -> dict[str, Any]:
return update_startup_check(check_id, "failed", detail=detail, required=required, source=source)
return _read_json_file(_get_runtime_status_path())
def remove_pid_file() -> None:
"""Remove the gateway PID file, but only if it belongs to this process.
During --replace handoffs, the old process's atexit handler can fire AFTER
the new process has written its own PID file. Blindly removing the file
would delete the new process's record, leaving the gateway running with no
PID file (invisible to ``get_running_pid()``).
"""
"""Remove the gateway PID file if it exists."""
try:
path = _get_pid_path()
record = _read_json_file(path)
if record is not None:
try:
file_pid = int(record["pid"])
except (KeyError, TypeError, ValueError):
file_pid = None
if file_pid is not None and file_pid != os.getpid():
# PID file belongs to a different process — leave it alone.
return
path.unlink(missing_ok=True)
_get_pid_path().unlink(missing_ok=True)
except Exception:
pass
+1 -160
View File
@@ -64,18 +64,6 @@ class GatewayStreamConsumer:
# progressive edits for the remainder of the stream.
_MAX_FLOOD_STRIKES = 3
# Reasoning/thinking tags that models emit inline in content.
# Must stay in sync with cli.py _OPEN_TAGS/_CLOSE_TAGS and
# run_agent.py _strip_think_blocks() tag variants.
_OPEN_THINK_TAGS = (
"<REASONING_SCRATCHPAD>", "<think>", "<reasoning>",
"<THINKING>", "<thinking>", "<thought>",
)
_CLOSE_THINK_TAGS = (
"</REASONING_SCRATCHPAD>", "</think>", "</reasoning>",
"</THINKING>", "</thinking>", "</thought>",
)
def __init__(
self,
adapter: Any,
@@ -100,10 +88,6 @@ class GatewayStreamConsumer:
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
self._final_response_sent = False
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
self._in_think_block = False
self._think_buffer = ""
@property
def already_sent(self) -> bool:
"""True if at least one message was sent or edited during the run."""
@@ -148,112 +132,6 @@ class GatewayStreamConsumer:
"""Signal that the stream is complete."""
self._queue.put(_DONE)
# ── Think-block filtering ────────────────────────────────────────
# Models like MiniMax emit inline <think>...</think> blocks in their
# content. The CLI's _stream_delta suppresses these via a state
# machine; we do the same here so gateway users never see raw
# reasoning tags. The agent also strips them from the final
# response (run_agent.py _strip_think_blocks), but the stream
# consumer sends intermediate edits before that stripping happens.
def _filter_and_accumulate(self, text: str) -> None:
"""Add a text delta to the accumulated buffer, suppressing think blocks.
Uses a state machine that tracks whether we are inside a
reasoning/thinking block. Text inside such blocks is silently
discarded. Partial tags at buffer boundaries are held back in
``_think_buffer`` until enough characters arrive to decide.
"""
buf = self._think_buffer + text
self._think_buffer = ""
while buf:
if self._in_think_block:
# Look for the earliest closing tag
best_idx = -1
best_len = 0
for tag in self._CLOSE_THINK_TAGS:
idx = buf.find(tag)
if idx != -1 and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_len = len(tag)
if best_len:
# Found closing tag — discard block, process remainder
self._in_think_block = False
buf = buf[best_idx + best_len:]
else:
# No closing tag yet — hold tail that could be a
# partial closing tag prefix, discard the rest.
max_tag = max(len(t) for t in self._CLOSE_THINK_TAGS)
self._think_buffer = buf[-max_tag:] if len(buf) > max_tag else buf
return
else:
# Look for earliest opening tag at a block boundary
# (start of text / preceded by newline + optional whitespace).
# This prevents false positives when models *mention* tags
# in prose (e.g. "the <think> tag is used for…").
best_idx = -1
best_len = 0
for tag in self._OPEN_THINK_TAGS:
search_start = 0
while True:
idx = buf.find(tag, search_start)
if idx == -1:
break
# Block-boundary check (mirrors cli.py logic)
if idx == 0:
is_boundary = (
not self._accumulated
or self._accumulated.endswith("\n")
)
else:
preceding = buf[:idx]
last_nl = preceding.rfind("\n")
if last_nl == -1:
is_boundary = (
(not self._accumulated
or self._accumulated.endswith("\n"))
and preceding.strip() == ""
)
else:
is_boundary = preceding[last_nl + 1:].strip() == ""
if is_boundary and (best_idx == -1 or idx < best_idx):
best_idx = idx
best_len = len(tag)
break # first boundary hit for this tag is enough
search_start = idx + 1
if best_len:
# Emit text before the tag, enter think block
self._accumulated += buf[:best_idx]
self._in_think_block = True
buf = buf[best_idx + best_len:]
else:
# No opening tag — check for a partial tag at the tail
held_back = 0
for tag in self._OPEN_THINK_TAGS:
for i in range(1, len(tag)):
if buf.endswith(tag[:i]) and i > held_back:
held_back = i
if held_back:
self._accumulated += buf[:-held_back]
self._think_buffer = buf[-held_back:]
else:
self._accumulated += buf
return
def _flush_think_buffer(self) -> None:
"""Flush any held-back partial-tag buffer into accumulated text.
Called when the stream ends (got_done) so that partial text that
was held back waiting for a possible opening tag is not lost.
"""
if self._think_buffer and not self._in_think_block:
self._accumulated += self._think_buffer
self._think_buffer = ""
async def run(self) -> None:
"""Async task that drains the queue and edits the platform message."""
# Platform message length limit — leave room for cursor + formatting
@@ -278,16 +156,10 @@ class GatewayStreamConsumer:
if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY:
commentary_text = item[1]
break
self._filter_and_accumulate(item)
self._accumulated += item
except queue.Empty:
break
# Flush any held-back partial-tag buffer on stream end
# so trailing text that was waiting for a potential open
# tag is not lost.
if got_done:
self._flush_think_buffer()
# Decide whether to flush an edit
now = time.monotonic()
elapsed = now - self._last_edit_time
@@ -408,14 +280,6 @@ class GatewayStreamConsumer:
await self._send_or_edit(self._accumulated)
except Exception:
pass
# If we delivered any content before being cancelled, mark the
# final response as sent so the gateway's already_sent check
# doesn't trigger a duplicate message. The 5-second
# stream_task timeout (gateway/run.py) can cancel us while
# waiting on a slow Telegram API call — without this flag the
# gateway falls through to the normal send path.
if self._already_sent:
self._final_response_sent = True
except Exception as e:
logger.error("Stream consumer error: %s", e)
@@ -627,31 +491,8 @@ class GatewayStreamConsumer:
# Media files are delivered as native attachments after the stream
# finishes (via _deliver_media_from_response in gateway/run.py).
text = self._clean_for_display(text)
# A bare streaming cursor is not meaningful user-visible content and
# can render as a stray tofu/white-box message on some clients.
visible_without_cursor = text
if self.cfg.cursor:
visible_without_cursor = visible_without_cursor.replace(self.cfg.cursor, "")
_visible_stripped = visible_without_cursor.strip()
if not _visible_stripped:
return True # cursor-only / whitespace-only update
if not text.strip():
return True # nothing to send is "success"
# Guard: do not create a brand-new standalone message when the only
# visible content is a handful of characters alongside the streaming
# cursor. During rapid tool-calling the model often emits 1-2 tokens
# before switching to tool calls; the resulting "X ▉" message risks
# leaving the cursor permanently visible if the follow-up edit (to
# strip the cursor on segment break) is rate-limited by the platform.
# This was reported on Telegram, Matrix, and other clients where the
# ▉ block character renders as a visible white box ("tofu").
# Existing messages (edits) are unaffected — only first sends gated.
_MIN_NEW_MSG_CHARS = 4
if (self._message_id is None
and self.cfg.cursor
and self.cfg.cursor in text
and len(_visible_stripped) < _MIN_NEW_MSG_CHARS):
return True # too short for a standalone message — accumulate more
try:
if self._message_id is not None:
if self._edit_supported:
-160
View File
@@ -1,160 +0,0 @@
# Hermes Agent Has Had "Routines" Since March
Anthropic just announced [Claude Code Routines](https://claude.com/blog/introducing-routines-in-claude-code) — scheduled tasks, GitHub event triggers, and API-triggered agent runs. Bundled prompt + repo + connectors, running on their infrastructure.
It's a good feature. We shipped it two months ago.
---
## The Three Trigger Types — Side by Side
Claude Code Routines offers three ways to trigger an automation:
**1. Scheduled (cron)**
> "Every night at 2am: pull the top bug from Linear, attempt a fix, and open a draft PR."
Hermes equivalent — works today:
```bash
hermes cron create "0 2 * * *" \
"Pull the top bug from the issue tracker, attempt a fix, and open a draft PR." \
--name "Nightly bug fix" \
--deliver telegram
```
**2. GitHub Events (webhook)**
> "Flag PRs that touch the /auth-provider module and post to #auth-changes."
Hermes equivalent — works today:
```bash
hermes webhook subscribe auth-watch \
--events "pull_request" \
--prompt "PR #{pull_request.number}: {pull_request.title} by {pull_request.user.login}. Check if it touches the auth-provider module. If yes, summarize the changes." \
--deliver slack
```
**3. API Triggers**
> "Read the alert payload, find the owning service, post a triage summary to #oncall."
Hermes equivalent — works today:
```bash
hermes webhook subscribe alert-triage \
--prompt "Alert: {alert.name} — Severity: {alert.severity}. Find the owning service, investigate, and post a triage summary with proposed first steps." \
--deliver slack
```
Every use case in their blog post — backlog triage, docs drift, deploy verification, alert correlation, library porting, bespoke PR review — has a working Hermes implementation. No new features needed. It's been shipping since March 2026.
---
## What's Different
| | Claude Code Routines | Hermes Agent |
|---|---|---|
| **Scheduled tasks** | ✅ Schedule-based | ✅ Any cron expression + human-readable intervals |
| **GitHub triggers** | ✅ PR, issue, push events | ✅ Any GitHub event via webhook subscriptions |
| **API triggers** | ✅ POST to unique endpoint | ✅ POST to webhook routes with HMAC auth |
| **MCP connectors** | ✅ Native connectors | ✅ Full MCP client support |
| **Script pre-processing** | ❌ | ✅ Python scripts run before agent, inject context |
| **Skill chaining** | ❌ | ✅ Load multiple skills per automation |
| **Daily limit** | 5-25 runs/day | **Unlimited** |
| **Model choice** | Claude only | **Any model** — Claude, GPT, Gemini, DeepSeek, Qwen, local |
| **Delivery targets** | GitHub comments | Telegram, Discord, Slack, SMS, email, GitHub comments, webhooks, local files |
| **Infrastructure** | Anthropic's servers | **Your infrastructure** — VPS, home server, laptop |
| **Data residency** | Anthropic's cloud | **Your machines** |
| **Cost** | Pro/Max/Team/Enterprise subscription | Your API key, your rates |
| **Open source** | No | **Yes** — MIT license |
---
## Things Hermes Does That Routines Can't
### Script Injection
Run a Python script *before* the agent. The script's stdout becomes context. The script handles mechanical work (fetching, diffing, computing); the agent handles reasoning.
```bash
hermes cron create "every 1h" \
"If CHANGE DETECTED, summarize what changed. If NO_CHANGE, respond with [SILENT]." \
--script ~/.hermes/scripts/watch-site.py \
--name "Pricing monitor" \
--deliver telegram
```
The `[SILENT]` pattern means you only get notified when something actually happens. No spam.
### Multi-Skill Workflows
Chain specialized skills together. Each skill teaches the agent a specific capability, and the prompt ties them together.
```bash
hermes cron create "0 8 * * *" \
"Search arXiv for papers on language model reasoning. Save the top 3 as Obsidian notes." \
--skills "arxiv,obsidian" \
--name "Paper digest"
```
### Deliver Anywhere
One automation, any destination:
```bash
--deliver telegram # Telegram home channel
--deliver discord # Discord home channel
--deliver slack # Slack channel
--deliver sms:+15551234567 # Text message
--deliver telegram:-1001234567890:42 # Specific Telegram forum topic
--deliver local # Save to file, no notification
```
### Model-Agnostic
Your nightly triage can run on Claude. Your deploy verification can run on GPT. Your cost-sensitive monitors can run on DeepSeek or a local model. Same automation system, any backend.
---
## The Limits Tell the Story
Claude Code Routines: **5 routines per day** on Pro. **25 on Enterprise.** That's their ceiling.
Hermes has no daily limit. Run 500 automations a day if you want. The only constraint is your API budget, and you choose which models to use for which tasks.
A nightly backlog triage on Sonnet costs roughly $0.02-0.05. A monitoring check on DeepSeek costs fractions of a cent. You control the economics.
---
## Get Started
Hermes Agent is open source and free. The automation infrastructure — cron scheduler, webhook platform, skill system, multi-platform delivery — is built in.
```bash
pip install hermes-agent
hermes setup
```
Set up a scheduled task in 30 seconds:
```bash
hermes cron create "0 9 * * 1" \
"Generate a weekly AI news digest. Search the web for major announcements, trending repos, and notable papers. Keep it under 500 words with links." \
--name "Weekly digest" \
--deliver telegram
```
Set up a GitHub webhook in 60 seconds:
```bash
hermes gateway setup # enable webhooks
hermes webhook subscribe pr-review \
--events "pull_request" \
--prompt "Review PR #{pull_request.number}: {pull_request.title}" \
--skills "github-code-review" \
--deliver github_comment
```
Full automation templates gallery: [hermes-agent.nousresearch.com/docs/guides/automation-templates](https://hermes-agent.nousresearch.com/docs/guides/automation-templates)
Documentation: [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com)
GitHub: [github.com/NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent)
---
*Hermes Agent is built by [Nous Research](https://nousresearch.com). Open source, model-agnostic, runs on your infrastructure.*
+2 -2
View File
@@ -11,5 +11,5 @@ Provides subcommands for:
- hermes cron - Manage cron jobs
"""
__version__ = "0.9.0"
__release_date__ = "2026.4.13"
__version__ = "0.8.0"
__release_date__ = "2026.4.8"
+35 -90
View File
@@ -160,21 +160,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("KIMI_API_KEY",),
base_url_env_var="KIMI_BASE_URL",
),
"kimi-coding-cn": ProviderConfig(
id="kimi-coding-cn",
name="Kimi / Moonshot (China)",
auth_type="api_key",
inference_base_url="https://api.moonshot.cn/v1",
api_key_env_vars=("KIMI_CN_API_KEY",),
),
"arcee": ProviderConfig(
id="arcee",
name="Arcee AI",
auth_type="api_key",
inference_base_url="https://api.arcee.ai/api/v1",
api_key_env_vars=("ARCEEAI_API_KEY",),
base_url_env_var="ARCEE_BASE_URL",
),
"minimax": ProviderConfig(
id="minimax",
name="MiniMax",
@@ -224,7 +209,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
),
"ai-gateway": ProviderConfig(
id="ai-gateway",
name="Vercel AI Gateway",
name="AI Gateway",
auth_type="api_key",
inference_base_url="https://ai-gateway.vercel.sh/v1",
api_key_env_vars=("AI_GATEWAY_API_KEY",),
@@ -383,16 +368,13 @@ def _resolve_api_key_provider_secret(
# Z.AI has separate billing for general vs coding plans, and global vs China
# endpoints. A key that works on one may return "Insufficient balance" on
# another. We probe at setup time and store the working endpoint.
# Each entry lists candidate models to try in order — newer coding plan accounts
# may only have access to recent models (glm-5.1, glm-5v-turbo) while older
# ones still use glm-4.7.
ZAI_ENDPOINTS = [
# (id, base_url, probe_models, label)
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
# (id, base_url, default_model, label)
("global", "https://api.z.ai/api/paas/v4", "glm-5", "Global"),
("cn", "https://open.bigmodel.cn/api/paas/v4", "glm-5", "China"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", "glm-4.7", "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", "glm-4.7", "China (Coding Plan)"),
]
@@ -400,37 +382,35 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str
"""Probe z.ai endpoints to find one that accepts this API key.
Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the
first working endpoint, or None if all fail. For endpoints with multiple
candidate models, tries each in order and returns the first that succeeds.
first working endpoint, or None if all fail.
"""
for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS:
for model in probe_models:
try:
resp = httpx.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"stream": False,
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
},
timeout=timeout,
)
if resp.status_code == 200:
logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model)
return {
"id": ep_id,
"base_url": base_url,
"model": model,
"label": label,
}
logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code)
except Exception as exc:
logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc)
for ep_id, base_url, model, label in ZAI_ENDPOINTS:
try:
resp = httpx.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"stream": False,
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
},
timeout=timeout,
)
if resp.status_code == 200:
logger.debug("Z.AI endpoint probe: %s (%s) OK", ep_id, base_url)
return {
"id": ep_id,
"base_url": base_url,
"model": model,
"label": label,
}
logger.debug("Z.AI endpoint probe: %s returned %s", ep_id, resp.status_code)
except Exception as exc:
logger.debug("Z.AI endpoint probe: %s failed: %s", ep_id, exc)
return None
@@ -912,8 +892,6 @@ def resolve_provider(
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
"arcee-ai": "arcee", "arceeai": "arcee",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"claude": "anthropic", "claude-code": "anthropic",
"github": "copilot", "github-copilot": "copilot",
@@ -2267,40 +2245,7 @@ def resolve_nous_runtime_credentials(
# =============================================================================
def get_nous_auth_status() -> Dict[str, Any]:
"""Status snapshot for `hermes status` output.
Checks the credential pool first (where the dashboard device-code flow
and ``hermes auth`` store credentials), then falls back to the legacy
auth-store provider state.
"""
# Check credential pool first — the dashboard device-code flow saves
# here but may not have written to the auth store yet.
try:
from agent.credential_pool import load_pool
pool = load_pool("nous")
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
access_token = (
getattr(entry, "access_token", None)
or getattr(entry, "runtime_api_key", "")
)
if access_token:
return {
"logged_in": True,
"portal_base_url": getattr(entry, "portal_base_url", None)
or getattr(entry, "base_url", None),
"inference_base_url": getattr(entry, "inference_base_url", None)
or getattr(entry, "base_url", None),
"access_token": access_token,
"access_expires_at": getattr(entry, "expires_at", None),
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
"has_refresh_token": bool(getattr(entry, "refresh_token", None)),
}
except Exception:
pass
# Fall back to auth-store provider state
"""Status snapshot for `hermes status` output."""
state = get_provider_auth_state("nous")
if not state:
return {
+9 -9
View File
@@ -36,23 +36,25 @@ _OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth"}
def _get_custom_provider_names() -> list:
"""Return list of (display_name, pool_key, provider_key) tuples."""
"""Return list of (display_name, pool_key) tuples for custom_providers in config."""
try:
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_cli.config import load_config
config = load_config()
except Exception:
return []
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
return []
result = []
for entry in get_compatible_custom_providers(config):
for entry in custom_providers:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
continue
pool_key = f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}"
provider_key = str(entry.get("provider_key", "") or "").strip()
result.append((name.strip(), pool_key, provider_key))
result.append((name.strip(), pool_key))
return result
@@ -64,11 +66,9 @@ def _resolve_custom_provider_input(raw: str) -> str | None:
# Direct match on 'custom:name' format
if normalized.startswith(CUSTOM_POOL_PREFIX):
return normalized
for display_name, pool_key, provider_key in _get_custom_provider_names():
for display_name, pool_key in _get_custom_provider_names():
if _normalize_custom_pool_name(display_name) == normalized:
return pool_key
if provider_key and provider_key.strip().lower() == normalized:
return pool_key
return None
@@ -405,7 +405,7 @@ def _pick_provider(prompt: str = "Provider") -> str:
known = sorted(set(list(PROVIDER_REGISTRY.keys()) + ["openrouter"]))
custom_names = _get_custom_provider_names()
if custom_names:
custom_display = [name for name, _key, _provider_key in custom_names]
custom_display = [name for name, _key in custom_names]
print(f"\nKnown providers: {', '.join(known)}")
print(f"Custom endpoints: {', '.join(custom_display)}")
else:
+1
View File
@@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
import json
import logging
import os
import shutil
import subprocess
import threading
+3 -3
View File
@@ -75,12 +75,12 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
if not hasattr(cli, "_secret_deadline"):
cli._secret_deadline = 0
try:
value = getpass.getpass(f"{prompt} (hidden, ESC or empty Enter to skip): ")
value = getpass.getpass(f"{prompt} (hidden, Enter to skip): ")
except (EOFError, KeyboardInterrupt):
value = ""
if not value:
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
cprint(f"\n{_DIM} ⏭ Secret entry cancelled{_RST}")
return {
"success": True,
"reason": "cancelled",
@@ -133,7 +133,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
cli._app.invalidate()
if not value:
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
cprint(f"\n{_DIM} ⏭ Secret entry cancelled{_RST}")
return {
"success": True,
"reason": "cancelled",
+1
View File
@@ -6,6 +6,7 @@ mcp_config.py, and memory_setup.py.
"""
import getpass
import sys
from hermes_cli.colors import Colors, color
+77 -240
View File
@@ -12,9 +12,6 @@ from __future__ import annotations
import os
import re
import shutil
import subprocess
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
@@ -193,6 +190,52 @@ def resolve_command(name: str) -> CommandDef | None:
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
def rebuild_lookups() -> None:
"""Rebuild all derived lookup dicts from the current COMMAND_REGISTRY.
Called after plugin commands are registered so they appear in help,
autocomplete, gateway dispatch, Telegram menu, and Slack mapping.
"""
global GATEWAY_KNOWN_COMMANDS
_COMMAND_LOOKUP.clear()
_COMMAND_LOOKUP.update(_build_command_lookup())
COMMANDS.clear()
for cmd in COMMAND_REGISTRY:
if not cmd.gateway_only:
COMMANDS[f"/{cmd.name}"] = _build_description(cmd)
for alias in cmd.aliases:
COMMANDS[f"/{alias}"] = f"{cmd.description} (alias for /{cmd.name})"
COMMANDS_BY_CATEGORY.clear()
for cmd in COMMAND_REGISTRY:
if not cmd.gateway_only:
cat = COMMANDS_BY_CATEGORY.setdefault(cmd.category, {})
cat[f"/{cmd.name}"] = COMMANDS[f"/{cmd.name}"]
for alias in cmd.aliases:
cat[f"/{alias}"] = COMMANDS[f"/{alias}"]
SUBCOMMANDS.clear()
for cmd in COMMAND_REGISTRY:
if cmd.subcommands:
SUBCOMMANDS[f"/{cmd.name}"] = list(cmd.subcommands)
for cmd in COMMAND_REGISTRY:
key = f"/{cmd.name}"
if key in SUBCOMMANDS or not cmd.args_hint:
continue
m = _PIPE_SUBS_RE.search(cmd.args_hint)
if m:
SUBCOMMANDS[key] = m.group(0).split("|")
GATEWAY_KNOWN_COMMANDS = frozenset(
name
for cmd in COMMAND_REGISTRY
if not cmd.cli_only or cmd.gateway_config_gate
for name in (cmd.name, *cmd.aliases)
)
def _build_description(cmd: CommandDef) -> str:
"""Build a CLI-facing description string including usage hint."""
if cmd.args_hint:
@@ -582,116 +625,6 @@ def discord_skill_commands(
)
def discord_skill_commands_by_category(
reserved_names: set[str],
) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]:
"""Return skill entries organized by category for Discord ``/skill`` subcommand groups.
Skills whose directory is nested at least 2 levels under ``SKILLS_DIR``
(e.g. ``creative/ascii-art/SKILL.md``) are grouped by their top-level
category. Root-level skills (e.g. ``dogfood/SKILL.md``) are returned as
*uncategorized* the caller should register them as direct subcommands
of the ``/skill`` group.
The same filtering as :func:`discord_skill_commands` is applied: hub
skills excluded, per-platform disabled excluded, names clamped.
Returns:
``(categories, uncategorized, hidden_count)``
- *categories*: ``{category_name: [(name, description, cmd_key), ...]}``
- *uncategorized*: ``[(name, description, cmd_key), ...]``
- *hidden_count*: skills dropped due to Discord group limits
(25 subcommand groups, 25 subcommands per group)
"""
from pathlib import Path as _P
_platform_disabled: set[str] = set()
try:
from agent.skill_utils import get_disabled_skill_names
_platform_disabled = get_disabled_skill_names(platform="discord")
except Exception:
pass
# Collect raw skill data --------------------------------------------------
categories: dict[str, list[tuple[str, str, str]]] = {}
uncategorized: list[tuple[str, str, str]] = []
_names_used: set[str] = set(reserved_names)
hidden = 0
try:
from agent.skill_commands import get_skill_commands
from tools.skills_tool import SKILLS_DIR
_skills_dir = SKILLS_DIR.resolve()
_hub_dir = (SKILLS_DIR / ".hub").resolve()
skill_cmds = get_skill_commands()
for cmd_key in sorted(skill_cmds):
info = skill_cmds[cmd_key]
skill_path = info.get("skill_md_path", "")
if not skill_path:
continue
sp = _P(skill_path).resolve()
# Skip skills outside SKILLS_DIR or from the hub
if not str(sp).startswith(str(_skills_dir)):
continue
if str(sp).startswith(str(_hub_dir)):
continue
skill_name = info.get("name", "")
if skill_name in _platform_disabled:
continue
raw_name = cmd_key.lstrip("/")
# Clamp to 32 chars (Discord limit)
discord_name = raw_name[:32]
if discord_name in _names_used:
continue
_names_used.add(discord_name)
desc = info.get("description", "")
if len(desc) > 100:
desc = desc[:97] + "..."
# Determine category from the relative path within SKILLS_DIR.
# e.g. creative/ascii-art/SKILL.md → parts = ("creative", "ascii-art")
try:
rel = sp.parent.relative_to(_skills_dir)
except ValueError:
continue
parts = rel.parts
if len(parts) >= 2:
cat = parts[0]
categories.setdefault(cat, []).append((discord_name, desc, cmd_key))
else:
uncategorized.append((discord_name, desc, cmd_key))
except Exception:
pass
# Enforce Discord limits: 25 subcommand groups, 25 subcommands each ------
_MAX_GROUPS = 25
_MAX_PER_GROUP = 25
trimmed_categories: dict[str, list[tuple[str, str, str]]] = {}
group_count = 0
for cat in sorted(categories):
if group_count >= _MAX_GROUPS:
hidden += len(categories[cat])
continue
entries = categories[cat][:_MAX_PER_GROUP]
hidden += max(0, len(categories[cat]) - _MAX_PER_GROUP)
trimmed_categories[cat] = entries
group_count += 1
# Uncategorized skills also count against the 25 top-level limit
remaining_slots = _MAX_GROUPS - group_count
if len(uncategorized) > remaining_slots:
hidden += len(uncategorized) - remaining_slots
uncategorized = uncategorized[:remaining_slots]
return trimmed_categories, uncategorized, hidden
def slack_subcommand_map() -> dict[str, str]:
"""Return subcommand -> /command mapping for Slack /hermes handler.
@@ -723,10 +656,6 @@ class SlashCommandCompleter(Completer):
) -> None:
self._skill_commands_provider = skill_commands_provider
self._command_filter = command_filter
# Cached project file list for fuzzy @ completions
self._file_cache: list[str] = []
self._file_cache_time: float = 0.0
self._file_cache_cwd: str = ""
def _command_allowed(self, slash_command: str) -> bool:
if self._command_filter is None:
@@ -911,138 +840,46 @@ class SlashCommandCompleter(Completer):
count += 1
return
# Bare @ or @partial — fuzzy project-wide file search
# Bare @ or @partial — show matching files/folders from cwd
query = word[1:] # strip the @
yield from self._fuzzy_file_completions(word, query, limit)
def _get_project_files(self) -> list[str]:
"""Return cached list of project files (refreshed every 5s)."""
cwd = os.getcwd()
now = time.monotonic()
if (
self._file_cache
and self._file_cache_cwd == cwd
and now - self._file_cache_time < 5.0
):
return self._file_cache
files: list[str] = []
# Try rg first (fast, respects .gitignore), then fd, then find.
for cmd in [
["rg", "--files", "--sortr=modified", cwd],
["rg", "--files", cwd],
["fd", "--type", "f", "--base-directory", cwd],
]:
tool = cmd[0]
if not shutil.which(tool):
continue
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=2,
cwd=cwd,
)
if proc.returncode == 0 and proc.stdout.strip():
raw = proc.stdout.strip().split("\n")
# Store relative paths
for p in raw[:5000]:
rel = os.path.relpath(p, cwd) if os.path.isabs(p) else p
files.append(rel)
break
except (subprocess.TimeoutExpired, OSError):
continue
self._file_cache = files
self._file_cache_time = now
self._file_cache_cwd = cwd
return files
@staticmethod
def _score_path(filepath: str, query: str) -> int:
"""Score a file path against a fuzzy query. Higher = better match."""
if not query:
return 1 # show everything when query is empty
search_dir, match_prefix = ".", ""
else:
expanded = os.path.expanduser(query)
if expanded.endswith("/"):
search_dir, match_prefix = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "."
match_prefix = os.path.basename(expanded)
filename = os.path.basename(filepath)
lower_file = filename.lower()
lower_path = filepath.lower()
lower_q = query.lower()
# Exact filename match
if lower_file == lower_q:
return 100
# Filename starts with query
if lower_file.startswith(lower_q):
return 80
# Filename contains query as substring
if lower_q in lower_file:
return 60
# Full path contains query
if lower_q in lower_path:
return 40
# Initials / abbreviation match: e.g. "fo" matches "file_operations"
# Check if query chars appear in order in filename
qi = 0
for c in lower_file:
if qi < len(lower_q) and c == lower_q[qi]:
qi += 1
if qi == len(lower_q):
# Bonus if matches land on word boundaries (after _, -, /, .)
boundary_hits = 0
qi = 0
prev = "_" # treat start as boundary
for c in lower_file:
if qi < len(lower_q) and c == lower_q[qi]:
if prev in "_-./":
boundary_hits += 1
qi += 1
prev = c
if boundary_hits >= len(lower_q) * 0.5:
return 35
return 25
return 0
def _fuzzy_file_completions(self, word: str, query: str, limit: int = 20):
"""Yield fuzzy file completions for bare @query."""
files = self._get_project_files()
if not query:
# No query — show recently modified files (already sorted by mtime)
for fp in files[:limit]:
is_dir = fp.endswith("/")
filename = os.path.basename(fp)
kind = "folder" if is_dir else "file"
meta = "dir" if is_dir else _file_size_label(
os.path.join(os.getcwd(), fp)
)
yield Completion(
f"@{kind}:{fp}",
start_position=-len(word),
display=filename,
display_meta=meta,
)
try:
entries = os.listdir(search_dir)
except OSError:
return
# Score and rank
scored = []
for fp in files:
s = self._score_path(fp, query)
if s > 0:
scored.append((s, fp))
scored.sort(key=lambda x: (-x[0], x[1]))
for _, fp in scored[:limit]:
is_dir = fp.endswith("/")
filename = os.path.basename(fp)
count = 0
prefix_lower = match_prefix.lower()
for entry in sorted(entries):
if match_prefix and not entry.lower().startswith(prefix_lower):
continue
if entry.startswith("."):
continue # skip hidden files in bare @ mode
if count >= limit:
break
full_path = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full_path)
display_path = os.path.relpath(full_path)
suffix = "/" if is_dir else ""
kind = "folder" if is_dir else "file"
meta = "dir" if is_dir else _file_size_label(
os.path.join(os.getcwd(), fp)
)
meta = "dir" if is_dir else _file_size_label(full_path)
completion = f"@{kind}:{display_path}{suffix}"
yield Completion(
f"@{kind}:{fp}",
completion,
start_position=-len(word),
display=filename,
display_meta=f"{fp} {meta}" if meta else fp,
display=entry + suffix,
display_meta=meta,
)
count += 1
def _model_completions(self, sub_text: str, sub_lower: str):
"""Yield completions for /model from config aliases + built-in aliases."""
-315
View File
@@ -1,315 +0,0 @@
"""Shell completion script generation for hermes CLI.
Walks the live argparse parser tree to generate accurate, always-up-to-date
completion scripts no hardcoded subcommand lists, no extra dependencies.
Supports bash, zsh, and fish.
"""
from __future__ import annotations
import argparse
from typing import Any
def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]:
"""Recursively extract subcommands and flags from a parser.
Uses _SubParsersAction._choices_actions to get canonical names (no aliases)
along with their help text.
"""
flags: list[str] = []
subcommands: dict[str, Any] = {}
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
# _choices_actions has one entry per canonical name; aliases are
# omitted, which keeps completion lists clean.
seen: set[str] = set()
for pseudo in action._choices_actions:
name = pseudo.dest
if name in seen:
continue
seen.add(name)
subparser = action.choices.get(name)
if subparser is None:
continue
info = _walk(subparser)
info["help"] = _clean(pseudo.help or "")
subcommands[name] = info
elif action.option_strings:
flags.extend(o for o in action.option_strings if o.startswith("-"))
return {"flags": flags, "subcommands": subcommands}
def _clean(text: str, maxlen: int = 60) -> str:
"""Strip shell-unsafe characters and truncate."""
return text.replace("'", "").replace('"', "").replace("\\", "")[:maxlen]
# ---------------------------------------------------------------------------
# Bash
# ---------------------------------------------------------------------------
def generate_bash(parser: argparse.ArgumentParser) -> str:
tree = _walk(parser)
top_cmds = " ".join(sorted(tree["subcommands"]))
cases: list[str] = []
for cmd in sorted(tree["subcommands"]):
info = tree["subcommands"][cmd]
if cmd == "profile" and info["subcommands"]:
# Profile subcommand: complete actions, then profile names for
# actions that accept a profile argument.
subcmds = " ".join(sorted(info["subcommands"]))
profile_actions = "use delete show alias rename export"
cases.append(
f" profile)\n"
f" case \"$prev\" in\n"
f" profile)\n"
f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
f" return\n"
f" ;;\n"
f" {profile_actions.replace(' ', '|')})\n"
f" COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n"
f" return\n"
f" ;;\n"
f" esac\n"
f" ;;"
)
elif info["subcommands"]:
subcmds = " ".join(sorted(info["subcommands"]))
cases.append(
f" {cmd})\n"
f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n"
f" return\n"
f" ;;"
)
elif info["flags"]:
flags = " ".join(info["flags"])
cases.append(
f" {cmd})\n"
f" COMPREPLY=($(compgen -W \"{flags}\" -- \"$cur\"))\n"
f" return\n"
f" ;;"
)
cases_str = "\n".join(cases)
return f"""# Hermes Agent bash completion
# Add to ~/.bashrc:
# eval "$(hermes completion bash)"
_hermes_profiles() {{
local profiles_dir="$HOME/.hermes/profiles"
local profiles="default"
if [ -d "$profiles_dir" ]; then
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
fi
echo "$profiles"
}}
_hermes_completion() {{
local cur prev
COMPREPLY=()
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
# Complete profile names after -p / --profile
if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then
COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur"))
return
fi
if [[ $COMP_CWORD -ge 2 ]]; then
case "${{COMP_WORDS[1]}}" in
{cases_str}
esac
fi
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=($(compgen -W "{top_cmds}" -- "$cur"))
fi
}}
complete -F _hermes_completion hermes
"""
# ---------------------------------------------------------------------------
# Zsh
# ---------------------------------------------------------------------------
def generate_zsh(parser: argparse.ArgumentParser) -> str:
tree = _walk(parser)
top_cmds_lines: list[str] = []
for cmd in sorted(tree["subcommands"]):
help_text = _clean(tree["subcommands"][cmd].get("help", ""))
top_cmds_lines.append(f" '{cmd}:{help_text}'")
top_cmds_str = "\n".join(top_cmds_lines)
sub_cases: list[str] = []
for cmd in sorted(tree["subcommands"]):
info = tree["subcommands"][cmd]
if not info["subcommands"]:
continue
if cmd == "profile":
# Profile subcommand: complete actions, then profile names for
# actions that accept a profile argument.
sub_lines: list[str] = []
for sc in sorted(info["subcommands"]):
sh = _clean(info["subcommands"][sc].get("help", ""))
sub_lines.append(f" '{sc}:{sh}'")
sub_str = "\n".join(sub_lines)
sub_cases.append(
f" profile)\n"
f" case ${{line[2]}} in\n"
f" use|delete|show|alias|rename|export)\n"
f" _hermes_profiles\n"
f" ;;\n"
f" *)\n"
f" local -a profile_cmds\n"
f" profile_cmds=(\n"
f"{sub_str}\n"
f" )\n"
f" _describe 'profile command' profile_cmds\n"
f" ;;\n"
f" esac\n"
f" ;;"
)
else:
sub_lines = []
for sc in sorted(info["subcommands"]):
sh = _clean(info["subcommands"][sc].get("help", ""))
sub_lines.append(f" '{sc}:{sh}'")
sub_str = "\n".join(sub_lines)
safe = cmd.replace("-", "_")
sub_cases.append(
f" {cmd})\n"
f" local -a {safe}_cmds\n"
f" {safe}_cmds=(\n"
f"{sub_str}\n"
f" )\n"
f" _describe '{cmd} command' {safe}_cmds\n"
f" ;;"
)
sub_cases_str = "\n".join(sub_cases)
return f"""#compdef hermes
# Hermes Agent zsh completion
# Add to ~/.zshrc:
# eval "$(hermes completion zsh)"
_hermes_profiles() {{
local -a profiles
profiles=(default)
if [[ -d "$HOME/.hermes/profiles" ]]; then
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
fi
_describe 'profile' profiles
}}
_hermes() {{
local context state line
typeset -A opt_args
_arguments -C \\
'(-h --help){{-h,--help}}[Show help and exit]' \\
'(-V --version){{-V,--version}}[Show version and exit]' \\
'(-p --profile){{-p,--profile}}[Profile name]:profile:_hermes_profiles' \\
'1:command:->commands' \\
'*::arg:->args'
case $state in
commands)
local -a subcmds
subcmds=(
{top_cmds_str}
)
_describe 'hermes command' subcmds
;;
args)
case ${{line[1]}} in
{sub_cases_str}
esac
;;
esac
}}
_hermes "$@"
"""
# ---------------------------------------------------------------------------
# Fish
# ---------------------------------------------------------------------------
def generate_fish(parser: argparse.ArgumentParser) -> str:
tree = _walk(parser)
top_cmds = sorted(tree["subcommands"])
top_cmds_str = " ".join(top_cmds)
lines: list[str] = [
"# Hermes Agent fish completion",
"# Add to your config:",
"# hermes completion fish | source",
"",
"# Helper: list available profiles",
"function __hermes_profiles",
" echo default",
" if test -d $HOME/.hermes/profiles",
" ls $HOME/.hermes/profiles 2>/dev/null",
" end",
"end",
"",
"# Disable file completion by default",
"complete -c hermes -f",
"",
"# Complete profile names after -p / --profile",
"complete -c hermes -f -s p -l profile"
" -d 'Profile name' -xa '(__hermes_profiles)'",
"",
"# Top-level subcommands",
]
for cmd in top_cmds:
info = tree["subcommands"][cmd]
help_text = _clean(info.get("help", ""))
lines.append(
f"complete -c hermes -f "
f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' "
f"-a {cmd} -d '{help_text}'"
)
lines.append("")
lines.append("# Subcommand completions")
profile_name_actions = {"use", "delete", "show", "alias", "rename", "export"}
for cmd in top_cmds:
info = tree["subcommands"][cmd]
if not info["subcommands"]:
continue
lines.append(f"# {cmd}")
for sc in sorted(info["subcommands"]):
sinfo = info["subcommands"][sc]
sh = _clean(sinfo.get("help", ""))
lines.append(
f"complete -c hermes -f "
f"-n '__fish_seen_subcommand_from {cmd}' "
f"-a {sc} -d '{sh}'"
)
# For profile subcommand, complete profile names for relevant actions
if cmd == "profile":
for action in sorted(profile_name_actions):
lines.append(
f"complete -c hermes -f "
f"-n '__fish_seen_subcommand_from {action}; "
f"and __fish_seen_subcommand_from profile' "
f"-a '(__hermes_profiles)' -d 'Profile name'"
)
lines.append("")
return "\n".join(lines)
+3 -226
View File
@@ -45,9 +45,6 @@ _EXTRA_ENV_KEYS = frozenset({
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME",
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
@@ -819,30 +816,6 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"KIMI_CN_API_KEY": {
"description": "Kimi / Moonshot China API key",
"prompt": "Kimi (China) API key",
"url": "https://platform.moonshot.cn/",
"password": True,
"category": "provider",
"advanced": True,
},
"ARCEEAI_API_KEY": {
"description": "Arcee AI API key",
"prompt": "Arcee AI API key",
"url": "https://chat.arcee.ai/",
"password": True,
"category": "provider",
"advanced": True,
},
"ARCEE_BASE_URL": {
"description": "Arcee AI base URL override",
"prompt": "Arcee base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"MINIMAX_API_KEY": {
"description": "MiniMax API key (international)",
"prompt": "MiniMax API key",
@@ -1195,7 +1168,7 @@ OPTIONAL_ENV_VARS = {
"SLACK_BOT_TOKEN": {
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
"im:history, im:read, im:write, users:read, files:read, files:write",
"im:history, im:read, im:write, users:read, files:write",
"prompt": "Slack Bot Token (xoxb-...)",
"url": "https://api.slack.com/apps",
"password": True,
@@ -1334,53 +1307,6 @@ OPTIONAL_ENV_VARS = {
"password": False,
"category": "messaging",
},
"BLUEBUBBLES_ALLOW_ALL_USERS": {
"description": "Allow all BlueBubbles users without allowlist",
"prompt": "Allow All BlueBubbles Users",
"category": "messaging",
},
"QQ_APP_ID": {
"description": "QQ Bot App ID from QQ Open Platform (q.qq.com)",
"prompt": "QQ App ID",
"url": "https://q.qq.com",
"category": "messaging",
},
"QQ_CLIENT_SECRET": {
"description": "QQ Bot Client Secret from QQ Open Platform",
"prompt": "QQ Client Secret",
"password": True,
"category": "messaging",
},
"QQ_ALLOWED_USERS": {
"description": "Comma-separated QQ user IDs allowed to use the bot",
"prompt": "QQ Allowed Users",
"category": "messaging",
},
"QQ_GROUP_ALLOWED_USERS": {
"description": "Comma-separated QQ group IDs allowed to interact with the bot",
"prompt": "QQ Group Allowed Users",
"category": "messaging",
},
"QQ_ALLOW_ALL_USERS": {
"description": "Allow all QQ users without an allowlist (true/false)",
"prompt": "Allow All QQ Users",
"category": "messaging",
},
"QQ_HOME_CHANNEL": {
"description": "Default QQ channel/group for cron delivery and notifications",
"prompt": "QQ Home Channel",
"category": "messaging",
},
"QQ_HOME_CHANNEL_NAME": {
"description": "Display name for the QQ home channel",
"prompt": "QQ Home Channel Name",
"category": "messaging",
},
"QQ_SANDBOX": {
"description": "Enable QQ sandbox mode for development testing (true/false)",
"prompt": "QQ Sandbox Mode",
"category": "messaging",
},
"GATEWAY_ALLOW_ALL_USERS": {
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
"prompt": "Allow all users (true/false)",
@@ -1429,22 +1355,6 @@ OPTIONAL_ENV_VARS = {
"category": "messaging",
"advanced": True,
},
"GATEWAY_PROXY_URL": {
"description": "URL of a remote Hermes API server to forward messages to (proxy mode). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Use for Docker E2EE containers that relay to a host agent. Also configurable via gateway.proxy_url in config.yaml.",
"prompt": "Remote Hermes API server URL (e.g. http://192.168.1.100:8642)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"GATEWAY_PROXY_KEY": {
"description": "Bearer token for authenticating with the remote Hermes API server (proxy mode). Must match the API_SERVER_KEY on the remote host.",
"prompt": "Remote API server auth key",
"url": None,
"password": True,
"category": "messaging",
"advanced": True,
},
"WEBHOOK_ENABLED": {
"description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
"prompt": "Enable webhooks (true/false)",
@@ -1634,137 +1544,6 @@ def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
return missing
def _normalize_custom_provider_entry(
entry: Any,
*,
provider_key: str = "",
) -> Optional[Dict[str, Any]]:
"""Return a runtime-compatible custom provider entry or ``None``."""
if not isinstance(entry, dict):
return None
base_url = ""
for url_key in ("api", "url", "base_url"):
raw_url = entry.get(url_key)
if isinstance(raw_url, str) and raw_url.strip():
base_url = raw_url.strip()
break
if not base_url:
return None
name = ""
raw_name = entry.get("name")
if isinstance(raw_name, str) and raw_name.strip():
name = raw_name.strip()
elif provider_key.strip():
name = provider_key.strip()
if not name:
return None
normalized: Dict[str, Any] = {
"name": name,
"base_url": base_url,
}
provider_key = provider_key.strip()
if provider_key:
normalized["provider_key"] = provider_key
api_key = entry.get("api_key")
if isinstance(api_key, str) and api_key.strip():
normalized["api_key"] = api_key.strip()
key_env = entry.get("key_env")
if isinstance(key_env, str) and key_env.strip():
normalized["key_env"] = key_env.strip()
api_mode = entry.get("api_mode") or entry.get("transport")
if isinstance(api_mode, str) and api_mode.strip():
normalized["api_mode"] = api_mode.strip()
model_name = entry.get("model") or entry.get("default_model")
if isinstance(model_name, str) and model_name.strip():
normalized["model"] = model_name.strip()
models = entry.get("models")
if isinstance(models, dict) and models:
normalized["models"] = models
context_length = entry.get("context_length")
if isinstance(context_length, int) and context_length > 0:
normalized["context_length"] = context_length
rate_limit_delay = entry.get("rate_limit_delay")
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
normalized["rate_limit_delay"] = rate_limit_delay
return normalized
def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:
"""Normalize ``providers`` config entries into the legacy custom-provider shape."""
if not isinstance(providers_dict, dict):
return []
custom_providers: List[Dict[str, Any]] = []
for key, entry in providers_dict.items():
normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))
if normalized is not None:
custom_providers.append(normalized)
return custom_providers
def get_compatible_custom_providers(
config: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Return a deduplicated custom-provider view across legacy and v12+ config.
``custom_providers`` remains the on-disk legacy format, while ``providers``
is the newer keyed schema. Runtime and picker flows still need a single
list-shaped view, but we should not materialise that compatibility layer
back into config.yaml because it duplicates entries in UIs.
"""
if config is None:
config = load_config()
compatible: List[Dict[str, Any]] = []
seen_provider_keys: set = set()
seen_name_url_pairs: set = set()
def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
if entry is None:
return
provider_key = str(entry.get("provider_key", "") or "").strip().lower()
name = str(entry.get("name", "") or "").strip().lower()
base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower()
model = str(entry.get("model", "") or "").strip().lower()
pair = (name, base_url, model)
if provider_key and provider_key in seen_provider_keys:
return
if name and base_url and pair in seen_name_url_pairs:
return
compatible.append(entry)
if provider_key:
seen_provider_keys.add(provider_key)
if name and base_url:
seen_name_url_pairs.add(pair)
custom_providers = config.get("custom_providers")
if custom_providers is not None:
if not isinstance(custom_providers, list):
return []
for entry in custom_providers:
_append_if_new(_normalize_custom_provider_entry(entry))
for entry in providers_dict_to_custom_providers(config.get("providers")):
_append_if_new(entry)
return compatible
def check_config_version() -> Tuple[int, int]:
"""
Check config version.
@@ -2082,8 +1861,8 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if migrated_count > 0:
config["providers"] = providers_dict
# Remove the old list — runtime reads via get_compatible_custom_providers()
config.pop("custom_providers", None)
# Remove the old list
del config["custom_providers"]
save_config(config)
if not quiet:
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
@@ -2543,7 +2322,6 @@ _FALLBACK_COMMENT = """
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
@@ -2587,7 +2365,6 @@ _COMMENTED_SECTIONS = """
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
+2 -5
View File
@@ -42,7 +42,6 @@ _PROVIDER_ENV_HINTS = (
"ZAI_API_KEY",
"Z_AI_API_KEY",
"KIMI_API_KEY",
"KIMI_CN_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"KILOCODE_API_KEY",
@@ -722,15 +721,13 @@ def run_doctor(args):
_apikey_providers = [
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
("OpenCode Go", ("OPENCODE_GO_API_KEY",), "https://opencode.ai/zen/go/v1/models", "OPENCODE_GO_BASE_URL", True),
@@ -750,7 +747,7 @@ def run_doctor(args):
print(f" Checking {_pname} API...", end="", flush=True)
try:
import httpx
_base = os.getenv(_base_env, "") if _base_env else ""
_base = os.getenv(_base_env, "")
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com
if not _base and _key.startswith("sk-kimi-"):
_base = "https://api.kimi.com/coding/v1"
-1
View File
@@ -131,7 +131,6 @@ def _configured_platforms() -> list[str]:
"wecom": "WECOM_BOT_ID",
"wecom_callback": "WECOM_CALLBACK_CORP_ID",
"weixin": "WEIXIN_ACCOUNT_ID",
"qqbot": "QQ_APP_ID",
}
return [name for name, env in checks.items() if os.getenv(env)]
+3 -149
View File
@@ -10,7 +10,6 @@ import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@@ -38,10 +37,6 @@ from hermes_cli.setup import (
from hermes_cli.colors import Colors, color
_SERVICE_READINESS_TIMEOUT = 30.0
_SERVICE_READINESS_POLL_INTERVAL = 0.2
# =============================================================================
# Process Management (for manual gateway runs)
# =============================================================================
@@ -1105,123 +1100,12 @@ def systemd_uninstall(system: bool = False):
print(f"{_service_scope_label(system).capitalize()} service uninstalled")
def _describe_startup_check(check_id: str, check: dict) -> str:
source = check.get("source")
detail = check.get("detail")
label = f"{check_id} ({source})" if source and source != check_id else check_id
return f"{label}: {detail}" if detail else label
def _classify_startup_checks(state: dict | None) -> tuple[list[str], list[str], list[str]]:
checks = (state or {}).get("startup_checks") or {}
pending_required: list[str] = []
failed_required: list[str] = []
optional_warnings: list[str] = []
if not isinstance(checks, dict):
return pending_required, failed_required, optional_warnings
for check_id, raw_check in checks.items():
check = raw_check if isinstance(raw_check, dict) else {}
label = _describe_startup_check(str(check_id), check)
check_state = str(check.get("state", "pending")).strip().lower()
required = bool(check.get("required", True))
if check_state == "ready":
continue
if required:
if check_state == "failed":
failed_required.append(label)
else:
pending_required.append(label)
else:
prefix = "failed" if check_state == "failed" else "pending"
optional_warnings.append(f"{prefix}: {label}")
return pending_required, failed_required, optional_warnings
def _wait_for_service_readiness(
*,
action: str,
previous_pid: int | None = None,
timeout: float = _SERVICE_READINESS_TIMEOUT,
poll_interval: float = _SERVICE_READINESS_POLL_INTERVAL,
) -> list[str]:
from gateway.status import get_running_pid, read_runtime_status
deadline = time.monotonic() + timeout
last_pending: list[str] = []
while time.monotonic() < deadline:
live_pid = get_running_pid()
if live_pid is None or (previous_pid is not None and live_pid == previous_pid):
time.sleep(poll_interval)
continue
runtime = read_runtime_status() or {}
try:
runtime_pid = int(runtime.get("pid"))
except (TypeError, ValueError):
runtime_pid = None
if runtime_pid != live_pid:
time.sleep(poll_interval)
continue
gateway_state = runtime.get("gateway_state")
pending_required, failed_required, optional_warnings = _classify_startup_checks(runtime)
last_pending = pending_required
if gateway_state == "startup_failed":
reason = runtime.get("exit_reason") or f"gateway {action} failed during startup"
raise RuntimeError(reason)
if failed_required:
raise RuntimeError(
"required startup checks failed: " + "; ".join(failed_required)
)
if gateway_state == "running" and not pending_required:
return optional_warnings
time.sleep(poll_interval)
if last_pending:
raise RuntimeError(
"timed out waiting for required startup checks: " + "; ".join(last_pending)
)
if previous_pid is not None:
raise RuntimeError(
f"timed out waiting for gateway {action}; previous process is still active or no new runtime became ready"
)
raise RuntimeError(f"timed out waiting for gateway {action} readiness")
def _await_service_ready_or_exit(
*,
action: str,
previous_pid: int | None = None,
timeout: float = _SERVICE_READINESS_TIMEOUT,
) -> None:
try:
optional_warnings = _wait_for_service_readiness(
action=action,
previous_pid=previous_pid,
timeout=timeout,
)
except RuntimeError as exc:
print_error(f" Gateway {action} did not become ready: {exc}")
raise SystemExit(1) from exc
for warning in optional_warnings:
print_warning(f" Optional startup check {warning}")
def systemd_start(system: bool = False):
system = _select_systemd_scope(system)
if system:
_require_root_for_system_service("start")
refresh_systemd_unit_if_needed(system=system)
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
_await_service_ready_or_exit(action="start")
print(f"{_service_scope_label(system).capitalize()} service started")
@@ -1244,11 +1128,9 @@ def systemd_restart(system: bool = False):
pid = get_running_pid()
if pid is not None and _request_gateway_self_restart(pid):
_await_service_ready_or_exit(action="restart", previous_pid=pid)
print(f"{_service_scope_label(system).capitalize()} service restarted")
print(f"{_service_scope_label(system).capitalize()} service restart requested")
return
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
_await_service_ready_or_exit(action="restart", previous_pid=pid)
print(f"{_service_scope_label(system).capitalize()} service restarted")
@@ -1507,7 +1389,6 @@ def launchd_start():
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
_await_service_ready_or_exit(action="start")
print("✓ Service started")
return
@@ -1520,7 +1401,6 @@ def launchd_start():
print("↻ launchd job was unloaded; reloading service definition")
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
_await_service_ready_or_exit(action="start")
print("✓ Service started")
def launchd_stop():
@@ -1591,8 +1471,7 @@ def launchd_restart():
try:
pid = get_running_pid()
if pid is not None and _request_gateway_self_restart(pid):
_await_service_ready_or_exit(action="restart", previous_pid=pid)
print("✓ Service restarted")
print("✓ Service restart requested")
return
if pid is not None:
try:
@@ -1604,7 +1483,6 @@ def launchd_restart():
if not exited:
print(f"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart")
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
_await_service_ready_or_exit(action="restart", previous_pid=pid)
print("✓ Service restarted")
except subprocess.CalledProcessError as e:
if e.returncode not in (3, 113):
@@ -1614,7 +1492,6 @@ def launchd_restart():
plist_path = get_launchd_plist_path()
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
_await_service_ready_or_exit(action="restart", previous_pid=pid)
print("✓ Service restarted")
def launchd_status(deep: bool = False):
@@ -1757,7 +1634,7 @@ _PLATFORMS = [
" Create an App-Level Token with scope: connections:write → copy xapp-... token",
"3. Add Bot Token Scopes: Features → OAuth & Permissions → Scopes",
" Required: chat:write, app_mentions:read, channels:history, channels:read,",
" groups:history, im:history, im:read, im:write, users:read, files:read, files:write",
" groups:history, im:history, im:read, im:write, users:read, files:write",
"4. Subscribe to Events: Features → Event Subscriptions → Enable",
" Required events: message.im, message.channels, app_mention",
" Optional: message.groups (for private channels)",
@@ -2036,29 +1913,6 @@ _PLATFORMS = [
"help": "Phone number or Apple ID to deliver cron results and notifications to."},
],
},
{
"key": "qqbot",
"label": "QQ Bot",
"emoji": "🐧",
"token_var": "QQ_APP_ID",
"setup_instructions": [
"1. Register a QQ Bot application at q.qq.com",
"2. Note your App ID and App Secret from the application page",
"3. Enable the required intents (C2C, Group, Guild messages)",
"4. Configure sandbox or publish the bot",
],
"vars": [
{"name": "QQ_APP_ID", "prompt": "QQ Bot App ID", "password": False,
"help": "Your QQ Bot App ID from q.qq.com."},
{"name": "QQ_CLIENT_SECRET", "prompt": "QQ Bot App Secret", "password": True,
"help": "Your QQ Bot App Secret from q.qq.com."},
{"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False,
"is_allowlist": True,
"help": "Optional — restrict DM access to specific user OpenIDs."},
{"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False,
"help": "OpenID to deliver cron results and notifications to."},
],
},
]
+119 -137
View File
@@ -999,7 +999,7 @@ def select_provider_and_model(args=None):
from hermes_cli.auth import (
resolve_provider, AuthError, format_auth_error,
)
from hermes_cli.config import get_compatible_custom_providers, load_config, get_env_value
from hermes_cli.config import load_config, get_env_value
config = load_config()
current_model = config.get("model")
@@ -1034,9 +1034,28 @@ def select_provider_and_model(args=None):
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
active = "custom"
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
provider_labels = {
"openrouter": "OpenRouter",
"nous": "Nous Portal",
"openai-codex": "OpenAI Codex",
"qwen-oauth": "Qwen OAuth",
"copilot-acp": "GitHub Copilot ACP",
"copilot": "GitHub Copilot",
"anthropic": "Anthropic",
"gemini": "Google AI Studio",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"ai-gateway": "AI Gateway",
"kilocode": "Kilo Code",
"alibaba": "Alibaba Cloud (DashScope)",
"huggingface": "Hugging Face",
"xiaomi": "Xiaomi MiMo",
"custom": "Custom endpoint",
}
active_label = provider_labels.get(active, active) if active else "none"
print()
@@ -1044,12 +1063,38 @@ def select_provider_and_model(args=None):
print(f" Active provider: {active_label}")
print()
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
# Step 1: Provider selection — top providers shown first, rest behind "More..."
top_providers = [
("nous", "Nous Portal (Nous Research subscription)"),
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
("openai-codex", "OpenAI Codex"),
("qwen-oauth", "Qwen OAuth (reuses local Qwen CLI login)"),
("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
("huggingface", "Hugging Face Inference Providers (20+ open models)"),
]
extended_providers = [
("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
("gemini", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
("minimax", "MiniMax (global direct API)"),
("minimax-cn", "MiniMax China (domestic direct API)"),
("kilocode", "Kilo Code (Kilo Gateway API)"),
("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
("opencode-go", "OpenCode Go (open models, $10/month subscription)"),
("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
("xiaomi", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
]
def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
custom_providers_cfg = cfg.get("custom_providers") or []
custom_provider_map = {}
for entry in get_compatible_custom_providers(cfg):
if not isinstance(custom_providers_cfg, list):
return custom_provider_map
for entry in custom_providers_cfg:
if not isinstance(entry, dict):
continue
name = (entry.get("name") or "").strip()
@@ -1057,20 +1102,12 @@ def select_provider_and_model(args=None):
if not name or not base_url:
continue
key = "custom:" + name.lower().replace(" ", "-")
provider_key = (entry.get("provider_key") or "").strip()
if provider_key:
try:
resolve_provider(provider_key)
except AuthError:
key = provider_key
custom_provider_map[key] = {
"name": name,
"base_url": base_url,
"api_key": entry.get("api_key", ""),
"key_env": entry.get("key_env", ""),
"model": entry.get("model", ""),
"api_mode": entry.get("api_mode", ""),
"provider_key": provider_key,
}
return custom_provider_map
@@ -1082,22 +1119,29 @@ def select_provider_and_model(args=None):
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
saved_model = provider_info.get("model", "")
model_hint = f"{saved_model}" if saved_model else ""
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
# Build the menu
top_keys = {k for k, _ in top_providers}
extended_keys = {k for k, _ in extended_providers}
# If the active provider is in the extended list, promote it into top
if active and active in extended_keys:
promoted = [(k, l) for k, l in extended_providers if k == active]
extended_providers = [(k, l) for k, l in extended_providers if k != active]
top_providers = promoted + top_providers
top_keys.add(active)
# Build the primary menu
ordered = []
default_idx = 0
for key, label in all_providers:
for key, label in top_providers:
if active and key == active:
ordered.append((key, f"{label} ← currently active"))
default_idx = len(ordered) - 1
else:
ordered.append((key, label))
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(config.get("custom_providers"))
if _has_saved_custom_list:
ordered.append(("remove-custom", "Remove a saved custom provider"))
ordered.append(("more", "More providers..."))
ordered.append(("cancel", "Cancel"))
provider_idx = _prompt_provider_choice(
@@ -1109,6 +1153,22 @@ def select_provider_and_model(args=None):
selected_provider = ordered[provider_idx][0]
# "More providers..." — show the extended list
if selected_provider == "more":
ext_ordered = list(extended_providers)
ext_ordered.append(("custom", "Custom endpoint (enter URL manually)"))
if _custom_provider_map:
ext_ordered.append(("remove-custom", "Remove a saved custom provider"))
ext_ordered.append(("cancel", "Cancel"))
ext_idx = _prompt_provider_choice(
[label for _, label in ext_ordered], default=0,
)
if ext_idx is None or ext_ordered[ext_idx][0] == "cancel":
print("No change.")
return
selected_provider = ext_ordered[ext_idx][0]
# Step 2: Provider-specific setup + model selection
if selected_provider == "openrouter":
_model_flow_openrouter(config, current_model)
@@ -1124,7 +1184,7 @@ def select_provider_and_model(args=None):
_model_flow_copilot(config, current_model)
elif selected_provider == "custom":
_model_flow_custom(config)
elif selected_provider.startswith("custom:") or selected_provider in _custom_provider_map:
elif selected_provider.startswith("custom:"):
provider_info = _named_custom_provider_map(load_config()).get(selected_provider)
if provider_info is None:
print(
@@ -1139,7 +1199,7 @@ def select_provider_and_model(args=None):
_model_flow_anthropic(config, current_model)
elif selected_provider == "kimi-coding":
_model_flow_kimi(config, current_model)
elif selected_provider in ("gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi", "arcee"):
elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi"):
_model_flow_api_key_provider(config, selected_provider, current_model)
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
@@ -1618,10 +1678,6 @@ def _model_flow_custom(config):
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
context_length_str = input("Context length in tokens [leave blank for auto-detect]: ").strip()
# Prompt for a display name — shown in the provider menu on future runs
default_name = _auto_provider_name(effective_url)
display_name = input(f"Display name [{default_name}]: ").strip() or default_name
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return
@@ -1677,37 +1733,15 @@ def _model_flow_custom(config):
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
# Auto-save to custom_providers so it appears in the menu next time
_save_custom_provider(effective_url, effective_key, model_name or "",
context_length=context_length, name=display_name)
_save_custom_provider(effective_url, effective_key, model_name or "", context_length=context_length)
def _auto_provider_name(base_url: str) -> str:
"""Generate a display name from a custom endpoint URL.
Returns a human-friendly label like "Local (localhost:11434)" or
"RunPod (xyz.runpod.io)". Used as the default when prompting the
user for a display name during custom endpoint setup.
"""
import re
clean = base_url.replace("https://", "").replace("http://", "").rstrip("/")
clean = re.sub(r"/v1/?$", "", clean)
name = clean.split("/")[0]
if "localhost" in name or "127.0.0.1" in name:
name = f"Local ({name})"
elif "runpod" in name.lower():
name = f"RunPod ({name})"
else:
name = name.capitalize()
return name
def _save_custom_provider(base_url, api_key="", model="", context_length=None,
name=None):
def _save_custom_provider(base_url, api_key="", model="", context_length=None):
"""Save a custom endpoint to custom_providers in config.yaml.
Deduplicates by base_url if the URL already exists, updates the
model name and context_length but doesn't add a duplicate entry.
Uses *name* when provided, otherwise auto-generates from the URL.
Auto-generates a display name from the URL hostname.
"""
from hermes_cli.config import load_config, save_config
@@ -1735,9 +1769,20 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None,
save_config(cfg)
return # already saved, updated if needed
# Use provided name or auto-generate from URL
if not name:
name = _auto_provider_name(base_url)
# Auto-generate a name from the URL
import re
clean = base_url.replace("https://", "").replace("http://", "").rstrip("/")
# Remove /v1 suffix for cleaner names
clean = re.sub(r"/v1/?$", "", clean)
# Use hostname:port as the name
name = clean.split("/")[0]
# Capitalize for readability
if "localhost" in name or "127.0.0.1" in name:
name = f"Local ({name})"
elif "runpod" in name.lower():
name = f"RunPod ({name})"
else:
name = name.capitalize()
entry = {"name": name, "base_url": base_url}
if api_key:
@@ -1824,9 +1869,7 @@ def _model_flow_named_custom(config, provider_info):
name = provider_info["name"]
base_url = provider_info["base_url"]
api_key = provider_info.get("api_key", "")
key_env = provider_info.get("key_env", "")
saved_model = provider_info.get("model", "")
provider_key = (provider_info.get("provider_key") or "").strip()
print(f" Provider: {name}")
print(f" URL: {base_url}")
@@ -1909,15 +1952,10 @@ def _model_flow_named_custom(config, provider_info):
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
if provider_key:
model["provider"] = provider_key
model.pop("base_url", None)
model.pop("api_key", None)
else:
model["provider"] = "custom"
model["base_url"] = base_url
if api_key:
model["api_key"] = api_key
model["provider"] = "custom"
model["base_url"] = base_url
if api_key:
model["api_key"] = api_key
# Apply api_mode from custom_providers entry, or clear stale value
custom_api_mode = provider_info.get("api_mode", "")
if custom_api_mode:
@@ -1927,23 +1965,8 @@ def _model_flow_named_custom(config, provider_info):
save_config(cfg)
deactivate_provider()
# Persist the selected model back to whichever schema owns this endpoint.
if provider_key:
cfg = load_config()
providers_cfg = cfg.get("providers")
if isinstance(providers_cfg, dict):
provider_entry = providers_cfg.get(provider_key)
if isinstance(provider_entry, dict):
provider_entry["default_model"] = model_name
if api_key and not str(provider_entry.get("api_key", "") or "").strip():
provider_entry["api_key"] = api_key
if key_env and not str(provider_entry.get("key_env", "") or "").strip():
provider_entry["key_env"] = key_env
cfg["providers"] = providers_cfg
save_config(cfg)
else:
# Save model name to the custom_providers entry for next time
_save_custom_provider(base_url, api_key, model_name)
# Save model name to the custom_providers entry for next time
_save_custom_provider(base_url, api_key, model_name)
print(f"\n✅ Model set to: {model_name}")
print(f" Provider: {name} ({base_url})")
@@ -2643,12 +2666,13 @@ def _run_anthropic_oauth_flow(save_env_value):
def _model_flow_anthropic(config, current_model=""):
"""Flow for Anthropic provider — OAuth subscription, API key, or Claude Code creds."""
import os
from hermes_cli.auth import (
_prompt_model_selection, _save_model_choice,
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
deactivate_provider,
)
from hermes_cli.config import (
save_env_value, load_config, save_config,
get_env_value, save_env_value, load_config, save_config,
save_anthropic_api_key,
)
from hermes_cli.models import _PROVIDER_MODELS
@@ -4036,40 +4060,7 @@ def cmd_update(args):
capture_output=True, text=True, timeout=15,
)
if restart.returncode == 0:
# Verify the service actually survived the
# restart. systemctl restart returns 0 even
# if the new process crashes immediately.
import time as _time
_time.sleep(3)
verify = subprocess.run(
scope_cmd + ["is-active", svc_name],
capture_output=True, text=True, timeout=5,
)
if verify.stdout.strip() == "active":
restarted_services.append(svc_name)
else:
# Retry once — transient startup failures
# (stale module cache, import race) often
# resolve on the second attempt.
print(f"{svc_name} died after restart, retrying...")
retry = subprocess.run(
scope_cmd + ["restart", svc_name],
capture_output=True, text=True, timeout=15,
)
_time.sleep(3)
verify2 = subprocess.run(
scope_cmd + ["is-active", svc_name],
capture_output=True, text=True, timeout=5,
)
if verify2.stdout.strip() == "active":
restarted_services.append(svc_name)
print(f"{svc_name} recovered on retry")
else:
print(
f"{svc_name} failed to stay running after restart.\n"
f" Check logs: journalctl --user -u {svc_name} --since '2 min ago'\n"
f" Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}"
)
restarted_services.append(svc_name)
else:
print(f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}")
except (FileNotFoundError, subprocess.TimeoutExpired):
@@ -4157,8 +4148,6 @@ def _coalesce_session_name_args(argv: list) -> list:
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
"mcp", "sessions", "insights", "version", "update", "uninstall",
"profile", "dashboard",
"honcho", "claw", "plugins", "acp",
"webhook", "memory", "dump", "debug", "backup", "import", "completion", "logs",
}
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
@@ -4454,20 +4443,17 @@ def cmd_dashboard(args):
host=args.host,
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
)
def cmd_completion(args, parser=None):
def cmd_completion(args):
"""Print shell completion script."""
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion
shell = getattr(args, "shell", "bash")
if shell == "zsh":
print(generate_zsh(parser))
elif shell == "fish":
print(generate_fish(parser))
print(generate_zsh_completion())
else:
print(generate_bash(parser))
print(generate_bash_completion())
def cmd_logs(args):
@@ -4612,7 +4598,7 @@ For more help on a command:
)
chat_parser.add_argument(
"--provider",
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"],
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "xiaomi"],
default=None,
help="Inference provider (default: auto)"
)
@@ -5947,13 +5933,13 @@ Examples:
# =========================================================================
completion_parser = subparsers.add_parser(
"completion",
help="Print shell completion script (bash, zsh, or fish)",
help="Print shell completion script (bash or zsh)",
)
completion_parser.add_argument(
"shell", nargs="?", default="bash", choices=["bash", "zsh", "fish"],
"shell", nargs="?", default="bash", choices=["bash", "zsh"],
help="Shell type (default: bash)",
)
completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser))
completion_parser.set_defaults(func=cmd_completion)
# =========================================================================
# dashboard command
@@ -5966,10 +5952,6 @@ Examples:
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
dashboard_parser.add_argument(
"--insecure", action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================
+5 -9
View File
@@ -324,9 +324,6 @@ def cmd_setup(args) -> None:
val = _prompt(desc, default=str(effective_default) if effective_default else None)
if val:
provider_config[key] = val
# Also write to .env if this field has an env_var
if env_var and env_var not in env_writes:
env_writes[env_var] = val
# Write activation key to config.yaml
config["memory"]["provider"] = name
@@ -412,13 +409,12 @@ def cmd_status(args) -> None:
else:
print(f" Status: not available ✗")
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
# Check all fields that have env_var (both secret and non-secret)
required_fields = [f for f in schema if f.get("env_var")]
if required_fields:
secrets = [f for f in schema if f.get("secret")]
if secrets:
print(f" Missing:")
for f in required_fields:
env_var = f.get("env_var", "")
url = f.get("url", "")
for s in secrets:
env_var = s.get("env_var", "")
url = s.get("url", "")
is_set = bool(os.environ.get(env_var))
mark = "" if is_set else ""
line = f" {mark} {env_var}"
-3
View File
@@ -51,7 +51,6 @@ _VENDOR_PREFIXES: dict[str, str] = {
"grok": "x-ai",
"qwen": "qwen",
"mimo": "xiaomi",
"trinity": "arcee-ai",
"nemotron": "nvidia",
"llama": "meta-llama",
"step": "stepfun",
@@ -89,13 +88,11 @@ _AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
_MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
"zai",
"kimi-coding",
"kimi-coding-cn",
"minimax",
"minimax-cn",
"alibaba",
"qwen-oauth",
"xiaomi",
"arcee",
"custom",
})
+11 -88
View File
@@ -41,6 +41,7 @@ from agent.models_dev import (
get_model_capabilities,
get_model_info,
list_provider_models,
search_models_dev,
)
logger = logging.getLogger(__name__)
@@ -705,10 +706,6 @@ def switch_model(
error_message=msg,
)
# Apply auto-correction if validation found a closer match
if validation.get("corrected_model"):
new_model = validation["corrected_model"]
# --- OpenCode api_mode override ---
if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}:
api_mode = opencode_model_api_mode(target_provider, new_model)
@@ -938,65 +935,6 @@ def list_authenticated_providers(
seen_slugs.add(pid)
seen_slugs.add(hermes_slug)
# --- 2b. Cross-check canonical provider list ---
# Catches providers that are in CANONICAL_PROVIDERS but weren't found
# in PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS (keeps /model in sync
# with `hermes model`).
try:
from hermes_cli.models import CANONICAL_PROVIDERS as _canon_provs
except ImportError:
_canon_provs = []
for _cp in _canon_provs:
if _cp.slug in seen_slugs:
continue
# Check credentials via PROVIDER_REGISTRY (auth.py)
_cp_config = _auth_registry.get(_cp.slug)
_cp_has_creds = False
if _cp_config and _cp_config.api_key_env_vars:
_cp_has_creds = any(os.environ.get(ev) for ev in _cp_config.api_key_env_vars)
# Also check auth store and credential pool
if not _cp_has_creds:
try:
from hermes_cli.auth import _load_auth_store
_cp_store = _load_auth_store()
_cp_providers_store = _cp_store.get("providers", {})
_cp_pool_store = _cp_store.get("credential_pool", {})
if _cp_store and (
_cp.slug in _cp_providers_store
or _cp.slug in _cp_pool_store
):
_cp_has_creds = True
except Exception:
pass
if not _cp_has_creds:
try:
from agent.credential_pool import load_pool
_cp_pool = load_pool(_cp.slug)
if _cp_pool.has_credentials():
_cp_has_creds = True
except Exception:
pass
if not _cp_has_creds:
continue
_cp_model_ids = curated.get(_cp.slug, [])
_cp_total = len(_cp_model_ids)
_cp_top = _cp_model_ids[:max_models]
results.append({
"slug": _cp.slug,
"name": _cp.label,
"is_current": _cp.slug == current_provider,
"is_user_defined": False,
"models": _cp_top,
"total_models": _cp_total,
"source": "canonical",
})
seen_slugs.add(_cp.slug)
# --- 3. User-defined endpoints from config ---
if user_providers and isinstance(user_providers, dict):
for ep_name, ep_cfg in user_providers.items():
@@ -1031,17 +969,7 @@ def list_authenticated_providers(
})
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named
# provider. Entries sharing the same provider name are grouped into a
# single picker row so that e.g. four Ollama Cloud entries
# (qwen3-coder, glm-5.1, kimi-k2, minimax-m2.7) appear as one
# "Ollama Cloud" row with four models inside instead of four
# duplicate "Ollama Cloud" rows. Entries with distinct provider names
# still produce separate rows (e.g. Ollama Cloud vs Moonshot).
if custom_providers and isinstance(custom_providers, list):
from collections import OrderedDict
groups: "OrderedDict[str, dict]" = OrderedDict()
for entry in custom_providers:
if not isinstance(entry, dict):
continue
@@ -1057,28 +985,23 @@ def list_authenticated_providers(
continue
slug = custom_provider_slug(display_name)
if slug not in groups:
groups[slug] = {
"name": display_name,
"api_url": api_url,
"models": [],
}
default_model = (entry.get("model") or "").strip()
if default_model and default_model not in groups[slug]["models"]:
groups[slug]["models"].append(default_model)
for slug, grp in groups.items():
if slug in seen_slugs:
continue
models_list = []
default_model = (entry.get("model") or "").strip()
if default_model:
models_list.append(default_model)
results.append({
"slug": slug,
"name": grp["name"],
"name": display_name,
"is_current": slug == current_provider,
"is_user_defined": True,
"models": grp["models"],
"total_models": len(grp["models"]),
"models": models_list,
"total_models": len(models_list),
"source": "user-config",
"api_url": grp["api_url"],
"api_url": api_url,
})
seen_slugs.add(slug)
+41 -110
View File
@@ -12,7 +12,7 @@ import os
import urllib.request
import urllib.error
from difflib import get_close_matches
from typing import Any, NamedTuple, Optional
from typing import Any, Optional
COPILOT_BASE_URL = "https://api.githubcopilot.com"
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
@@ -29,7 +29,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("qwen/qwen3.6-plus", ""),
("anthropic/claude-sonnet-4.5", ""),
("anthropic/claude-haiku-4.5", ""),
("openrouter/elephant-alpha", "free"),
("openai/gpt-5.4", ""),
("openai/gpt-5.4-mini", ""),
("xiaomi/mimo-v2-pro", ""),
@@ -44,7 +43,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("minimax/minimax-m2.7", ""),
("minimax/minimax-m2.5", ""),
("z-ai/glm-5.1", ""),
("z-ai/glm-5v-turbo", ""),
("z-ai/glm-5-turbo", ""),
("moonshotai/kimi-k2.5", ""),
("x-ai/grok-4.20", ""),
@@ -90,7 +88,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"minimax/minimax-m2.7",
"minimax/minimax-m2.5",
"z-ai/glm-5.1",
"z-ai/glm-5v-turbo",
"z-ai/glm-5-turbo",
"moonshotai/kimi-k2.5",
"x-ai/grok-4.20-beta",
@@ -100,7 +97,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"arcee-ai/trinity-large-thinking",
"openai/gpt-5.4-pro",
"openai/gpt-5.4-nano",
"openrouter/elephant-alpha",
],
"openai-codex": _codex_curated_models(),
"copilot-acp": [
@@ -136,7 +132,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"zai": [
"glm-5.1",
"glm-5",
"glm-5v-turbo",
"glm-5-turbo",
"glm-4.7",
"glm-4.5",
@@ -163,12 +158,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"kimi-coding-cn": [
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"moonshot": [
"kimi-k2.5",
"kimi-k2-thinking",
@@ -205,11 +194,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"mimo-v2-omni",
"mimo-v2-flash",
],
"arcee": [
"trinity-large-thinking",
"trinity-large-preview",
"trinity-mini",
],
"opencode-zen": [
"gpt-5.4-pro",
"gpt-5.4",
@@ -495,52 +479,29 @@ def check_nous_free_tier() -> bool:
return False # default to paid on error — don't block users
# ---------------------------------------------------------------------------
# Canonical provider list — single source of truth for provider identity.
# Every code path that lists, displays, or iterates providers derives from
# this list: hermes model, /model, /provider, list_authenticated_providers.
#
# Fields:
# slug — internal provider ID (used in config.yaml, --provider flag)
# label — short display name
# tui_desc — longer description for the `hermes model` interactive picker
# ---------------------------------------------------------------------------
class ProviderEntry(NamedTuple):
slug: str
label: str
tui_desc: str # detailed description for `hermes model` TUI
CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
ProviderEntry("kimi-coding", "Kimi / Moonshot", "Kimi / Moonshot (Moonshot AI direct API)"),
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"),
ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"),
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (200+ models, pay-per-use)"),
]
# Derived dicts — used throughout the codebase
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
_PROVIDER_LABELS = {
"openrouter": "OpenRouter",
"openai-codex": "OpenAI Codex",
"copilot-acp": "GitHub Copilot ACP",
"nous": "Nous Portal",
"copilot": "GitHub Copilot",
"gemini": "Google AI Studio",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"anthropic": "Anthropic",
"deepseek": "DeepSeek",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"ai-gateway": "AI Gateway",
"kilocode": "Kilo Code",
"alibaba": "Alibaba Cloud (DashScope)",
"qwen-oauth": "Qwen OAuth (Portal)",
"huggingface": "Hugging Face",
"xiaomi": "Xiaomi MiMo",
"custom": "Custom endpoint",
}
_PROVIDER_ALIASES = {
"glm": "zai",
@@ -558,10 +519,6 @@ _PROVIDER_ALIASES = {
"google-ai-studio": "gemini",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
"moonshot-cn": "kimi-coding-cn",
"arcee-ai": "arcee",
"arceeai": "arcee",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"claude": "anthropic",
@@ -587,9 +544,6 @@ _PROVIDER_ALIASES = {
"huggingface-hub": "huggingface",
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
"grok": "xai",
"x-ai": "xai",
"x.ai": "xai",
}
@@ -676,6 +630,13 @@ def model_ids(*, force_refresh: bool = False) -> list[str]:
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
def menu_labels(*, force_refresh: bool = False) -> list[str]:
"""Return display labels like 'anthropic/claude-opus-4.6 (recommended)'."""
labels = []
for mid, desc in fetch_openrouter_models(force_refresh=force_refresh):
labels.append(f"{mid} ({desc})" if desc else mid)
return labels
# ---------------------------------------------------------------------------
@@ -875,20 +836,23 @@ def list_available_providers() -> list[dict[str, str]]:
Each dict has ``id``, ``label``, and ``aliases``.
Checks which providers have valid credentials configured.
Derives the provider list from :data:`CANONICAL_PROVIDERS` (single
source of truth shared with ``hermes model``, ``/model``, etc.).
"""
# Derive display order from canonical list + custom
provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["custom"]
# Canonical providers in display order
_PROVIDER_ORDER = [
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "huggingface",
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
"qwen-oauth", "xiaomi",
"opencode-zen", "opencode-go",
"ai-gateway", "deepseek", "custom",
]
# Build reverse alias map
aliases_for: dict[str, list[str]] = {}
for alias, canonical in _PROVIDER_ALIASES.items():
aliases_for.setdefault(canonical, []).append(alias)
result = []
for pid in provider_order:
for pid in _PROVIDER_ORDER:
label = _PROVIDER_LABELS.get(pid, pid)
alias_list = aliases_for.get(pid, [])
# Check if this provider has credentials available
@@ -1823,17 +1787,6 @@ def validate_requested_model(
"message": None,
}
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
@@ -1885,16 +1838,6 @@ def validate_requested_model(
"recognized": True,
"message": None,
}
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, codex_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
@@ -1927,18 +1870,6 @@ def validate_requested_model(
# the user may have access to models not shown in the public
# listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding
# endpoints even though it's not in /models). Warn but allow.
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
-1
View File
@@ -35,7 +35,6 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")),
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")),
("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")),
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
])
+11 -107
View File
@@ -31,6 +31,7 @@ import importlib
import importlib.metadata
import importlib.util
import logging
import os
import sys
import types
from dataclasses import dataclass, field
@@ -262,53 +263,6 @@ class PluginContext:
self._manager._hooks.setdefault(hook_name, []).append(callback)
logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name)
# -- skill registration -------------------------------------------------
def register_skill(
self,
name: str,
path: Path,
description: str = "",
) -> None:
"""Register a read-only skill provided by this plugin.
The skill becomes resolvable as ``'<plugin_name>:<name>'`` via
``skill_view()``. It does **not** enter the flat
``~/.hermes/skills/`` tree and is **not** listed in the system
prompt's ``<available_skills>`` index — plugin skills are
opt-in explicit loads only.
Raises:
ValueError: if *name* contains ``':'`` or invalid characters.
FileNotFoundError: if *path* does not exist.
"""
from agent.skill_utils import _NAMESPACE_RE
if ":" in name:
raise ValueError(
f"Skill name '{name}' must not contain ':' "
f"(the namespace is derived from the plugin name "
f"'{self.manifest.name}' automatically)."
)
if not name or not _NAMESPACE_RE.match(name):
raise ValueError(
f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+."
)
if not path.exists():
raise FileNotFoundError(f"SKILL.md not found at {path}")
qualified = f"{self.manifest.name}:{name}"
self._manager._plugin_skills[qualified] = {
"path": path,
"plugin": self.manifest.name,
"bare_name": name,
"description": description,
}
logger.debug(
"Plugin %s registered skill: %s",
self.manifest.name, qualified,
)
# ---------------------------------------------------------------------------
# PluginManager
@@ -325,8 +279,6 @@ class PluginManager:
self._context_engine = None # Set by a plugin via register_context_engine()
self._discovered: bool = False
self._cli_ref = None # Set by CLI after plugin discovery
# Plugin skill registry: qualified name → metadata dict.
self._plugin_skills: Dict[str, Dict[str, Any]] = {}
# -----------------------------------------------------------------------
# Public
@@ -603,28 +555,6 @@ class PluginManager:
)
return result
# -----------------------------------------------------------------------
# Plugin skill lookups
# -----------------------------------------------------------------------
def find_plugin_skill(self, qualified_name: str) -> Optional[Path]:
"""Return the ``Path`` to a plugin skill's SKILL.md, or ``None``."""
entry = self._plugin_skills.get(qualified_name)
return entry["path"] if entry else None
def list_plugin_skills(self, plugin_name: str) -> List[str]:
"""Return sorted bare names of all skills registered by *plugin_name*."""
prefix = f"{plugin_name}:"
return sorted(
e["bare_name"]
for qn, e in self._plugin_skills.items()
if qn.startswith(prefix)
)
def remove_plugin_skill(self, qualified_name: str) -> None:
"""Remove a stale registry entry (silently ignores missing keys)."""
self._plugin_skills.pop(qualified_name, None)
# ---------------------------------------------------------------------------
# Module-level singleton & convenience functions
@@ -654,44 +584,18 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
return get_plugin_manager().invoke_hook(hook_name, **kwargs)
def get_plugin_tool_names() -> Set[str]:
"""Return the set of tool names registered by plugins."""
return get_plugin_manager()._plugin_tool_names
def get_pre_tool_call_block_message(
tool_name: str,
args: Optional[Dict[str, Any]],
task_id: str = "",
session_id: str = "",
tool_call_id: str = "",
) -> Optional[str]:
"""Check ``pre_tool_call`` hooks for a blocking directive.
Plugins that need to enforce policy (rate limiting, security
restrictions, approval workflows) can return::
def get_plugin_cli_commands() -> Dict[str, dict]:
"""Return CLI commands registered by general plugins.
{"action": "block", "message": "Reason the tool was blocked"}
from their ``pre_tool_call`` callback. The first valid block
directive wins. Invalid or irrelevant hook return values are
silently ignored so existing observer-only hooks are unaffected.
Returns a dict of ``{name: {help, setup_fn, handler_fn, ...}}``
suitable for wiring into argparse subparsers.
"""
hook_results = invoke_hook(
"pre_tool_call",
tool_name=tool_name,
args=args if isinstance(args, dict) else {},
task_id=task_id,
session_id=session_id,
tool_call_id=tool_call_id,
)
for result in hook_results:
if not isinstance(result, dict):
continue
if result.get("action") != "block":
continue
message = result.get("message")
if isinstance(message, str) and message:
return message
return None
return dict(get_plugin_manager()._cli_commands)
def get_plugin_context_engine():
@@ -718,7 +622,7 @@ def get_plugin_toolsets() -> List[tuple]:
toolset_tools: Dict[str, List[str]] = {}
toolset_plugin: Dict[str, LoadedPlugin] = {}
for tool_name in manager._plugin_tool_names:
entry = registry.get_entry(tool_name)
entry = registry._tools.get(tool_name)
if not entry:
continue
ts = entry.toolset
@@ -727,7 +631,7 @@ def get_plugin_toolsets() -> List[tuple]:
# Map toolsets back to the plugin that registered them
for _name, loaded in manager._plugins.items():
for tool_name in loaded.tools_registered:
entry = registry.get_entry(tool_name)
entry = registry._tools.get(tool_name)
if entry and entry.toolset in toolset_tools:
toolset_plugin.setdefault(entry.toolset, loaded)
-10
View File
@@ -136,11 +136,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
base_url_env_var="XIAOMI_BASE_URL",
),
"arcee": HermesOverlay(
transport="openai_chat",
base_url_override="https://api.arcee.ai/api/v1",
base_url_env_var="ARCEE_BASE_URL",
),
}
@@ -184,7 +179,6 @@ ALIASES: Dict[str, str] = {
# kimi-for-coding (models.dev ID)
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
"kimi-coding-cn": "kimi-for-coding",
"moonshot": "kimi-for-coding",
# minimax-cn
@@ -236,10 +230,6 @@ ALIASES: Dict[str, str] = {
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
# arcee
"arcee-ai": "arcee",
"arceeai": "arcee",
# Local server aliases → virtual "local" concept (resolved via user config)
"lmstudio": "lmstudio",
"lm-studio": "lmstudio",
+10 -25
View File
@@ -26,7 +26,7 @@ from hermes_cli.auth import (
resolve_external_process_provider_credentials,
has_usable_secret,
)
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_cli.config import load_config
from hermes_constants import OPENROUTER_BASE_URL
@@ -287,9 +287,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# Resolve the API key from the env var name stored in key_env
key_env = str(entry.get("key_env", "") or "").strip()
resolved_api_key = os.getenv(key_env, "").strip() if key_env else ""
# Fall back to inline api_key when key_env is absent or unresolvable
if not resolved_api_key:
resolved_api_key = str(entry.get("api_key", "") or "").strip()
if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
# Found match by provider key
@@ -318,16 +315,13 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# Fall back to custom_providers: list (legacy format)
custom_providers = config.get("custom_providers")
if isinstance(custom_providers, dict):
logger.warning(
"custom_providers in config.yaml is a dict, not a list. "
"Each entry must be prefixed with '-' in YAML. "
"Run 'hermes doctor' for details."
)
return None
custom_providers = get_compatible_custom_providers(config)
if not custom_providers:
if not isinstance(custom_providers, list):
if isinstance(custom_providers, dict):
logger.warning(
"custom_providers in config.yaml is a dict, not a list. "
"Each entry must be prefixed with '-' in YAML. "
"Run 'hermes doctor' for details."
)
return None
for entry in custom_providers:
@@ -339,21 +333,13 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
continue
name_norm = _normalize_custom_provider_name(name)
menu_key = f"custom:{name_norm}"
provider_key = str(entry.get("provider_key", "") or "").strip()
provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else ""
provider_menu_key = f"custom:{provider_key_norm}" if provider_key_norm else ""
if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}:
if requested_norm not in {name_norm, menu_key}:
continue
result = {
"name": name.strip(),
"base_url": base_url.strip(),
"api_key": str(entry.get("api_key", "") or "").strip(),
}
key_env = str(entry.get("key_env", "") or "").strip()
if key_env:
result["key_env"] = key_env
if provider_key:
result["provider_key"] = provider_key
api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode:
result["api_mode"] = api_mode
@@ -395,7 +381,6 @@ def _resolve_named_custom_runtime(
api_key_candidates = [
(explicit_api_key or "").strip(),
str(custom_provider.get("api_key", "") or "").strip(),
os.getenv(str(custom_provider.get("key_env", "") or "").strip(), "").strip(),
os.getenv("OPENAI_API_KEY", "").strip(),
os.getenv("OPENROUTER_API_KEY", "").strip(),
]
@@ -611,7 +596,7 @@ def _resolve_explicit_runtime(
base_url = explicit_base_url
if not base_url:
if provider in ("kimi-coding", "kimi-coding-cn"):
if provider == "kimi-coding":
creds = resolve_api_key_provider_credentials(provider)
base_url = creds.get("base_url", "").rstrip("/")
else:
+47 -66
View File
@@ -43,6 +43,14 @@ def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]:
return {}
def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
if not model_name:
return
model_cfg = _model_config_dict(config)
model_cfg["default"] = model_name
config["model"] = model_cfg
def _get_credential_pool_strategies(config: Dict[str, Any]) -> Dict[str, str]:
strategies = config.get("credential_pool_strategies")
return dict(strategies) if isinstance(strategies, dict) else {}
@@ -98,8 +106,6 @@ _DEFAULT_PROVIDER_MODELS = {
],
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
@@ -129,6 +135,43 @@ def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
agent_cfg["reasoning_effort"] = effort
def _setup_copilot_reasoning_selection(
config: Dict[str, Any],
model_id: str,
prompt_choice,
*,
catalog: Optional[list[dict[str, Any]]] = None,
api_key: str = "",
) -> None:
from hermes_cli.models import github_model_reasoning_efforts, normalize_copilot_model_id
normalized_model = normalize_copilot_model_id(
model_id,
catalog=catalog,
api_key=api_key,
) or model_id
efforts = github_model_reasoning_efforts(normalized_model, catalog=catalog, api_key=api_key)
if not efforts:
return
current_effort = _current_reasoning_effort(config)
choices = list(efforts) + ["Disable reasoning", f"Keep current ({current_effort or 'default'})"]
if current_effort == "none":
default_idx = len(efforts)
elif current_effort in efforts:
default_idx = efforts.index(current_effort)
elif "medium" in efforts:
default_idx = efforts.index("medium")
else:
default_idx = len(choices) - 1
effort_idx = prompt_choice("Select reasoning effort:", choices, default_idx)
if effort_idx < len(efforts):
_set_reasoning_effort(config, efforts[effort_idx])
elif effort_idx == len(efforts):
_set_reasoning_effort(config, "none")
# Import config helpers
@@ -772,11 +815,10 @@ def setup_model_provider(config: dict, *, quick: bool = False):
"copilot-acp": "GitHub Copilot ACP",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
"ai-gateway": "Vercel AI Gateway",
"ai-gateway": "AI Gateway",
"custom": "your custom endpoint",
}
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
@@ -1737,7 +1779,7 @@ def _setup_slack():
print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions")
print_info(" Required scopes: chat:write, app_mentions:read,")
print_info(" channels:history, channels:read, im:history,")
print_info(" im:read, im:write, users:read, files:read, files:write")
print_info(" im:read, im:write, users:read, files:write")
print_info(" Optional for private channels: groups:history")
print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable")
print_info(" Required events: message.im, message.channels, app_mention")
@@ -1969,54 +2011,6 @@ def _setup_wecom_callback():
_gw_setup()
def _setup_qqbot():
"""Configure QQ Bot gateway."""
print_header("QQ Bot")
existing = get_env_value("QQ_APP_ID")
if existing:
print_info("QQ Bot: already configured")
if not prompt_yes_no("Reconfigure QQ Bot?", False):
return
print_info("Connects Hermes to QQ via the Official QQ Bot API (v2).")
print_info(" Requires a QQ Bot application at q.qq.com")
print_info(" Reference: https://bot.q.qq.com/wiki/develop/api-v2/")
print()
app_id = prompt("QQ Bot App ID")
if not app_id:
print_warning("App ID is required — skipping QQ Bot setup")
return
save_env_value("QQ_APP_ID", app_id.strip())
client_secret = prompt("QQ Bot App Secret", password=True)
if not client_secret:
print_warning("App Secret is required — skipping QQ Bot setup")
return
save_env_value("QQ_CLIENT_SECRET", client_secret)
print_success("QQ Bot credentials saved")
print()
print_info("🔒 Security: Restrict who can DM your bot")
print_info(" Use QQ user OpenIDs (found in event payloads)")
print()
allowed_users = prompt("Allowed user OpenIDs (comma-separated, leave empty for open access)")
if allowed_users:
save_env_value("QQ_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("QQ Bot allowlist configured")
else:
print_info("⚠️ No allowlist set — anyone can DM the bot!")
print()
print_info("📬 Home Channel: OpenID for cron job delivery and notifications.")
home_channel = prompt("Home channel OpenID (leave empty to set later)")
if home_channel:
save_env_value("QQ_HOME_CHANNEL", home_channel)
print()
print_success("QQ Bot configured!")
def _setup_bluebubbles():
"""Configure BlueBubbles iMessage gateway."""
print_header("BlueBubbles (iMessage)")
@@ -2082,15 +2076,6 @@ def _setup_bluebubbles():
print_info(" Install: https://docs.bluebubbles.app/helper-bundle/installation")
def _setup_qqbot():
"""Configure QQ Bot (Official API v2) via standard platform setup."""
from hermes_cli.gateway import _PLATFORMS
qq_platform = next((p for p in _PLATFORMS if p["key"] == "qqbot"), None)
if qq_platform:
from hermes_cli.gateway import _setup_standard_platform
_setup_standard_platform(qq_platform)
def _setup_webhooks():
"""Configure webhook integration."""
print_header("Webhooks")
@@ -2154,7 +2139,6 @@ _GATEWAY_PLATFORMS = [
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
("QQ Bot", "QQ_APP_ID", _setup_qqbot),
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
]
@@ -2206,7 +2190,6 @@ def setup_gateway(config: dict):
or get_env_value("WECOM_BOT_ID")
or get_env_value("WEIXIN_ACCOUNT_ID")
or get_env_value("BLUEBUBBLES_SERVER_URL")
or get_env_value("QQ_APP_ID")
or get_env_value("WEBHOOK_ENABLED")
)
if any_messaging:
@@ -2228,8 +2211,6 @@ def setup_gateway(config: dict):
missing_home.append("Slack")
if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"):
missing_home.append("BlueBubbles")
if get_env_value("QQ_APP_ID") and not get_env_value("QQ_HOME_CHANNEL"):
missing_home.append("QQBot")
if missing_home:
print()
+1 -1
View File
@@ -15,7 +15,7 @@ from typing import List, Optional, Set
from hermes_cli.config import load_config, save_config
from hermes_cli.colors import Colors, color
from hermes_cli.platforms import PLATFORMS as _PLATFORMS
from hermes_cli.platforms import PLATFORMS as _PLATFORMS, platform_label
# Backward-compatible view: {key: label_string} so existing code that
# iterates ``PLATFORMS.items()`` or calls ``PLATFORMS.get(key)`` keeps
+9 -102
View File
@@ -32,12 +32,6 @@ All fields are optional. Missing values inherit from the ``default`` skin.
response_border: "#FFD700" # Response box border (ANSI)
session_label: "#DAA520" # Session label color
session_border: "#8B8682" # Session ID dim color
status_bar_bg: "#1a1a2e" # TUI status/usage bar background
voice_status_bg: "#1a1a2e" # TUI voice status background
completion_menu_bg: "#1a1a2e" # Completion menu background
completion_menu_current_bg: "#333355" # Active completion row background
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
completion_menu_meta_current_bg: "#333355" # Active completion meta background
# Spinner: customize the animated spinner during API calls
spinner:
@@ -93,8 +87,6 @@ BUILT-IN SKINS
- ``ares`` Crimson/bronze war-god theme with custom spinner wings
- ``mono`` Clean grayscale monochrome
- ``slate`` Cool blue developer-focused theme
- ``daylight`` Light background theme with dark text and blue accents
- ``warm-lightmode`` Warm brown/gold text for light terminal backgrounds
USER SKINS
==========
@@ -134,6 +126,10 @@ class SkinConfig:
"""Get a color value with fallback."""
return self.colors.get(key, fallback)
def get_spinner_list(self, key: str) -> List[str]:
"""Get a spinner list (faces, verbs, etc.)."""
return self.spinner.get(key, [])
def get_spinner_wings(self) -> List[Tuple[str, str]]:
"""Get spinner wing pairs, or empty list if none."""
raw = self.spinner.get("wings", [])
@@ -312,80 +308,6 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
},
"tool_prefix": "",
},
"daylight": {
"name": "daylight",
"description": "Light theme for bright terminals with dark text and cool blue accents",
"colors": {
"banner_border": "#2563EB",
"banner_title": "#0F172A",
"banner_accent": "#1D4ED8",
"banner_dim": "#475569",
"banner_text": "#111827",
"ui_accent": "#2563EB",
"ui_label": "#0F766E",
"ui_ok": "#15803D",
"ui_error": "#B91C1C",
"ui_warn": "#B45309",
"prompt": "#111827",
"input_rule": "#93C5FD",
"response_border": "#2563EB",
"session_label": "#1D4ED8",
"session_border": "#64748B",
"status_bar_bg": "#E5EDF8",
"voice_status_bg": "#E5EDF8",
"completion_menu_bg": "#F8FAFC",
"completion_menu_current_bg": "#DBEAFE",
"completion_menu_meta_bg": "#EEF2FF",
"completion_menu_meta_current_bg": "#BFDBFE",
},
"spinner": {},
"branding": {
"agent_name": "Hermes Agent",
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! ⚕",
"response_label": " ⚕ Hermes ",
"prompt_symbol": " ",
"help_header": "[?] Available Commands",
},
"tool_prefix": "",
},
"warm-lightmode": {
"name": "warm-lightmode",
"description": "Warm light mode — dark brown/gold text for light terminal backgrounds",
"colors": {
"banner_border": "#8B6914",
"banner_title": "#5C3D11",
"banner_accent": "#8B4513",
"banner_dim": "#8B7355",
"banner_text": "#2C1810",
"ui_accent": "#8B4513",
"ui_label": "#5C3D11",
"ui_ok": "#2E7D32",
"ui_error": "#C62828",
"ui_warn": "#E65100",
"prompt": "#2C1810",
"input_rule": "#8B6914",
"response_border": "#8B6914",
"session_label": "#5C3D11",
"session_border": "#A0845C",
"status_bar_bg": "#F5F0E8",
"voice_status_bg": "#F5F0E8",
"completion_menu_bg": "#F5EFE0",
"completion_menu_current_bg": "#E8DCC8",
"completion_menu_meta_bg": "#F0E8D8",
"completion_menu_meta_current_bg": "#DFCFB0",
},
"spinner": {},
"branding": {
"agent_name": "Hermes Agent",
"welcome": "Welcome to Hermes Agent! Type your message or /help for commands.",
"goodbye": "Goodbye! \u2695",
"response_label": " \u2695 Hermes ",
"prompt_symbol": "\u276f ",
"help_header": "(^_^)? Available Commands",
},
"tool_prefix": "\u250a",
},
"poseidon": {
"name": "poseidon",
"description": "Ocean-god theme — deep blue and seafoam",
@@ -767,12 +689,6 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
label = skin.get_color("ui_label", title)
warn = skin.get_color("ui_warn", "#FF8C00")
error = skin.get_color("ui_error", "#FF6B6B")
status_bg = skin.get_color("status_bar_bg", "#1a1a2e")
voice_bg = skin.get_color("voice_status_bg", status_bg)
menu_bg = skin.get_color("completion_menu_bg", "#1a1a2e")
menu_current_bg = skin.get_color("completion_menu_current_bg", "#333355")
menu_meta_bg = skin.get_color("completion_menu_meta_bg", menu_bg)
menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg)
return {
"input-area": prompt,
@@ -780,20 +696,13 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
"prompt": prompt,
"prompt-working": f"{dim} italic",
"hint": f"{dim} italic",
"status-bar": f"bg:{status_bg} {text}",
"status-bar-strong": f"bg:{status_bg} {title} bold",
"status-bar-dim": f"bg:{status_bg} {dim}",
"status-bar-good": f"bg:{status_bg} {skin.get_color('ui_ok', '#8FBC8F')} bold",
"status-bar-warn": f"bg:{status_bg} {warn} bold",
"status-bar-bad": f"bg:{status_bg} {skin.get_color('banner_accent', warn)} bold",
"status-bar-critical": f"bg:{status_bg} {error} bold",
"input-rule": input_rule,
"image-badge": f"{label} bold",
"completion-menu": f"bg:{menu_bg} {text}",
"completion-menu.completion": f"bg:{menu_bg} {text}",
"completion-menu.completion.current": f"bg:{menu_current_bg} {title}",
"completion-menu.meta.completion": f"bg:{menu_meta_bg} {dim}",
"completion-menu.meta.completion.current": f"bg:{menu_meta_current_bg} {label}",
"completion-menu": f"bg:#1a1a2e {text}",
"completion-menu.completion": f"bg:#1a1a2e {text}",
"completion-menu.completion.current": f"bg:#333355 {title}",
"completion-menu.meta.completion": f"bg:#1a1a2e {dim}",
"completion-menu.meta.completion.current": f"bg:#333355 {label}",
"clarify-border": input_rule,
"clarify-title": f"{title} bold",
"clarify-question": f"{text} bold",
@@ -811,6 +720,4 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
"approval-cmd": f"{dim} italic",
"approval-choice": dim,
"approval-selected": f"{title} bold",
"voice-status": f"bg:{voice_bg} {label}",
"voice-status-recording": f"bg:{voice_bg} {error} bold",
}
-1
View File
@@ -305,7 +305,6 @@ def show_status(args):
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():
+4 -2
View File
@@ -1,7 +1,7 @@
"""Random tips shown at CLI session start to help users discover features."""
import random
from typing import Optional
# ---------------------------------------------------------------------------
# Tip corpus — one-liners covering slash commands, CLI flags, config,
@@ -346,4 +346,6 @@ def get_random_tip(exclude_recent: int = 0) -> str:
return random.choice(TIPS)
def get_tip_count() -> int:
"""Return the total number of tips available."""
return len(TIPS)
+2 -4
View File
@@ -362,7 +362,7 @@ def _run_post_setup(post_setup_key: str):
_print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)")
elif post_setup_key == "camofox":
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser"
if not camofox_dir.exists() and shutil.which("npm"):
_print_info(" Installing Camofox browser server...")
import subprocess
@@ -376,7 +376,7 @@ def _run_post_setup(post_setup_key: str):
_print_warning(" npm install failed - run manually: npm install")
if camofox_dir.exists():
_print_info(" Start the Camofox server:")
_print_info(" npx @askjo/camofox-browser")
_print_info(" npx @askjo/camoufox-browser")
_print_info(" First run downloads the Camoufox engine (~300MB)")
_print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
elif not shutil.which("npm"):
@@ -426,8 +426,6 @@ def _get_enabled_platforms() -> List[str]:
enabled.append("slack")
if get_env_value("WHATSAPP_ENABLED"):
enabled.append("whatsapp")
if get_env_value("QQ_APP_ID"):
enabled.append("qqbot")
return enabled
+1
View File
@@ -7,6 +7,7 @@ Provides options for:
"""
import os
import platform
import shutil
import subprocess
from pathlib import Path
+33 -1212
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -237,6 +237,10 @@ def get_skills_dir() -> Path:
return get_hermes_home() / "skills"
def get_logs_dir() -> Path:
"""Return the path to the logs directory under HERMES_HOME."""
return get_hermes_home() / "logs"
def get_env_path() -> Path:
"""Return the path to the ``.env`` file under HERMES_HOME."""
@@ -292,3 +296,5 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
+6 -1
View File
@@ -79,7 +79,12 @@ def set_session_context(session_id: str) -> None:
def clear_session_context() -> None:
"""Clear the session ID for the current thread."""
"""Clear the session ID for the current thread.
Optional ``set_session_context()`` overwrites the previous value,
so explicit clearing is only needed if the thread is reused for
non-conversation work after ``run_conversation()`` returns.
"""
_session_context.session_id = None
+21 -44
View File
@@ -464,7 +464,6 @@ def handle_function_call(
session_id: Optional[str] = None,
user_task: Optional[str] = None,
enabled_tools: Optional[List[str]] = None,
skip_pre_tool_call_hook: bool = False,
) -> str:
"""
Main function call dispatcher that routes calls to the tool registry.
@@ -485,53 +484,31 @@ def handle_function_call(
# Coerce string arguments to their schema-declared types (e.g. "42"→42)
function_args = coerce_tool_args(function_name, function_args)
# Notify the read-loop tracker when a non-read/search tool runs,
# so the *consecutive* counter resets (reads after other work are fine).
if function_name not in _READ_SEARCH_TOOLS:
try:
from tools.file_tools import notify_other_tool_call
notify_other_tool_call(task_id or "default")
except Exception:
pass # file_tools may not be loaded yet
try:
if function_name in _AGENT_LOOP_TOOLS:
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
# Check plugin hooks for a block directive (unless caller already
# checked — e.g. run_agent._invoke_tool passes skip=True to
# avoid double-firing the hook).
if not skip_pre_tool_call_hook:
block_message: Optional[str] = None
try:
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
function_name,
function_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
except Exception:
pass
if block_message is not None:
return json.dumps({"error": block_message}, ensure_ascii=False)
else:
# Still fire the hook for observers — just don't check for blocking
# (the caller already did that).
try:
from hermes_cli.plugins import invoke_hook
invoke_hook(
"pre_tool_call",
tool_name=function_name,
args=function_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
except Exception:
pass
# Notify the read-loop tracker when a non-read/search tool runs,
# so the *consecutive* counter resets (reads after other work are fine).
if function_name not in _READ_SEARCH_TOOLS:
try:
from tools.file_tools import notify_other_tool_call
notify_other_tool_call(task_id or "default")
except Exception:
pass # file_tools may not be loaded yet
try:
from hermes_cli.plugins import invoke_hook
invoke_hook(
"pre_tool_call",
tool_name=function_name,
args=function_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
except Exception:
pass
if function_name == "execute_code":
# Prefer the caller-provided list so subagents can't overwrite
@@ -1,255 +0,0 @@
---
name: fitness-nutrition
description: >
Gym workout planner and nutrition tracker. Search 690+ exercises by muscle,
equipment, or category via wger. Look up macros and calories for 380,000+
foods via USDA FoodData Central. Compute BMI, TDEE, one-rep max, macro
splits, and body fat — pure Python, no pip installs. Built for anyone
chasing gains, cutting weight, or just trying to eat better.
version: 1.0.0
authors:
- haileymarshall
license: MIT
metadata:
hermes:
tags: [health, fitness, nutrition, gym, workout, diet, exercise]
category: health
prerequisites:
commands: [curl, python3]
required_environment_variables:
- name: USDA_API_KEY
prompt: "USDA FoodData Central API key (free)"
help: "Get one free at https://fdc.nal.usda.gov/api-key-signup/ — or skip to use DEMO_KEY with lower rate limits"
required_for: "higher rate limits on food/nutrition lookups (DEMO_KEY works without signup)"
optional: true
---
# Fitness & Nutrition
Expert fitness coach and sports nutritionist skill. Two data sources
plus offline calculators — everything a gym-goer needs in one place.
**Data sources (all free, no pip dependencies):**
- **wger** (https://wger.de/api/v2/) — open exercise database, 690+ exercises with muscles, equipment, images. Public endpoints need zero authentication.
- **USDA FoodData Central** (https://api.nal.usda.gov/fdc/v1/) — US government nutrition database, 380,000+ foods. `DEMO_KEY` works instantly; free signup for higher limits.
**Offline calculators (pure stdlib Python):**
- BMI, TDEE (Mifflin-St Jeor), one-rep max (Epley/Brzycki/Lombardi), macro splits, body fat % (US Navy method)
---
## When to Use
Trigger this skill when the user asks about:
- Exercises, workouts, gym routines, muscle groups, workout splits
- Food macros, calories, protein content, meal planning, calorie counting
- Body composition: BMI, body fat, TDEE, caloric surplus/deficit
- One-rep max estimates, training percentages, progressive overload
- Macro ratios for cutting, bulking, or maintenance
---
## Procedure
### Exercise Lookup (wger API)
All wger public endpoints return JSON and require no auth. Always add
`format=json` and `language=2` (English) to exercise queries.
**Step 1 — Identify what the user wants:**
- By muscle → use `/api/v2/exercise/?muscles={id}&language=2&status=2&format=json`
- By category → use `/api/v2/exercise/?category={id}&language=2&status=2&format=json`
- By equipment → use `/api/v2/exercise/?equipment={id}&language=2&status=2&format=json`
- By name → use `/api/v2/exercise/search/?term={query}&language=english&format=json`
- Full details → use `/api/v2/exerciseinfo/{exercise_id}/?format=json`
**Step 2 — Reference IDs (so you don't need extra API calls):**
Exercise categories:
| ID | Category |
|----|-------------|
| 8 | Arms |
| 9 | Legs |
| 10 | Abs |
| 11 | Chest |
| 12 | Back |
| 13 | Shoulders |
| 14 | Calves |
| 15 | Cardio |
Muscles:
| ID | Muscle | ID | Muscle |
|----|---------------------------|----|-------------------------|
| 1 | Biceps brachii | 2 | Anterior deltoid |
| 3 | Serratus anterior | 4 | Pectoralis major |
| 5 | Obliquus externus | 6 | Gastrocnemius |
| 7 | Rectus abdominis | 8 | Gluteus maximus |
| 9 | Trapezius | 10 | Quadriceps femoris |
| 11 | Biceps femoris | 12 | Latissimus dorsi |
| 13 | Brachialis | 14 | Triceps brachii |
| 15 | Soleus | | |
Equipment:
| ID | Equipment |
|----|----------------|
| 1 | Barbell |
| 3 | Dumbbell |
| 4 | Gym mat |
| 5 | Swiss Ball |
| 6 | Pull-up bar |
| 7 | none (bodyweight) |
| 8 | Bench |
| 9 | Incline bench |
| 10 | Kettlebell |
**Step 3 — Fetch and present results:**
```bash
# Search exercises by name
QUERY="$1"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$QUERY")
curl -s "https://wger.de/api/v2/exercise/search/?term=${ENCODED}&language=english&format=json" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
for s in data.get('suggestions',[])[:10]:
d=s.get('data',{})
print(f\" ID {d.get('id','?'):>4} | {d.get('name','N/A'):<35} | Category: {d.get('category','N/A')}\")
"
```
```bash
# Get full details for a specific exercise
EXERCISE_ID="$1"
curl -s "https://wger.de/api/v2/exerciseinfo/${EXERCISE_ID}/?format=json" \
| python3 -c "
import json,sys,html,re
data=json.load(sys.stdin)
trans=[t for t in data.get('translations',[]) if t.get('language')==2]
t=trans[0] if trans else data.get('translations',[{}])[0]
desc=re.sub('<[^>]+>','',html.unescape(t.get('description','N/A')))
print(f\"Exercise : {t.get('name','N/A')}\")
print(f\"Category : {data.get('category',{}).get('name','N/A')}\")
print(f\"Primary : {', '.join(m.get('name_en','') for m in data.get('muscles',[])) or 'N/A'}\")
print(f\"Secondary : {', '.join(m.get('name_en','') for m in data.get('muscles_secondary',[])) or 'none'}\")
print(f\"Equipment : {', '.join(e.get('name','') for e in data.get('equipment',[])) or 'bodyweight'}\")
print(f\"How to : {desc[:500]}\")
imgs=data.get('images',[])
if imgs: print(f\"Image : {imgs[0].get('image','')}\")
"
```
```bash
# List exercises filtering by muscle, category, or equipment
# Combine filters as needed: ?muscles=4&equipment=1&language=2&status=2
FILTER="$1" # e.g. "muscles=4" or "category=11" or "equipment=3"
curl -s "https://wger.de/api/v2/exercise/?${FILTER}&language=2&status=2&limit=20&format=json" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
print(f'Found {data.get(\"count\",0)} exercises.')
for ex in data.get('results',[]):
print(f\" ID {ex['id']:>4} | muscles: {ex.get('muscles',[])} | equipment: {ex.get('equipment',[])}\")
"
```
### Nutrition Lookup (USDA FoodData Central)
Uses `USDA_API_KEY` env var if set, otherwise falls back to `DEMO_KEY`.
DEMO_KEY = 30 requests/hour. Free signup key = 1,000 requests/hour.
```bash
# Search foods by name
FOOD="$1"
API_KEY="${USDA_API_KEY:-DEMO_KEY}"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$FOOD")
curl -s "https://api.nal.usda.gov/fdc/v1/foods/search?api_key=${API_KEY}&query=${ENCODED}&pageSize=5&dataType=Foundation,SR%20Legacy" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
foods=data.get('foods',[])
if not foods: print('No foods found.'); sys.exit()
for f in foods:
n={x['nutrientName']:x.get('value','?') for x in f.get('foodNutrients',[])}
cal=n.get('Energy','?'); prot=n.get('Protein','?')
fat=n.get('Total lipid (fat)','?'); carb=n.get('Carbohydrate, by difference','?')
print(f\"{f.get('description','N/A')}\")
print(f\" Per 100g: {cal} kcal | {prot}g protein | {fat}g fat | {carb}g carbs\")
print(f\" FDC ID: {f.get('fdcId','N/A')}\")
print()
"
```
```bash
# Detailed nutrient profile by FDC ID
FDC_ID="$1"
API_KEY="${USDA_API_KEY:-DEMO_KEY}"
curl -s "https://api.nal.usda.gov/fdc/v1/food/${FDC_ID}?api_key=${API_KEY}" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f\"Food: {d.get('description','N/A')}\")
print(f\"{'Nutrient':<40} {'Amount':>8} {'Unit'}\")
print('-'*56)
for x in sorted(d.get('foodNutrients',[]),key=lambda x:x.get('nutrient',{}).get('rank',9999)):
nut=x.get('nutrient',{}); amt=x.get('amount',0)
if amt and float(amt)>0:
print(f\" {nut.get('name',''):<38} {amt:>8} {nut.get('unitName','')}\")
"
```
### Offline Calculators
Use the helper scripts in `scripts/` for batch operations,
or run inline for single calculations:
- `python3 scripts/body_calc.py bmi <weight_kg> <height_cm>`
- `python3 scripts/body_calc.py tdee <weight_kg> <height_cm> <age> <M|F> <activity 1-5>`
- `python3 scripts/body_calc.py 1rm <weight> <reps>`
- `python3 scripts/body_calc.py macros <tdee_kcal> <cut|maintain|bulk>`
- `python3 scripts/body_calc.py bodyfat <M|F> <neck_cm> <waist_cm> [hip_cm] <height_cm>`
See `references/FORMULAS.md` for the science behind each formula.
---
## Pitfalls
- wger exercise endpoint returns **all languages by default** — always add `language=2` for English
- wger includes **unverified user submissions** — add `status=2` to only get approved exercises
- USDA `DEMO_KEY` has **30 req/hour** — add `sleep 2` between batch requests or get a free key
- USDA data is **per 100g** — remind users to scale to their actual portion size
- BMI does not distinguish muscle from fat — high BMI in muscular people is not necessarily unhealthy
- Body fat formulas are **estimates** (±3-5%) — recommend DEXA scans for precision
- 1RM formulas lose accuracy above 10 reps — use sets of 3-5 for best estimates
- wger's `exercise/search` endpoint uses `term` not `query` as the parameter name
---
## Verification
After running exercise search: confirm results include exercise names, muscle groups, and equipment.
After nutrition lookup: confirm per-100g macros are returned with kcal, protein, fat, carbs.
After calculators: sanity-check outputs (e.g. TDEE should be 1500-3500 for most adults).
---
## Quick Reference
| Task | Source | Endpoint |
|------|--------|----------|
| Search exercises by name | wger | `GET /api/v2/exercise/search/?term=&language=english` |
| Exercise details | wger | `GET /api/v2/exerciseinfo/{id}/` |
| Filter by muscle | wger | `GET /api/v2/exercise/?muscles={id}&language=2&status=2` |
| Filter by equipment | wger | `GET /api/v2/exercise/?equipment={id}&language=2&status=2` |
| List categories | wger | `GET /api/v2/exercisecategory/` |
| List muscles | wger | `GET /api/v2/muscle/` |
| Search foods | USDA | `GET /fdc/v1/foods/search?query=&dataType=Foundation,SR Legacy` |
| Food details | USDA | `GET /fdc/v1/food/{fdcId}` |
| BMI / TDEE / 1RM / macros | offline | `python3 scripts/body_calc.py` |
@@ -1,100 +0,0 @@
# Formulas Reference
Scientific references for all calculators used in the fitness-nutrition skill.
## BMI (Body Mass Index)
**Formula:** BMI = weight (kg) / height (m)²
| Category | BMI Range |
|-------------|------------|
| Underweight | < 18.5 |
| Normal | 18.5 24.9 |
| Overweight | 25.0 29.9 |
| Obese | 30.0+ |
**Limitation:** BMI does not distinguish muscle from fat. A muscular person
can have a high BMI while being lean. Use body fat % for a better picture.
Reference: Quetelet, A. (1832). Keys et al., Int J Obes (1972).
## TDEE (Total Daily Energy Expenditure)
Uses the **Mifflin-St Jeor equation** — the most accurate BMR predictor for
the general population according to the ADA (2005).
**BMR formulas:**
- Male: BMR = 10 × weight(kg) + 6.25 × height(cm) 5 × age + 5
- Female: BMR = 10 × weight(kg) + 6.25 × height(cm) 5 × age 161
**Activity multipliers:**
| Level | Description | Multiplier |
|-------|--------------------------------|------------|
| 1 | Sedentary (desk job) | 1.200 |
| 2 | Lightly active (1-3 days/wk) | 1.375 |
| 3 | Moderately active (3-5 days) | 1.550 |
| 4 | Very active (6-7 days) | 1.725 |
| 5 | Extremely active (2x/day) | 1.900 |
Reference: Mifflin et al., Am J Clin Nutr 51, 241-247 (1990).
## One-Rep Max (1RM)
Three validated formulas. Average of all three is most reliable.
- **Epley:** 1RM = w × (1 + r/30)
- **Brzycki:** 1RM = w × 36 / (37 r)
- **Lombardi:** 1RM = w × r^0.1
All formulas are most accurate for r ≤ 10. Above 10 reps, error increases.
Reference: LeSuer et al., J Strength Cond Res 11(4), 211-213 (1997).
## Macro Splits
Recommended splits based on goal:
| Goal | Protein | Fat | Carbs | Calorie Offset |
|-------------|---------|------|-------|----------------|
| Fat loss | 40% | 30% | 30% | 500 kcal |
| Maintenance | 30% | 30% | 40% | 0 |
| Lean bulk | 30% | 25% | 45% | +400 kcal |
Protein targets for muscle growth: 1.62.2 g/kg body weight per day.
Minimum fat intake: 0.5 g/kg to support hormone production.
Conversion: Protein = 4 kcal/g, Fat = 9 kcal/g, Carbs = 4 kcal/g.
Reference: Morton et al., Br J Sports Med 52, 376384 (2018).
## Body Fat % (US Navy Method)
**Male:**
BF% = 86.010 × log₁₀(waist neck) 70.041 × log₁₀(height) + 36.76
**Female:**
BF% = 163.205 × log₁₀(waist + hip neck) 97.684 × log₁₀(height) 78.387
All measurements in centimeters.
| Category | Male | Female |
|--------------|--------|--------|
| Essential | 2-5% | 10-13% |
| Athletic | 6-13% | 14-20% |
| Fitness | 14-17% | 21-24% |
| Average | 18-24% | 25-31% |
| Obese | 25%+ | 32%+ |
Accuracy: ±3-5% compared to DEXA. Measure at the navel (waist),
at the Adam's apple (neck), and widest point (hip, females only).
Reference: Hodgdon & Beckett, Naval Health Research Center (1984).
## APIs
- wger: https://wger.de/api/v2/ — AGPL-3.0, exercise data is CC-BY-SA 3.0
- USDA FoodData Central: https://api.nal.usda.gov/fdc/v1/ — public domain (CC0 1.0)
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
"""
body_calc.py All-in-one fitness calculator.
Subcommands:
bmi <weight_kg> <height_cm>
tdee <weight_kg> <height_cm> <age> <M|F> <activity 1-5>
1rm <weight> <reps>
macros <tdee_kcal> <cut|maintain|bulk>
bodyfat <M|F> <neck_cm> <waist_cm> [hip_cm] <height_cm>
No external dependencies stdlib only.
"""
import sys
import math
def bmi(weight_kg, height_cm):
h = height_cm / 100
val = weight_kg / (h * h)
if val < 18.5:
cat = "Underweight"
elif val < 25:
cat = "Normal weight"
elif val < 30:
cat = "Overweight"
else:
cat = "Obese"
print(f"BMI: {val:.1f}{cat}")
print()
print("Ranges:")
print(f" Underweight : < 18.5")
print(f" Normal : 18.5 24.9")
print(f" Overweight : 25.0 29.9")
print(f" Obese : 30.0+")
def tdee(weight_kg, height_cm, age, sex, activity):
if sex.upper() == "M":
bmr = 10 * weight_kg + 6.25 * height_cm - 5 * age + 5
else:
bmr = 10 * weight_kg + 6.25 * height_cm - 5 * age - 161
multipliers = {
1: ("Sedentary (desk job, no exercise)", 1.2),
2: ("Lightly active (1-3 days/week)", 1.375),
3: ("Moderately active (3-5 days/week)", 1.55),
4: ("Very active (6-7 days/week)", 1.725),
5: ("Extremely active (athlete + physical job)", 1.9),
}
label, mult = multipliers.get(activity, ("Moderate", 1.55))
total = bmr * mult
print(f"BMR (Mifflin-St Jeor): {bmr:.0f} kcal/day")
print(f"Activity: {label} (x{mult})")
print(f"TDEE: {total:.0f} kcal/day")
print()
print("Calorie targets:")
print(f" Aggressive cut (-750): {total - 750:.0f} kcal/day")
print(f" Fat loss (-500): {total - 500:.0f} kcal/day")
print(f" Mild cut (-250): {total - 250:.0f} kcal/day")
print(f" Maintenance : {total:.0f} kcal/day")
print(f" Lean bulk (+250): {total + 250:.0f} kcal/day")
print(f" Bulk (+500): {total + 500:.0f} kcal/day")
def one_rep_max(weight, reps):
if reps < 1:
print("Error: reps must be at least 1.")
sys.exit(1)
if reps == 1:
print(f"1RM = {weight:.1f} (actual single)")
return
epley = weight * (1 + reps / 30)
brzycki = weight * (36 / (37 - reps)) if reps < 37 else 0
lombardi = weight * (reps ** 0.1)
avg = (epley + brzycki + lombardi) / 3
print(f"Estimated 1RM ({weight} x {reps} reps):")
print(f" Epley : {epley:.1f}")
print(f" Brzycki : {brzycki:.1f}")
print(f" Lombardi : {lombardi:.1f}")
print(f" Average : {avg:.1f}")
print()
print("Training percentages off average 1RM:")
for pct, rep_range in [
(100, "1"), (95, "1-2"), (90, "3-4"), (85, "4-6"),
(80, "6-8"), (75, "8-10"), (70, "10-12"),
(65, "12-15"), (60, "15-20"),
]:
print(f" {pct:>3}% = {avg * pct / 100:>7.1f} (~{rep_range} reps)")
def macros(tdee_kcal, goal):
goal = goal.lower()
if goal in ("cut", "lose", "deficit"):
cals = tdee_kcal - 500
p, f, c = 0.40, 0.30, 0.30
label = "Fat Loss (-500 kcal)"
elif goal in ("bulk", "gain", "surplus"):
cals = tdee_kcal + 400
p, f, c = 0.30, 0.25, 0.45
label = "Lean Bulk (+400 kcal)"
else:
cals = tdee_kcal
p, f, c = 0.30, 0.30, 0.40
label = "Maintenance"
prot_g = cals * p / 4
fat_g = cals * f / 9
carb_g = cals * c / 4
print(f"Goal: {label}")
print(f"Daily calories: {cals:.0f} kcal")
print()
print(f" Protein : {prot_g:>6.0f}g ({p * 100:.0f}%) = {prot_g * 4:.0f} kcal")
print(f" Fat : {fat_g:>6.0f}g ({f * 100:.0f}%) = {fat_g * 9:.0f} kcal")
print(f" Carbs : {carb_g:>6.0f}g ({c * 100:.0f}%) = {carb_g * 4:.0f} kcal")
print()
print(f"Per meal (3 meals): P {prot_g / 3:.0f}g | F {fat_g / 3:.0f}g | C {carb_g / 3:.0f}g")
print(f"Per meal (4 meals): P {prot_g / 4:.0f}g | F {fat_g / 4:.0f}g | C {carb_g / 4:.0f}g")
def bodyfat(sex, neck_cm, waist_cm, hip_cm, height_cm):
sex = sex.upper()
if sex == "M":
if waist_cm <= neck_cm:
print("Error: waist must be larger than neck."); sys.exit(1)
bf = 86.010 * math.log10(waist_cm - neck_cm) - 70.041 * math.log10(height_cm) + 36.76
else:
if (waist_cm + hip_cm) <= neck_cm:
print("Error: waist + hip must be larger than neck."); sys.exit(1)
bf = 163.205 * math.log10(waist_cm + hip_cm - neck_cm) - 97.684 * math.log10(height_cm) - 78.387
print(f"Estimated body fat: {bf:.1f}%")
if sex == "M":
ranges = [
(6, "Essential fat (2-5%)"),
(14, "Athletic (6-13%)"),
(18, "Fitness (14-17%)"),
(25, "Average (18-24%)"),
]
default = "Obese (25%+)"
else:
ranges = [
(14, "Essential fat (10-13%)"),
(21, "Athletic (14-20%)"),
(25, "Fitness (21-24%)"),
(32, "Average (25-31%)"),
]
default = "Obese (32%+)"
cat = default
for threshold, label in ranges:
if bf < threshold:
cat = label
break
print(f"Category: {cat}")
print(f"Method: US Navy circumference formula")
def usage():
print(__doc__)
sys.exit(1)
def main():
if len(sys.argv) < 2:
usage()
cmd = sys.argv[1].lower()
try:
if cmd == "bmi":
bmi(float(sys.argv[2]), float(sys.argv[3]))
elif cmd == "tdee":
tdee(
float(sys.argv[2]), float(sys.argv[3]),
int(sys.argv[4]), sys.argv[5], int(sys.argv[6]),
)
elif cmd in ("1rm", "orm"):
one_rep_max(float(sys.argv[2]), int(sys.argv[3]))
elif cmd == "macros":
macros(float(sys.argv[2]), sys.argv[3])
elif cmd == "bodyfat":
sex = sys.argv[2]
if sex.upper() == "M":
bodyfat(sex, float(sys.argv[3]), float(sys.argv[4]), 0, float(sys.argv[5]))
else:
bodyfat(sex, float(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5]), float(sys.argv[6]))
else:
print(f"Unknown command: {cmd}")
usage()
except (IndexError, ValueError) as e:
print(f"Error: {e}")
usage()
if __name__ == "__main__":
main()
@@ -1,86 +0,0 @@
#!/usr/bin/env python3
"""
nutrition_search.py Search USDA FoodData Central for nutrition info.
Usage:
python3 nutrition_search.py "chicken breast"
python3 nutrition_search.py "rice" "eggs" "broccoli"
echo -e "oats\\nbanana\\nwhey protein" | python3 nutrition_search.py -
Reads USDA_API_KEY from environment, falls back to DEMO_KEY.
No external dependencies.
"""
import sys
import os
import json
import time
import urllib.request
import urllib.parse
import urllib.error
API_KEY = os.environ.get("USDA_API_KEY", "DEMO_KEY")
BASE = "https://api.nal.usda.gov/fdc/v1"
def search(query, max_results=3):
encoded = urllib.parse.quote(query)
url = (
f"{BASE}/foods/search?api_key={API_KEY}"
f"&query={encoded}&pageSize={max_results}"
f"&dataType=Foundation,SR%20Legacy"
)
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
except Exception as e:
print(f" API error: {e}", file=sys.stderr)
return None
def display(food):
nutrients = {n["nutrientName"]: n.get("value", "?") for n in food.get("foodNutrients", [])}
cal = nutrients.get("Energy", "?")
prot = nutrients.get("Protein", "?")
fat = nutrients.get("Total lipid (fat)", "?")
carb = nutrients.get("Carbohydrate, by difference", "?")
fib = nutrients.get("Fiber, total dietary", "?")
sug = nutrients.get("Sugars, total including NLEA", "?")
print(f" {food.get('description', 'N/A')}")
print(f" Calories : {cal} kcal")
print(f" Protein : {prot}g")
print(f" Fat : {fat}g")
print(f" Carbs : {carb}g (fiber: {fib}g, sugar: {sug}g)")
print(f" FDC ID : {food.get('fdcId', 'N/A')}")
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
if sys.argv[1] == "-":
queries = [line.strip() for line in sys.stdin if line.strip()]
else:
queries = sys.argv[1:]
for query in queries:
print(f"\n--- {query.upper()} (per 100g) ---")
data = search(query, max_results=2)
if not data or not data.get("foods"):
print(" No results found.")
else:
for food in data["foods"]:
display(food)
print()
if len(queries) > 1:
time.sleep(1) # respect rate limits
if API_KEY == "DEMO_KEY":
print("\nTip: using DEMO_KEY (30 req/hr). Set USDA_API_KEY for 1000 req/hr.")
print("Free signup: https://fdc.nal.usda.gov/api-key-signup/")
if __name__ == "__main__":
main()
@@ -1,226 +0,0 @@
---
name: drug-discovery
description: >
Pharmaceutical research assistant for drug discovery workflows. Search
bioactive compounds on ChEMBL, calculate drug-likeness (Lipinski Ro5, QED,
TPSA, synthetic accessibility), look up drug-drug interactions via
OpenFDA, interpret ADMET profiles, and assist with lead optimization.
Use for medicinal chemistry questions, molecule property analysis, clinical
pharmacology, and open-science drug research.
version: 1.0.0
author: bennytimz
license: MIT
metadata:
hermes:
tags: [science, chemistry, pharmacology, research, health]
prerequisites:
commands: [curl, python3]
---
# Drug Discovery & Pharmaceutical Research
You are an expert pharmaceutical scientist and medicinal chemist with deep
knowledge of drug discovery, cheminformatics, and clinical pharmacology.
Use this skill for all pharma/chemistry research tasks.
## Core Workflows
### 1 — Bioactive Compound Search (ChEMBL)
Search ChEMBL (the world's largest open bioactivity database) for compounds
by target, activity, or molecule name. No API key required.
```bash
# Search compounds by target name (e.g. "EGFR", "COX-2", "ACE")
TARGET="$1"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$TARGET")
curl -s "https://www.ebi.ac.uk/chembl/api/data/target/search?q=${ENCODED}&format=json" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
targets=data.get('targets',[])[:5]
for t in targets:
print(f\"ChEMBL ID : {t.get('target_chembl_id')}\")
print(f\"Name : {t.get('pref_name')}\")
print(f\"Type : {t.get('target_type')}\")
print()
"
```
```bash
# Get bioactivity data for a ChEMBL target ID
TARGET_ID="$1" # e.g. CHEMBL203
curl -s "https://www.ebi.ac.uk/chembl/api/data/activity?target_chembl_id=${TARGET_ID}&pchembl_value__gte=6&limit=10&format=json" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
acts=data.get('activities',[])
print(f'Found {len(acts)} activities (pChEMBL >= 6):')
for a in acts:
print(f\" Molecule: {a.get('molecule_chembl_id')} | {a.get('standard_type')}: {a.get('standard_value')} {a.get('standard_units')} | pChEMBL: {a.get('pchembl_value')}\")
"
```
```bash
# Look up a specific molecule by ChEMBL ID
MOL_ID="$1" # e.g. CHEMBL25 (aspirin)
curl -s "https://www.ebi.ac.uk/chembl/api/data/molecule/${MOL_ID}?format=json" \
| python3 -c "
import json,sys
m=json.load(sys.stdin)
props=m.get('molecule_properties',{}) or {}
print(f\"Name : {m.get('pref_name','N/A')}\")
print(f\"SMILES : {m.get('molecule_structures',{}).get('canonical_smiles','N/A') if m.get('molecule_structures') else 'N/A'}\")
print(f\"MW : {props.get('full_mwt','N/A')} Da\")
print(f\"LogP : {props.get('alogp','N/A')}\")
print(f\"HBD : {props.get('hbd','N/A')}\")
print(f\"HBA : {props.get('hba','N/A')}\")
print(f\"TPSA : {props.get('psa','N/A')} Ų\")
print(f\"Ro5 violations: {props.get('num_ro5_violations','N/A')}\")
print(f\"QED : {props.get('qed_weighted','N/A')}\")
"
```
### 2 — Drug-Likeness Calculation (Lipinski Ro5 + Veber)
Assess any molecule against established oral bioavailability rules using
PubChem's free property API — no RDKit install needed.
```bash
COMPOUND="$1"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$COMPOUND")
curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${ENCODED}/property/MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA,InChIKey/JSON" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
props=data['PropertyTable']['Properties'][0]
mw = float(props.get('MolecularWeight', 0))
logp = float(props.get('XLogP', 0))
hbd = int(props.get('HBondDonorCount', 0))
hba = int(props.get('HBondAcceptorCount', 0))
rot = int(props.get('RotatableBondCount', 0))
tpsa = float(props.get('TPSA', 0))
print('=== Lipinski Rule of Five (Ro5) ===')
print(f' MW {mw:.1f} Da {\"✓\" if mw<=500 else \"✗ VIOLATION (>500)\"}')
print(f' LogP {logp:.2f} {\"✓\" if logp<=5 else \"✗ VIOLATION (>5)\"}')
print(f' HBD {hbd} {\"✓\" if hbd<=5 else \"✗ VIOLATION (>5)\"}')
print(f' HBA {hba} {\"✓\" if hba<=10 else \"✗ VIOLATION (>10)\"}')
viol = sum([mw>500, logp>5, hbd>5, hba>10])
print(f' Violations: {viol}/4 {\"→ Likely orally bioavailable\" if viol<=1 else \"→ Poor oral bioavailability predicted\"}')
print()
print('=== Veber Oral Bioavailability Rules ===')
print(f' TPSA {tpsa:.1f} Ų {\"✓\" if tpsa<=140 else \"✗ VIOLATION (>140)\"}')
print(f' Rot. bonds {rot} {\"✓\" if rot<=10 else \"✗ VIOLATION (>10)\"}')
print(f' Both rules met: {\"Yes → good oral absorption predicted\" if tpsa<=140 and rot<=10 else \"No → reduced oral absorption\"}')
"
```
### 3 — Drug Interaction & Safety Lookup (OpenFDA)
```bash
DRUG="$1"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$DRUG")
curl -s "https://api.fda.gov/drug/label.json?search=drug_interactions:\"${ENCODED}\"&limit=3" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
results=data.get('results',[])
if not results:
print('No interaction data found in FDA labels.')
sys.exit()
for r in results[:2]:
brand=r.get('openfda',{}).get('brand_name',['Unknown'])[0]
generic=r.get('openfda',{}).get('generic_name',['Unknown'])[0]
interactions=r.get('drug_interactions',['N/A'])[0]
print(f'--- {brand} ({generic}) ---')
print(interactions[:800])
print()
"
```
```bash
DRUG="$1"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$DRUG")
curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduct:\"${ENCODED}\"&count=patient.reaction.reactionmeddrapt.exact&limit=10" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
results=data.get('results',[])
if not results:
print('No adverse event data found.')
sys.exit()
print(f'Top adverse events reported:')
for r in results[:10]:
print(f\" {r['count']:>5}x {r['term']}\")
"
```
### 4 — PubChem Compound Search
```bash
COMPOUND="$1"
ENCODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$COMPOUND")
CID=$(curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${ENCODED}/cids/TXT" | head -1 | tr -d '[:space:]')
echo "PubChem CID: $CID"
curl -s "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${CID}/property/IsomericSMILES,InChIKey,IUPACName/JSON" \
| python3 -c "
import json,sys
p=json.load(sys.stdin)['PropertyTable']['Properties'][0]
print(f\"IUPAC Name : {p.get('IUPACName','N/A')}\")
print(f\"SMILES : {p.get('IsomericSMILES','N/A')}\")
print(f\"InChIKey : {p.get('InChIKey','N/A')}\")
"
```
### 5 — Target & Disease Literature (OpenTargets)
```bash
GENE="$1"
curl -s -X POST "https://api.platform.opentargets.org/api/v4/graphql" \
-H "Content-Type: application/json" \
-d "{\"query\":\"{ search(queryString: \\\"${GENE}\\\", entityNames: [\\\"target\\\"], page: {index: 0, size: 1}) { hits { id score object { ... on Target { id approvedSymbol approvedName associatedDiseases(page: {index: 0, size: 5}) { count rows { score disease { id name } } } } } } } }\"}" \
| python3 -c "
import json,sys
data=json.load(sys.stdin)
hits=data.get('data',{}).get('search',{}).get('hits',[])
if not hits:
print('Target not found.')
sys.exit()
obj=hits[0]['object']
print(f\"Target: {obj.get('approvedSymbol')} — {obj.get('approvedName')}\")
assoc=obj.get('associatedDiseases',{})
print(f\"Associated with {assoc.get('count',0)} diseases. Top associations:\")
for row in assoc.get('rows',[]):
print(f\" Score {row['score']:.3f} | {row['disease']['name']}\")
"
```
## Reasoning Guidelines
When analysing drug-likeness or molecular properties, always:
1. **State raw values first** — MW, LogP, HBD, HBA, TPSA, RotBonds
2. **Apply rule sets** — Ro5 (Lipinski), Veber, Ghose filter where relevant
3. **Flag liabilities** — metabolic hotspots, hERG risk, high TPSA for CNS penetration
4. **Suggest optimizations** — bioisosteric replacements, prodrug strategies, ring truncation
5. **Cite the source API** — ChEMBL, PubChem, OpenFDA, or OpenTargets
For ADMET questions, reason through Absorption, Distribution, Metabolism, Excretion, Toxicity systematically. See references/ADMET_REFERENCE.md for detailed guidance.
## Important Notes
- All APIs are free, public, require no authentication
- ChEMBL rate limits: add sleep 1 between batch requests
- FDA data reflects reported adverse events, not necessarily causation
- Always recommend consulting a licensed pharmacist or physician for clinical decisions
## Quick Reference
| Task | API | Endpoint |
|------|-----|----------|
| Find target | ChEMBL | `/api/data/target/search?q=` |
| Get bioactivity | ChEMBL | `/api/data/activity?target_chembl_id=` |
| Molecule properties | PubChem | `/rest/pug/compound/name/{name}/property/` |
| Drug interactions | OpenFDA | `/drug/label.json?search=drug_interactions:` |
| Adverse events | OpenFDA | `/drug/event.json?search=...&count=reaction` |
| Gene-disease | OpenTargets | GraphQL POST `/api/v4/graphql` |
@@ -1,66 +0,0 @@
# ADMET Reference Guide
Comprehensive reference for Absorption, Distribution, Metabolism, Excretion, and Toxicity (ADMET) analysis in drug discovery.
## Drug-Likeness Rule Sets
### Lipinski's Rule of Five (Ro5)
| Property | Threshold |
|----------|-----------|
| Molecular Weight (MW) | ≤ 500 Da |
| Lipophilicity (LogP) | ≤ 5 |
| H-Bond Donors (HBD) | ≤ 5 |
| H-Bond Acceptors (HBA) | ≤ 10 |
Reference: Lipinski et al., Adv. Drug Deliv. Rev. 23, 325 (1997).
### Veber's Oral Bioavailability Rules
| Property | Threshold |
|----------|-----------|
| TPSA | ≤ 140 Ų |
| Rotatable Bonds | ≤ 10 |
Reference: Veber et al., J. Med. Chem. 45, 26152623 (2002).
### CNS Penetration (BBB)
| Property | CNS-Optimal |
|----------|-------------|
| MW | ≤ 400 Da |
| LogP | 13 |
| TPSA | < 90 Ų |
| HBD | ≤ 3 |
## CYP450 Metabolism
| Isoform | % Drugs | Notable inhibitors |
|---------|---------|-------------------|
| CYP3A4 | ~50% | Grapefruit, ketoconazole |
| CYP2D6 | ~25% | Fluoxetine, paroxetine |
| CYP2C9 | ~15% | Fluconazole, amiodarone |
| CYP2C19 | ~10% | Omeprazole, fluoxetine |
| CYP1A2 | ~5% | Fluvoxamine, ciprofloxacin |
## hERG Cardiac Toxicity Risk
Structural alerts: basic nitrogen (pKa 79) + aromatic ring + hydrophobic moiety, LogP > 3.5 + basic amine.
Mitigation: reduce basicity, introduce polar groups, break planarity.
## Common Bioisosteric Replacements
| Original | Bioisostere | Purpose |
|----------|-------------|---------|
| -COOH | -tetrazole, -SO₂NH₂ | Improve permeability |
| -OH (phenol) | -F, -CN | Reduce glucuronidation |
| Phenyl | Pyridine, thiophene | Reduce LogP |
| Ester | -CONHR | Reduce hydrolysis |
## Key APIs
- ChEMBL: https://www.ebi.ac.uk/chembl/api/data/
- PubChem: https://pubchem.ncbi.nlm.nih.gov/rest/pug/
- OpenFDA: https://api.fda.gov/drug/
- OpenTargets GraphQL: https://api.platform.opentargets.org/api/v4/graphql
@@ -1,53 +0,0 @@
#!/usr/bin/env python3
"""
chembl_target.py Search ChEMBL for a target and retrieve top active compounds.
Usage: python3 chembl_target.py "EGFR" --min-pchembl 7 --limit 20
No external dependencies.
"""
import sys, json, time, argparse
import urllib.request, urllib.parse, urllib.error
BASE = "https://www.ebi.ac.uk/chembl/api/data"
def get(endpoint):
try:
req = urllib.request.Request(f"{BASE}{endpoint}", headers={"Accept":"application/json"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
except Exception as e:
print(f"API error: {e}", file=sys.stderr); return None
def main():
parser = argparse.ArgumentParser(description="ChEMBL target → active compounds")
parser.add_argument("target")
parser.add_argument("--min-pchembl", type=float, default=6.0)
parser.add_argument("--limit", type=int, default=10)
args = parser.parse_args()
enc = urllib.parse.quote(args.target)
data = get(f"/target/search?q={enc}&limit=5&format=json")
if not data or not data.get("targets"):
print("No targets found."); sys.exit(1)
t = data["targets"][0]
tid = t.get("target_chembl_id","")
print(f"\nTarget: {t.get('pref_name')} ({tid})")
print(f"Type: {t.get('target_type')} | Organism: {t.get('organism','N/A')}")
print(f"\nFetching compounds with pChEMBL ≥ {args.min_pchembl}...\n")
acts = get(f"/activity?target_chembl_id={tid}&pchembl_value__gte={args.min_pchembl}&assay_type=B&limit={args.limit}&order_by=-pchembl_value&format=json")
if not acts or not acts.get("activities"):
print("No activities found."); sys.exit(0)
print(f"{'Molecule':<18} {'pChEMBL':>8} {'Type':<12} {'Value':<10} {'Units'}")
print("-"*65)
seen = set()
for a in acts["activities"]:
mid = a.get("molecule_chembl_id","N/A")
if mid in seen: continue
seen.add(mid)
print(f"{mid:<18} {str(a.get('pchembl_value','N/A')):>8} {str(a.get('standard_type','N/A')):<12} {str(a.get('standard_value','N/A')):<10} {a.get('standard_units','N/A')}")
time.sleep(0.1)
print(f"\nTotal: {len(seen)} unique molecules")
if __name__ == "__main__": main()
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""
ro5_screen.py Batch Lipinski Ro5 + Veber screening via PubChem API.
Usage: python3 ro5_screen.py aspirin ibuprofen paracetamol
No external dependencies beyond stdlib.
"""
import sys, json, time, argparse
import urllib.request, urllib.parse, urllib.error
BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name"
PROPS = "MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA"
def fetch(name):
url = f"{BASE}/{urllib.parse.quote(name)}/property/{PROPS}/JSON"
try:
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())["PropertyTable"]["Properties"][0]
except Exception:
return None
def check(p):
mw,logp,hbd,hba,rot,tpsa = float(p.get("MolecularWeight",0)),float(p.get("XLogP",0)),int(p.get("HBondDonorCount",0)),int(p.get("HBondAcceptorCount",0)),int(p.get("RotatableBondCount",0)),float(p.get("TPSA",0))
v = sum([mw>500,logp>5,hbd>5,hba>10])
return dict(mw=mw,logp=logp,hbd=hbd,hba=hba,rot=rot,tpsa=tpsa,violations=v,ro5=v<=1,veber=tpsa<=140 and rot<=10,ok=v<=1 and tpsa<=140 and rot<=10)
def report(name, r):
if not r: print(f"{name:30s} — not found"); return
s = "✓ PASS" if r["ok"] else "✗ FAIL"
flags = (f" [Ro5 violations:{r['violations']}]" if not r["ro5"] else "") + (" [Veber fail]" if not r["veber"] else "")
print(f"{s} {name:28s} MW={r['mw']:.0f} LogP={r['logp']:.2f} HBD={r['hbd']} HBA={r['hba']} TPSA={r['tpsa']:.0f} RotB={r['rot']}{flags}")
def main():
compounds = sys.stdin.read().splitlines() if len(sys.argv)<2 or sys.argv[1]=="-" else sys.argv[1:]
print(f"\n{'Status':<8} {'Compound':<30} Properties\n" + "-"*85)
passed = 0
for name in compounds:
props = fetch(name.strip())
result = check(props) if props else None
report(name.strip(), result)
if result and result["ok"]: passed += 1
time.sleep(0.3)
print(f"\nSummary: {passed}/{len(compounds)} passed Ro5 + Veber.\n")
if __name__ == "__main__": main()

Some files were not shown because too many files have changed in this diff Show More